diff --git a/src/renderer/components/thread/ChatPane/parts/MessageList.tsx b/src/renderer/components/thread/ChatPane/parts/MessageList.tsx index 442b801b3..ce2c44ece 100644 --- a/src/renderer/components/thread/ChatPane/parts/MessageList.tsx +++ b/src/renderer/components/thread/ChatPane/parts/MessageList.tsx @@ -89,6 +89,7 @@ interface MessageListProps { onPointerDownCapture?: PointerEventHandler; onKeyDownCapture?: KeyboardEventHandler; onStartReached?: () => void; + drawDistance?: number; /** * Reverting is transcript-local today. Disable it while a turn is live so * late provider events cannot append onto a truncated timeline. @@ -144,6 +145,7 @@ export function MessageList({ onPointerDownCapture, onKeyDownCapture, onStartReached, + drawDistance, canRevertCheckpoints = true, checkpointGuard, checkpointActions, @@ -442,6 +444,7 @@ export function MessageList({ }} maintainScrollAtEndThreshold={0} maintainVisibleContentPosition={{ data: true, size: true }} + {...(drawDistance !== undefined ? { drawDistance } : {})} {...(onStartReached ? { onStartReached, onStartReachedThreshold: 0.75 } : {})} recycleItems={false} renderItem={({ item: entry, index }) => ( diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx index 25b8b052f..7ae9d68c7 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectLocation, ToolCallPayload } from "@/shared/contracts"; import { AppProvider } from "@/renderer/components/ui/provider"; @@ -398,7 +398,7 @@ describe("SubAgentContent", () => { ); const dialog = await screen.findByRole("region"); - expect(within(dialog).getByText("Inspect the renderer.")).toBeInTheDocument(); + expect(await within(dialog).findByText("Inspect the renderer.")).toBeInTheDocument(); expect(within(dialog).getByText(byTextContent("2 commands"))).toBeInTheDocument(); expect( await within(dialog).findByRole("heading", { name: "Child result" }, { timeout: 5_000 }), @@ -407,6 +407,299 @@ describe("SubAgentContent", () => { expect(within(dialog).queryByText("internal plan")).not.toBeInTheDocument(); }); + it("defers the first child timeline render until idle", async () => { + const threadId = "thread-1"; + const parentItem = makeSubAgentItem("parent-1"); + const children = Array.from({ length: 20 }, (_entry, index) => + makeChildItem(`assistant-${index}`, parentItem.id, "assistant_message", undefined, { + assistant_text: `Child message ${index}`, + }), + ); + + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...children.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(children.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + + render(); + + expect(screen.queryByText("Child message 19")).not.toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Loading" })).toBeInTheDocument(); + expect(await screen.findByText("Child message 18")).toBeInTheDocument(); + expect(screen.getByText("Child message 19")).toBeInTheDocument(); + }); + + it("keeps the pending idle reveal while child entries continue arriving", async () => { + const threadId = "thread-1"; + const parentItem = makeSubAgentItem("parent-1"); + const children = Array.from({ length: 8 }, (_entry, index) => + makeChildItem(`assistant-${index}`, parentItem.id, "assistant_message", undefined, { + assistant_text: `Child message ${index}`, + }), + ); + const idleCallbacks: IdleRequestCallback[] = []; + const cancelIdleCallback = vi.fn<(id: number) => void>(); + vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }); + vi.stubGlobal("cancelIdleCallback", cancelIdleCallback); + + try { + useAppStore.setState({ + runtimeItemIdsByThread: { [threadId]: [parentItem.id, children[0]!.id] }, + runtimeItemsByIdByThread: { + [threadId]: { [parentItem.id]: parentItem, [children[0]!.id]: children[0]! }, + }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + + render(); + await waitFor(() => expect(idleCallbacks).toHaveLength(1)); + + act(() => { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...children.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(children.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 2 }, + }); + }); + + expect(idleCallbacks).toHaveLength(1); + expect(cancelIdleCallback).not.toHaveBeenCalled(); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + expect(screen.getByText("Child message 7")).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not restart the timer fallback when child entries arrive", async () => { + vi.useFakeTimers(); + vi.stubGlobal("requestIdleCallback", undefined); + const threadId = "thread-1"; + const parentItem = makeSubAgentItem("parent-1"); + const children = Array.from({ length: 8 }, (_entry, index) => + makeChildItem(`assistant-${index}`, parentItem.id, "assistant_message", undefined, { + assistant_text: `Child message ${index}`, + }), + ); + + try { + useAppStore.setState({ + runtimeItemIdsByThread: { [threadId]: [parentItem.id, children[0]!.id] }, + runtimeItemsByIdByThread: { + [threadId]: { [parentItem.id]: parentItem, [children[0]!.id]: children[0]! }, + }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + render(); + + await act(async () => vi.advanceTimersByTime(10)); + act(() => { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...children.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(children.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 2 }, + }); + }); + await act(async () => vi.advanceTimersByTime(6)); + + expect(screen.getByText("Child message 7")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + + it("reveals a 250-call tool group in bounded idle batches", async () => { + const threadId = "thread-1"; + const runningParent = makeSubAgentItem("parent-1"); + const parentItem: RuntimeChatItem = { + ...runningParent, + state: "completed", + payload: { ...(runningParent.payload as ToolCallPayload), status: "success" }, + }; + const commands = Array.from({ length: 250 }, (_entry, index) => + makeChildItem(`command-${index}`, parentItem.id, "command_execution", { + command: `echo ${index}`, + }), + ); + const idleCallbacks: IdleRequestCallback[] = []; + vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }); + vi.stubGlobal("cancelIdleCallback", vi.fn()); + + try { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...commands.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(commands.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + + render(); + + expect(screen.getByRole("img", { name: "Loading" })).toBeInTheDocument(); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + expect(screen.getByText(byTextContent("40 commands"))).toBeInTheDocument(); + + for (let index = 0; index < 6; index += 1) { + await waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0)); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + } + expect(await screen.findByText(byTextContent("250 commands"))).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("bounds a large append after a tool group was fully revealed", async () => { + const threadId = "thread-1"; + const parentItem = makeSubAgentItem("parent-1"); + const commands = Array.from({ length: 201 }, (_entry, index) => + makeChildItem(`command-${index}`, parentItem.id, "command_execution", { + command: `echo ${index}`, + }), + ); + const idleCallbacks: IdleRequestCallback[] = []; + vi.stubGlobal("requestIdleCallback", (callback: IdleRequestCallback) => { + idleCallbacks.push(callback); + return idleCallbacks.length; + }); + vi.stubGlobal("cancelIdleCallback", vi.fn()); + + try { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, commands[0]!.id, commands[1]!.id], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + [commands[0]!.id]: commands[0]!, + [commands[1]!.id]: commands[1]!, + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 1 }, + }); + render(); + + await waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0)); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + expect(screen.getByText(byTextContent("2 commands"))).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...commands.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(commands.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 2 }, + }); + }); + expect(screen.getByText(byTextContent("2 commands"))).toBeInTheDocument(); + + await waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0)); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + expect(screen.getByText(byTextContent("42 commands"))).toBeInTheDocument(); + + for (let index = 0; index < 4; index += 1) { + await waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0)); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + } + expect(screen.getByText(byTextContent("201 commands"))).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, commands[0]!.id, commands[1]!.id], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + [commands[0]!.id]: commands[0]!, + [commands[1]!.id]: commands[1]!, + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 3 }, + }); + }); + expect(screen.getByText(byTextContent("2 commands"))).toBeInTheDocument(); + + act(() => { + useAppStore.setState({ + runtimeItemIdsByThread: { + [threadId]: [parentItem.id, ...commands.map((item) => item.id)], + }, + runtimeItemsByIdByThread: { + [threadId]: { + [parentItem.id]: parentItem, + ...Object.fromEntries(commands.map((item) => [item.id, item])), + }, + }, + runtimeStructuralVersionByThread: { [threadId]: 4 }, + }); + }); + expect(screen.getByText(byTextContent("2 commands"))).toBeInTheDocument(); + + await waitFor(() => expect(idleCallbacks.length).toBeGreaterThan(0)); + await act(async () => { + idleCallbacks.shift()?.({ didTimeout: false, timeRemaining: () => 50 }); + }); + expect(screen.getByText(byTextContent("42 commands"))).toBeInTheDocument(); + } finally { + vi.unstubAllGlobals(); + } + }); + it("does not present the final child tool group as live after the subagent completes", async () => { const threadId = "thread-1"; const runningParent = makeSubAgentItem("parent-1"); @@ -444,10 +737,9 @@ describe("SubAgentContent", () => { ); const dialog = await screen.findByRole("region"); - expect(within(dialog).getByText(byTextContent("2 commands")).closest("button")).toHaveAttribute( - "aria-expanded", - "false", - ); + expect( + (await within(dialog).findByText(byTextContent("2 commands"))).closest("button"), + ).toHaveAttribute("aria-expanded", "false"); }); it("shows an explicit terminal status for a cancelled Crossagent", async () => { diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx index 1f6cb3e65..850eff0c7 100644 --- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx +++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentOverlay.tsx @@ -1,4 +1,12 @@ -import { useEffect, useId, useRef, type ReactNode } from "react"; +import { + startTransition, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { Surface } from "@heroui/react"; import { Trans, useLingui } from "@lingui/react/macro"; import { Bot, X } from "lucide-react"; @@ -14,6 +22,7 @@ import { } from "@/renderer/state/slices/runtimeEventSlice"; import { guiChatFontCssVars } from "../../chatFontVars"; import { + EMPTY_THREAD_TIMELINE_ENTRIES, getChildTimelineEntriesStoreSelector, getRuntimeItemStoreSelector, type ChatTimelineEntry, @@ -27,6 +36,9 @@ import { deriveToolDisplay, isCrossagentTool, isWorkflowTool } from "./toolDispl import { WorkflowOverlayBody } from "./WorkflowOverlayBody"; import { parseWorkflowInfo, type WorkflowInfo } from "./workflowDisplay"; +const CHILD_TIMELINE_ENTRY_BATCH = 4; +const CHILD_TOOL_GROUP_ITEM_BATCH = 40; + interface SubAgentOpenControllerProps { threadId: string; projectLocation?: ProjectLocation; @@ -167,6 +179,7 @@ export function SubAgentContent({ /> ) : ( (null); const scrollControlsRef = useRef(null); const virtualScrollToBottomRef = useRef<(() => void) | null>(null); @@ -363,10 +377,11 @@ function ChildList({
scrollControlsRef.current?.onContentHeightChange()} @@ -394,7 +409,11 @@ function ChildList({ ) : null } emptyContent={ - workflow ? ( + entries.length > 0 ? ( +
+ +
+ ) : workflow ? ( ) : (

@@ -413,7 +432,7 @@ function ChildList({ scrollRef={scrollRef} contentRef={contentRef} layoutChangeToken={null} - tailEntryId={entries.at(-1)?.id ?? null} + tailEntryId={visibleEntries.at(-1)?.id ?? null} threadId={`${threadId}:subagent:${parentItemId}`} tailLoaderVisible={turn !== null} initialScrollSettled @@ -425,6 +444,154 @@ function ChildList({ ); } +function useDeferredChildEntries( + entries: readonly ChatTimelineEntry[], +): readonly ChatTimelineEntry[] { + const [reveal, setReveal] = useState(() => ({ + entries: { count: 0, fromStart: false }, + groups: {}, + })); + const latestEntriesRef = useRef(entries); + const cancelPendingRevealRef = useRef<(() => void) | null>(null); + latestEntriesRef.current = entries; + const visibleEntries = selectRevealedChildEntries(entries, reveal); + const needsReveal = childEntriesNeedReveal(entries, visibleEntries); + + useLayoutEffect(() => { + setReveal((current) => clampChildRevealAfterShrink(current, entries)); + }, [entries]); + + useEffect(() => { + if (!needsReveal || cancelPendingRevealRef.current) return; + const revealNext = () => { + cancelPendingRevealRef.current = null; + startTransition(() => { + setReveal((current) => advanceChildReveal(current, latestEntriesRef.current)); + }); + }; + if (typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(revealNext, { timeout: 50 }); + cancelPendingRevealRef.current = () => window.cancelIdleCallback?.(idleId); + return; + } + const timeoutId = window.setTimeout(revealNext, 16); + cancelPendingRevealRef.current = () => window.clearTimeout(timeoutId); + }, [entries, needsReveal, reveal]); + + useEffect( + () => () => { + cancelPendingRevealRef.current?.(); + cancelPendingRevealRef.current = null; + }, + [], + ); + + return visibleEntries; +} + +interface RevealRange { + count: number; + fromStart: boolean; +} + +interface ChildRevealState { + entries: RevealRange; + groups: Record; +} + +function clampChildRevealAfterShrink( + current: ChildRevealState, + entries: readonly ChatTimelineEntry[], +): ChildRevealState { + const groupsById = new Map( + entries + .filter((entry) => entry.kind === "tool_call_group") + .map((entry) => [entry.id, entry] as const), + ); + const groupShrank = Object.entries(current.groups).some(([id, range]) => { + const entry = groupsById.get(id); + return !entry || range.count > entry.itemIds.length; + }); + if (current.entries.count <= entries.length && !groupShrank) return current; + + const groups: Record = {}; + for (const [id, range] of Object.entries(current.groups)) { + const entry = groupsById.get(id); + if (!entry) continue; + groups[id] = { ...range, count: Math.min(range.count, entry.itemIds.length) }; + } + return { + entries: { ...current.entries, count: Math.min(current.entries.count, entries.length) }, + groups, + }; +} + +function advanceChildReveal( + current: ChildRevealState, + entries: readonly ChatTimelineEntry[], +): ChildRevealState { + const entryCount = Math.min( + entries.length, + Math.min(current.entries.count, entries.length) + CHILD_TIMELINE_ENTRY_BATCH, + ); + const entryRange = { + count: entryCount, + fromStart: current.entries.fromStart || entryCount >= entries.length, + }; + const visibleEntries = selectEntriesInRange(entries, entryRange); + const groups: Record = {}; + for (const entry of visibleEntries) { + if (entry.kind !== "tool_call_group") continue; + const previous = current.groups[entry.id]; + const itemCount = Math.min( + entry.itemIds.length, + Math.min(previous?.count ?? 0, entry.itemIds.length) + CHILD_TOOL_GROUP_ITEM_BATCH, + ); + groups[entry.id] = { + count: itemCount, + fromStart: (previous?.fromStart ?? false) || itemCount >= entry.itemIds.length, + }; + } + return { entries: entryRange, groups }; +} + +function selectRevealedChildEntries( + entries: readonly ChatTimelineEntry[], + reveal: ChildRevealState, +): readonly ChatTimelineEntry[] { + if (reveal.entries.count === 0) return EMPTY_THREAD_TIMELINE_ENTRIES; + return selectEntriesInRange(entries, reveal.entries).map((entry) => { + if (entry.kind !== "tool_call_group") return entry; + const range = reveal.groups[entry.id] ?? { + count: Math.min(CHILD_TOOL_GROUP_ITEM_BATCH, entry.itemIds.length), + fromStart: false, + }; + const itemIds = selectEntriesInRange(entry.itemIds, range); + return itemIds.length === entry.itemIds.length ? entry : { ...entry, itemIds }; + }); +} + +function selectEntriesInRange(entries: readonly T[], range: RevealRange): readonly T[] { + const count = Math.min(range.count, entries.length); + if (count === 0) return []; + return range.fromStart ? entries.slice(0, count) : entries.slice(-count); +} + +function childEntriesNeedReveal( + entries: readonly ChatTimelineEntry[], + visibleEntries: readonly ChatTimelineEntry[], +): boolean { + if (visibleEntries.length !== entries.length) return true; + return visibleEntries.some((entry, index) => { + const source = entries[index]; + return ( + source?.kind === "tool_call_group" && + entry.kind === "tool_call_group" && + entry.itemIds.length !== source.itemIds.length + ); + }); +} + function CrossagentStatusFooter({ status }: { status: "completed" | "failed" | "cancelled" }) { const { t } = useLingui(); const label = diff --git a/src/renderer/state/slices/runtimeEventReducer.ts b/src/renderer/state/slices/runtimeEventReducer.ts index c4856dc3c..a90f8edee 100644 --- a/src/renderer/state/slices/runtimeEventReducer.ts +++ b/src/renderer/state/slices/runtimeEventReducer.ts @@ -55,9 +55,36 @@ export function applyRuntimeEventBatchesToState( for (const batch of batches) { if (batch.events.length === 0) continue; const { threadId, events } = batch; + const coalescedEvents = coalesceRuntimeEvents(events); let batchChanged = false; - for (const event of coalesceRuntimeEvents(events)) { + for (let eventIndex = 0; eventIndex < coalescedEvents.length;) { + const event = coalescedEvents[eventIndex]!; + if (isRuntimeItemEvent(event)) { + let itemRunEnd = eventIndex + 1; + while ( + itemRunEnd < coalescedEvents.length && + isRuntimeItemEvent(coalescedEvents[itemRunEnd]!) + ) { + itemRunEnd += 1; + } + if (itemRunEnd - eventIndex > 1) { + const itemBatch = applyRuntimeItemEvents( + nextState, + threadId, + coalescedEvents.slice(eventIndex, itemRunEnd), + true, + ); + if (itemBatch) { + nextState = itemBatch.state; + batchChanged = true; + if (itemBatch.structuralChanged) structuralBumpThreadIds.add(threadId); + } + eventIndex = itemRunEnd; + continue; + } + } const patch = applyRuntimeEventToRuntimeState(nextState, threadId, event); + eventIndex += 1; if (Object.keys(patch).length === 0) continue; nextState = { ...nextState, ...patch }; const reopenPatch = reopenGuiTurnForLiveRuntimeActivity(nextState, threadId, event); @@ -88,6 +115,168 @@ export function applyRuntimeEventBatchesToState( }; } +/** + * Buffered sub-agent history is delivered as one batch containing hundreds of + * item lifecycle events. Clone the thread collections once for that replay; + * cloning the growing id array and item map for every event makes hydration + * quadratic and blocks the panel's first paint. + */ +function applyRuntimeItemEvents( + state: RuntimeEventState, + threadId: string, + events: RuntimeEvent[], + applyReopen: boolean, +): { state: RuntimeEventState; structuralChanged: boolean } | null { + if (!events.every(isRuntimeItemEvent)) return null; + + const existingIds = state.runtimeItemIdsByThread[threadId] ?? []; + const existingItems = state.runtimeItemsByIdByThread[threadId] ?? {}; + const preserveItemsMap = events.length === 1 && events[0]?.type === "content.delta"; + const draft: RuntimeItemDraft = { + itemIds: existingIds, + itemIdsChanged: false, + items: preserveItemsMap ? existingItems : { ...existingItems }, + }; + let nextState: RuntimeEventState = { + ...state, + runtimeItemsByIdByThread: { + ...state.runtimeItemsByIdByThread, + [threadId]: draft.items, + }, + }; + let changed = false; + let structuralChanged = false; + + for (const event of events) { + const itemIdsChangedBefore = draft.itemIdsChanged; + const eventChanged = applyRuntimeItemEvent(draft, event); + if (!eventChanged) continue; + changed = true; + if (eventAffectsStructuralVersion(event)) structuralChanged = true; + if (!itemIdsChangedBefore && draft.itemIdsChanged) { + nextState = { + ...nextState, + runtimeItemIdsByThread: { + ...nextState.runtimeItemIdsByThread, + [threadId]: draft.itemIds, + }, + }; + } + if (applyReopen) { + const reopenPatch = reopenGuiTurnForLiveRuntimeActivity(nextState, threadId, event); + if (Object.keys(reopenPatch).length > 0) { + nextState = { ...nextState, ...reopenPatch }; + } + } + } + + return changed ? { state: nextState, structuralChanged } : null; +} + +interface RuntimeItemDraft { + itemIds: readonly string[]; + itemIdsChanged: boolean; + items: Record; +} + +function mutableRuntimeItemIds(draft: RuntimeItemDraft): string[] { + if (!draft.itemIdsChanged) { + draft.itemIds = [...draft.itemIds]; + draft.itemIdsChanged = true; + } + return draft.itemIds as string[]; +} + +function applyRuntimeItemEvent( + draft: RuntimeItemDraft, + event: Extract< + RuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" | "content.delta" } + >, +): boolean { + switch (event.type) { + case "item.started": { + if (draft.items[event.itemId]) return false; + const item: RuntimeChatItem = { + id: event.itemId, + type: event.itemType, + state: "started", + ...(isDelegatedAgentTool(event.payload as ToolCallPayload | undefined) + ? { startedAt: Date.now() } + : {}), + payload: event.payload, + streams: {}, + observedLive: true, + ...(event.parentItemId ? { parentItemId: event.parentItemId } : {}), + }; + mutableRuntimeItemIds(draft).push(event.itemId); + draft.items[event.itemId] = item; + return true; + } + case "item.updated": { + const prev = draft.items[event.itemId]; + if (!prev) return false; + const payload = mergePayload(prev.payload, event.payload); + draft.items[event.itemId] = { + ...prev, + state: prev.state === "completed" ? "completed" : "updated", + ...(prev.startedAt === undefined && + isDelegatedAgentTool(payload as ToolCallPayload | undefined) + ? { startedAt: Date.now() } + : {}), + payload, + }; + return true; + } + case "item.completed": { + const prev = draft.items[event.itemId]; + if (!prev) return false; + const next: RuntimeChatItem = { + ...prev, + state: "completed", + ...(prev.startedAt !== undefined ? { completedAt: Date.now() } : {}), + payload: + event.payload !== undefined ? mergePayload(prev.payload, event.payload) : prev.payload, + }; + if (next.type === "reasoning" && !(next.streams.reasoning_text ?? "").trim()) { + const itemIds = mutableRuntimeItemIds(draft); + itemIds.splice(itemIds.indexOf(event.itemId), 1); + delete draft.items[event.itemId]; + } else { + draft.items[event.itemId] = next; + } + return true; + } + case "content.delta": { + const prev = draft.items[event.itemId]; + if (!prev) return false; + draft.items[event.itemId] = { + ...prev, + state: prev.state === "completed" ? "completed" : "updated", + streams: { + ...prev.streams, + [event.stream]: (prev.streams[event.stream] ?? "") + event.delta, + }, + }; + return true; + } + } +} + +function isRuntimeItemEvent( + event: RuntimeEvent, +): event is Extract< + RuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" | "content.delta" } +> { + return ( + event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed" || + event.type === "content.delta" + ); +} + function reopenGuiTurnForLiveRuntimeActivity( state: RuntimeEventState, threadId: string, @@ -214,6 +403,10 @@ function applyRuntimeEventToRuntimeState( threadId: string, event: RuntimeEvent, ): Partial { + if (isRuntimeItemEvent(event)) { + return applyRuntimeItemEvents(state, threadId, [event], false)?.state ?? {}; + } + switch (event.type) { case "session.started": case "session.exited": @@ -247,113 +440,6 @@ function applyRuntimeEventToRuntimeState( return { ...closeTurnPatch, ...pruneTrailingInterruptedReasoningItems(state, threadId) }; } - case "item.started": { - const existingIds = state.runtimeItemIdsByThread[threadId] ?? []; - const existingItems = state.runtimeItemsByIdByThread[threadId] ?? {}; - if (existingItems[event.itemId]) return {}; - const item: RuntimeChatItem = { - id: event.itemId, - type: event.itemType, - state: "started", - ...(isDelegatedAgentTool(event.payload as ToolCallPayload | undefined) - ? { startedAt: Date.now() } - : {}), - payload: event.payload, - streams: {}, - observedLive: true, - ...(event.parentItemId ? { parentItemId: event.parentItemId } : {}), - }; - return { - runtimeItemIdsByThread: { - ...state.runtimeItemIdsByThread, - [threadId]: [...existingIds, event.itemId], - }, - runtimeItemsByIdByThread: { - ...state.runtimeItemsByIdByThread, - [threadId]: { ...existingItems, [event.itemId]: item }, - }, - }; - } - - case "item.updated": { - const items = state.runtimeItemsByIdByThread[threadId]; - const prev = items?.[event.itemId]; - if (!prev || !items) return {}; - const payload = mergePayload(prev.payload, event.payload); - const next: RuntimeChatItem = { - ...prev, - state: prev.state === "completed" ? "completed" : "updated", - ...(prev.startedAt === undefined && - isDelegatedAgentTool(payload as ToolCallPayload | undefined) - ? { startedAt: Date.now() } - : {}), - payload, - }; - return { - runtimeItemsByIdByThread: { - ...state.runtimeItemsByIdByThread, - [threadId]: { ...items, [event.itemId]: next }, - }, - }; - } - - case "item.completed": { - const items = state.runtimeItemsByIdByThread[threadId]; - const prev = items?.[event.itemId]; - if (!prev || !items) return {}; - const next: RuntimeChatItem = { - ...prev, - state: "completed", - ...(prev.startedAt !== undefined ? { completedAt: Date.now() } : {}), - payload: - event.payload !== undefined ? mergePayload(prev.payload, event.payload) : prev.payload, - }; - // A reasoning item that completes with no streamed text is a bracket - // some agents emit before producing nothing — keeping it in the - // timeline would split otherwise-adjacent tool calls into separate - // groups. Drop it from the data so grouping naturally fuses them. - if (next.type === "reasoning" && !(next.streams.reasoning_text ?? "").trim()) { - const ids = state.runtimeItemIdsByThread[threadId]; - if (!ids) return {}; - const { [event.itemId]: _dropped, ...remaining } = items; - return { - runtimeItemIdsByThread: { - ...state.runtimeItemIdsByThread, - [threadId]: ids.filter((id) => id !== event.itemId), - }, - runtimeItemsByIdByThread: { - ...state.runtimeItemsByIdByThread, - [threadId]: remaining, - }, - }; - } - return { - runtimeItemsByIdByThread: { - ...state.runtimeItemsByIdByThread, - [threadId]: { ...items, [event.itemId]: next }, - }, - }; - } - - case "content.delta": { - const items = state.runtimeItemsByIdByThread[threadId]; - const prev = items?.[event.itemId]; - if (!prev || !items) return {}; - const prevStream = prev.streams[event.stream] ?? ""; - const next: RuntimeChatItem = { - ...prev, - state: prev.state === "completed" ? "completed" : "updated", - streams: { ...prev.streams, [event.stream]: prevStream + event.delta }, - }; - items[event.itemId] = next; - return { - runtimeItemsByIdByThread: { - ...state.runtimeItemsByIdByThread, - [threadId]: items, - }, - }; - } - case "context.updated": { const prev = state.runtimeContextByThread[threadId]; const next = mergeContextUsage(prev, event.usage); diff --git a/src/renderer/state/slices/runtimeEventSlice.test.ts b/src/renderer/state/slices/runtimeEventSlice.test.ts index 5ca4965fd..5a29e1534 100644 --- a/src/renderer/state/slices/runtimeEventSlice.test.ts +++ b/src/renderer/state/slices/runtimeEventSlice.test.ts @@ -763,4 +763,185 @@ describe("runtimeEventSlice.applyRuntimeEvent", () => { "world", ); }); + + it("hydrates a large subagent lifecycle batch with final item state intact", () => { + const events: RuntimeEvent[] = Array.from({ length: 250 }, (_item, index) => { + const itemId = `child-${index}`; + return [ + { + type: "item.started" as const, + threadId: "t1", + itemId, + itemType: "tool_call" as const, + parentItemId: "subagent-1", + payload: { name: `tool-${index}`, status: "running" as const }, + }, + { + type: "item.updated" as const, + threadId: "t1", + itemId, + payload: { title: `Tool ${index}` }, + }, + { + type: "item.completed" as const, + threadId: "t1", + itemId, + payload: { status: "success" as const }, + }, + ]; + }).flat(); + + applyBatch("t1", events); + + const state = store.getState(); + expect(state.runtimeItemIdsByThread["t1"]).toHaveLength(250); + expect(state.runtimeItemsByIdByThread["t1"]?.["child-249"]).toMatchObject({ + state: "completed", + parentItemId: "subagent-1", + payload: { name: "tool-249", title: "Tool 249", status: "success" }, + }); + expect(state.runtimeStructuralVersionByThread["t1"]).toBe(1); + }); + + it("keeps lifecycle runs batched around an interleaved usage event", () => { + const events: RuntimeEvent[] = Array.from({ length: 250 }, (_item, index) => { + const itemId = `child-${index}`; + return [ + { + type: "item.started" as const, + threadId: "t1", + itemId, + itemType: "tool_call" as const, + parentItemId: "subagent-1", + payload: { name: `tool-${index}`, status: "running" as const }, + }, + { + type: "item.completed" as const, + threadId: "t1", + itemId, + payload: { status: "success" as const }, + }, + ]; + }).flat(); + events.splice(250, 0, { + type: "usage.spent", + threadId: "t1", + usage: { + counterKind: "cumulative", + counter: 1, + scopeId: "child-thread", + epoch: 0, + sampleId: "sample-1", + }, + }); + + applyBatch("t1", events); + + const state = store.getState(); + expect(state.runtimeItemIdsByThread["t1"]).toHaveLength(250); + expect(state.runtimeItemsByIdByThread["t1"]?.["child-0"]?.state).toBe("completed"); + expect(state.runtimeItemsByIdByThread["t1"]?.["child-249"]?.state).toBe("completed"); + expect(state.runtimeStructuralVersionByThread["t1"]).toBe(1); + }); + + it("preserves item edge cases within one mutable batch draft", () => { + applyBatch("t1", [ + { + type: "item.started", + threadId: "t1", + itemId: "empty-reasoning", + itemType: "reasoning", + }, + { + type: "item.started", + threadId: "t1", + itemId: "empty-reasoning", + itemType: "reasoning", + }, + { + type: "item.updated", + threadId: "t1", + itemId: "missing", + payload: { ignored: true }, + }, + { + type: "item.completed", + threadId: "t1", + itemId: "empty-reasoning", + }, + { + type: "item.started", + threadId: "t1", + itemId: "assistant", + itemType: "assistant_message", + }, + { + type: "content.delta", + threadId: "t1", + itemId: "assistant", + stream: "assistant_text", + delta: "preserved", + }, + { + type: "item.completed", + threadId: "t1", + itemId: "assistant", + }, + ]); + + const state = store.getState(); + expect(state.runtimeItemIdsByThread["t1"]).toEqual(["assistant"]); + expect(state.runtimeItemsByIdByThread["t1"]?.["empty-reasoning"]).toBeUndefined(); + expect(state.runtimeItemsByIdByThread["t1"]?.assistant).toMatchObject({ + state: "completed", + streams: { assistant_text: "preserved" }, + }); + expect(state.runtimeStructuralVersionByThread["t1"]).toBe(1); + }); + + it("does not mark a multi-item delta batch as structurally changed", () => { + for (const itemId of ["child-1", "child-2"]) { + apply("t1", { + type: "item.started", + threadId: "t1", + itemId, + itemType: "assistant_message", + }); + } + const before = store.getState(); + const structuralVersion = before.runtimeStructuralVersionByThread["t1"]; + const itemIds = before.runtimeItemIdsByThread["t1"]; + + applyBatch("t1", [ + { + type: "content.delta", + threadId: "t1", + itemId: "child-1", + stream: "assistant_text", + delta: "one", + }, + { + type: "item.started", + threadId: "t1", + itemId: "child-1", + itemType: "assistant_message", + }, + { + type: "item.completed", + threadId: "t1", + itemId: "missing-child", + }, + { + type: "content.delta", + threadId: "t1", + itemId: "child-2", + stream: "assistant_text", + delta: "two", + }, + ]); + + const after = store.getState(); + expect(after.runtimeItemIdsByThread["t1"]).toBe(itemIds); + expect(after.runtimeStructuralVersionByThread["t1"]).toBe(structuralVersion); + }); });