diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 85f72405d..2a1d9e994 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -1,5 +1,6 @@ import { CHAT_CONTENT_MAX_WIDTH } from "@features/sessions/constants"; import { useContextUsage } from "@features/sessions/hooks/useContextUsage"; +import { useConversationItems } from "@features/sessions/hooks/useConversationItems"; import { useConversationSearch } from "@features/sessions/hooks/useConversationSearch"; import { SessionTaskIdProvider } from "@features/sessions/hooks/useSessionTaskId"; import { @@ -24,11 +25,7 @@ import { Box, Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import type { AcpMessage } from "@shared/types/session-events"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - buildConversationItems, - type ConversationItem, - type TurnContext, -} from "./buildConversationItems"; +import type { ConversationItem, TurnContext } from "./buildConversationItems"; import { ConversationSearchBar } from "./ConversationSearchBar"; import { GitActionMessage } from "./GitActionMessage"; import { GitActionResult } from "./GitActionResult"; @@ -82,19 +79,21 @@ export function ConversationView({ const [showScrollButton, setShowScrollButton] = useState(false); const debugLogsCloudRuns = useSettingsStore((s) => s.debugLogsCloudRuns); const showDebugLogs = debugLogsCloudRuns; + const contextUsage = useContextUsage(events); + // Streaming appends one event per token. The parse is incremental — each + // event is handled once and completed turns are reused by reference — so per + // token the work tracks the active turn, not the whole thread. We feed + // `events` directly (no frame-throttle) so a sent message's optimistic->real + // swap is never delayed past the frame the store commits it. const { items: conversationItems, lastTurnInfo, isCompacting, - } = useMemo( - () => - buildConversationItems(events, isPromptPending, { - showDebugLogs, - }), - [events, isPromptPending, showDebugLogs], - ); + } = useConversationItems(events, isPromptPending, { + showDebugLogs, + }); const firstUserMessageIdRef = useRef(undefined); if (firstUserMessageIdRef.current === undefined) { diff --git a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts index fbd0d1ee4..9501c2bc8 100644 --- a/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts +++ b/apps/code/src/renderer/features/sessions/components/buildConversationItems.ts @@ -79,6 +79,8 @@ interface ProgressCardState { steps: Step[]; isActive: boolean; }; + /** Index in `items` where this card sits. */ + itemIndex: number; } interface TurnState { @@ -94,9 +96,13 @@ interface TurnState { itemCount: number; } -interface ItemBuilder { +export interface ItemBuilder { items: ConversationItem[]; currentTurn: TurnState | null; + /** Index in `items` where the current turn's first item sits. Lets an + * incremental consumer treat everything before it (completed turns) as + * frozen and only re-derive the active turn. */ + currentTurnStartIndex: number; pendingPrompts: Map; shellExecutes: Map; isCompacting: boolean; @@ -106,18 +112,25 @@ interface ItemBuilder { * event for the same id mutates the same card, regardless of which turn is * currently active. */ progressCards: Map; + /** Lowest item index touched by a progress event since it was last reset. + * An incremental consumer resets this before feeding a batch of events and + * reads it after to detect a card being mutated inside an already frozen + * (completed) turn, which would otherwise go unseen. */ + lowestTouchedProgressIndex: number; } -function createItemBuilder(): ItemBuilder { +export function createItemBuilder(): ItemBuilder { let idCounter = 0; return { items: [], currentTurn: null, + currentTurnStartIndex: 0, pendingPrompts: new Map(), shellExecutes: new Map(), isCompacting: false, nextId: () => idCounter++, progressCards: new Map(), + lowestTouchedProgressIndex: Number.POSITIVE_INFINITY, }; } @@ -130,7 +143,7 @@ function isThoughtItem( ); } -function markThoughtCompletion(items: ConversationItem[]) { +export function markThoughtCompletion(items: ConversationItem[]) { const seenContexts = new Set(); for (let i = items.length - 1; i >= 0; i--) { @@ -172,23 +185,53 @@ export function buildConversationItems( const b = createItemBuilder(); for (const event of events) { - const msg = event.message; + processEvent(b, event, options); + } - if (isJsonRpcNotification(msg)) { - handleNotification(b, msg, event.ts, options); - continue; - } + finalizeBuilder(b, isPromptPending); - if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { - handlePromptRequest(b, msg, event.ts); - continue; - } + const lastTurnInfo = readLastTurnInfo(b); - if (isJsonRpcResponse(msg) && b.pendingPrompts.has(msg.id)) { - handlePromptResponse(b, msg, event.ts); - } + return { items: b.items, lastTurnInfo, isCompacting: b.isCompacting }; +} + +/** + * Apply one raw event to the builder. This is the append-only core: it never + * runs end-of-stream finalization, so it is safe to call incrementally as new + * events arrive without corrupting prior state. + */ +export function processEvent( + b: ItemBuilder, + event: AcpMessage, + options?: BuildConversationOptions, +) { + const msg = event.message; + + if (isJsonRpcNotification(msg)) { + handleNotification(b, msg, event.ts, options); + return; } + if (isJsonRpcRequest(msg) && msg.method === "session/prompt") { + handlePromptRequest(b, msg, event.ts); + return; + } + + if (isJsonRpcResponse(msg) && b.pendingPrompts.has(msg.id)) { + handlePromptResponse(b, msg, event.ts); + } +} + +/** + * End-of-stream finalization: speculative completions that assume no further + * events arrive. Mutates the builder in place, so an incremental consumer must + * only apply it to a snapshot it is about to read, never to state it will keep + * feeding events into. + */ +export function finalizeBuilder( + b: ItemBuilder, + isPromptPending: boolean | null, +) { // Only mark unresolved prompts as cancelled when we actively track prompt // state (local sessions). For cloud sessions isPromptPending is // null, meaning that the response hasn't streamed "in" yet @@ -207,16 +250,16 @@ export function buildConversationItems( } markThoughtCompletion(b.items); +} - const lastTurnInfo: LastTurnInfo | null = b.currentTurn +export function readLastTurnInfo(b: ItemBuilder): LastTurnInfo | null { + return b.currentTurn ? { isComplete: b.currentTurn.isComplete, durationMs: b.currentTurn.durationMs, stopReason: b.currentTurn.stopReason, } : null; - - return { items: b.items, lastTurnInfo, isCompacting: b.isCompacting }; } function handlePromptRequest( @@ -250,6 +293,7 @@ function handlePromptRequest( turnComplete: false, }; + b.currentTurnStartIndex = b.items.length; b.currentTurn = { id: turnId, promptId: msg.id, @@ -460,6 +504,7 @@ function ensureProgressCardForGroup( const card: ProgressCardState = { steps: new Map(), renderItem, + itemIndex: b.items.length, }; b.progressCards.set(group, card); pushItem(b, renderItem); @@ -487,6 +532,9 @@ function handleProgress(b: ItemBuilder, rawParams: unknown, ts: number) { const status = normalizeStepStatus(params.status); const card = ensureProgressCardForGroup(b, params.group, ts); if (!card) return; + if (card.itemIndex < b.lowestTouchedProgressIndex) { + b.lowestTouchedProgressIndex = card.itemIndex; + } card.steps.set(params.step, { key: params.step, status, @@ -525,6 +573,7 @@ function markCompactingStatusComplete(b: ItemBuilder) { function ensureImplicitTurn(b: ItemBuilder, ts: number) { if (b.currentTurn) return; + b.currentTurnStartIndex = b.items.length; const turnId = `turn-${ts}-implicit`; const toolCalls = new Map(); const childItems = new Map(); diff --git a/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts b/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts new file mode 100644 index 000000000..de4b8750c --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.test.ts @@ -0,0 +1,360 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { describe, expect, it } from "vitest"; +import { + type BuildResult, + buildConversationItems, + type ConversationItem, + type TurnContext, +} from "./buildConversationItems"; +import { createIncrementalConversationBuilder } from "./incrementalConversationItems"; + +// --- event builders ------------------------------------------------------- + +function updateMsg(ts: number, update: unknown): AcpMessage { + return { + type: "acp_message", + ts, + message: { jsonrpc: "2.0", method: "session/update", params: { update } }, + }; +} + +function userPromptMsg(ts: number, id: number, text: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + id, + method: "session/prompt", + params: { prompt: [{ type: "text", text }] }, + }, + }; +} + +function promptResponseMsg( + ts: number, + id: number, + stopReason = "end_turn", +): AcpMessage { + return { + type: "acp_message", + ts, + message: { jsonrpc: "2.0", id, result: { stopReason } }, + }; +} + +function turnCompleteMsg(ts: number, stopReason = "end_turn"): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/turn_complete", + params: { sessionId: "s", stopReason }, + }, + }; +} + +function consoleMsg(ts: number, message: string, level = "info"): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/console", + params: { level, message }, + }, + }; +} + +function progressMsg( + ts: number, + step: string, + status: string, + label: string, + group = "setup", +): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_posthog/progress", + params: { step, status, label, group }, + }, + }; +} + +function shellExecuteMsg(ts: number, id: string, command: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "_array/user_shell_execute", + params: { id, command, cwd: "/repo" }, + }, + }; +} + +const agentChunk = (ts: number, text: string) => + updateMsg(ts, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text }, + }); + +const thoughtChunk = (ts: number, text: string) => + updateMsg(ts, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text }, + }); + +const toolCallMsg = ( + ts: number, + toolCallId: string, + extra: Record = {}, +) => + updateMsg(ts, { + sessionUpdate: "tool_call", + toolCallId, + kind: "execute", + status: "pending", + title: toolCallId, + ...extra, + }); + +const toolUpdateMsg = ( + ts: number, + toolCallId: string, + extra: Record, +) => updateMsg(ts, { sessionUpdate: "tool_call_update", toolCallId, ...extra }); + +const childToolCallMsg = ( + ts: number, + toolCallId: string, + parentToolCallId: string, +) => + updateMsg(ts, { + sessionUpdate: "tool_call", + toolCallId, + kind: "read", + status: "pending", + title: toolCallId, + _meta: { claudeCode: { parentToolCallId } }, + }); + +// --- normalization (cycle-free, Map-resolved) ----------------------------- + +function normContext(ctx: TurnContext) { + return { + turnComplete: ctx.turnComplete, + turnCancelled: ctx.turnCancelled, + toolCalls: [...ctx.toolCalls.entries()].sort(byKey), + childItems: [...ctx.childItems.entries()] + .sort(byKey) + .map(([k, arr]) => [k, arr.map(normChild)]), + }; +} + +function byKey(a: [string, unknown], b: [string, unknown]) { + return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; +} + +function normChild(item: ConversationItem) { + if (item.type === "session_update") { + const { turnContext: _drop, ...rest } = item; + return rest; + } + return item; +} + +function normItem(item: ConversationItem) { + if (item.type === "session_update") { + const { turnContext, ...rest } = item; + return { ...rest, turnContext: normContext(turnContext) }; + } + return item; +} + +function normalize(result: BuildResult) { + return { + items: result.items.map(normItem), + lastTurnInfo: result.lastTurnInfo, + isCompacting: result.isCompacting, + }; +} + +function assertEquivalentAcrossPrefixes( + events: AcpMessage[], + isPromptPending: boolean | null, +) { + const inc = createIncrementalConversationBuilder(); + for (let k = 1; k <= events.length; k++) { + const prefix = events.slice(0, k); + const incremental = inc.update(prefix, isPromptPending); + const full = buildConversationItems(prefix, isPromptPending); + expect(normalize(incremental)).toEqual(normalize(full)); + } +} + +// --- scenarios ------------------------------------------------------------ + +const SCENARIOS: Record = { + "single local turn": [ + userPromptMsg(1, 1, "hello"), + agentChunk(2, "hi "), + agentChunk(3, "there"), + promptResponseMsg(4, 1), + ], + "multi-turn with tools": [ + userPromptMsg(1, 1, "do a thing"), + thoughtChunk(2, "thinking..."), + agentChunk(3, "working "), + toolCallMsg(4, "t1"), + toolUpdateMsg(5, "t1", { + status: "completed", + content: [{ type: "content", content: { type: "text", text: "ok" } }], + }), + agentChunk(6, "done"), + promptResponseMsg(7, 1), + userPromptMsg(8, 2, "another"), + agentChunk(9, "second turn"), + promptResponseMsg(10, 2), + ], + "implicit cloud turn": [ + agentChunk(1, "streaming "), + agentChunk(2, "without "), + agentChunk(3, "a prompt"), + turnCompleteMsg(4), + ], + "parent tool with children": [ + userPromptMsg(1, 1, "spawn agent"), + toolCallMsg(2, "task1", { _meta: { claudeCode: { toolName: "Task" } } }), + childToolCallMsg(3, "child1", "task1"), + childToolCallMsg(4, "child2", "task1"), + toolUpdateMsg(5, "task1", { status: "completed" }), + promptResponseMsg(6, 1), + ], + "progress single group": [ + userPromptMsg(1, 1, "setup"), + progressMsg(2, "a", "in_progress", "Step A"), + progressMsg(3, "b", "in_progress", "Step B"), + progressMsg(4, "a", "completed", "Step A"), + progressMsg(5, "b", "completed", "Step B"), + agentChunk(6, "ready"), + promptResponseMsg(7, 1), + ], + "progress card updated across turn boundary": [ + userPromptMsg(1, 1, "first"), + progressMsg(2, "a", "in_progress", "Step A", "g1"), + promptResponseMsg(3, 1), + userPromptMsg(4, 2, "second"), + // late completion for g1 reaches back into the (now frozen) first turn + progressMsg(5, "a", "completed", "Step A", "g1"), + agentChunk(6, "ok"), + promptResponseMsg(7, 2), + ], + "console and shell execute": [ + consoleMsg(1, "boot"), + shellExecuteMsg(2, "sh1", "ls"), + userPromptMsg(3, 1, "go"), + consoleMsg(4, "running"), + agentChunk(5, "out"), + promptResponseMsg(6, 1), + ], +}; + +const EQUIVALENCE_CASES = Object.entries(SCENARIOS).flatMap(([name, events]) => + ([true, false, null] as const).map((pending) => ({ name, events, pending })), +); + +describe("createIncrementalConversationBuilder", () => { + it.each(EQUIVALENCE_CASES)( + "matches buildConversationItems at every prefix — $name (pending=$pending)", + ({ events, pending }) => { + assertEquivalentAcrossPrefixes(events, pending); + }, + ); + + it("keeps completed-turn item references stable while the active turn streams", () => { + const inc = createIncrementalConversationBuilder(); + const base = [ + userPromptMsg(1, 1, "first"), + agentChunk(2, "answer one"), + promptResponseMsg(3, 1), + userPromptMsg(4, 2, "second"), + agentChunk(5, "A"), + ]; + const r1 = inc.update(base, true); + // [u1, a1, u2, a2] + expect(r1.items).toHaveLength(4); + + const next = [...base, agentChunk(6, "B")]; + const r2 = inc.update(next, true); + + // Completed turn 1: identical object references — no re-render. + expect(r2.items[0]).toBe(r1.items[0]); + expect(r2.items[1]).toBe(r1.items[1]); + // Active turn's static user message also stays referentially stable. + expect(r2.items[2]).toBe(r1.items[2]); + // Active streaming message is re-derived and its text has grown. + expect(r2.items[3]).not.toBe(r1.items[3]); + const active = r2.items[3]; + if (active.type !== "session_update" || !("content" in active.update)) { + throw new Error("expected an agent message item"); + } + expect((active.update.content as { text: string }).text).toBe("AB"); + }); + + it("preserves a sent user message across idle -> streaming -> complete transitions", () => { + const inc = createIncrementalConversationBuilder(); + const userMessages = (r: BuildResult) => + r.items.filter((i) => i.type === "user_message").map((i) => i.content); + + const turn1 = [ + userPromptMsg(1, 1, "first"), + agentChunk(2, "answer one"), + promptResponseMsg(3, 1), + ]; + expect(userMessages(inc.update(turn1, false))).toEqual(["first"]); + + // User sends "second": the prompt echo is appended to events. + const withPrompt = [...turn1, userPromptMsg(4, 2, "second")]; + // Renders that may straddle the pending flip — message must persist through all. + expect(userMessages(inc.update(withPrompt, false))).toEqual([ + "first", + "second", + ]); + expect(userMessages(inc.update(withPrompt, true))).toEqual([ + "first", + "second", + ]); + + const withChunk = [...withPrompt, agentChunk(5, "answer two")]; + expect(userMessages(inc.update(withChunk, true))).toEqual([ + "first", + "second", + ]); + + const done = [...withChunk, promptResponseMsg(6, 2)]; + expect(userMessages(inc.update(done, true))).toEqual(["first", "second"]); + expect(userMessages(inc.update(done, false))).toEqual(["first", "second"]); + }); + + it("re-derives the active turn's context each call so live updates surface", () => { + const inc = createIncrementalConversationBuilder(); + const base = [userPromptMsg(1, 1, "go"), toolCallMsg(2, "t1")]; + const r1 = inc.update(base, true); + const next = [...base, toolUpdateMsg(3, "t1", { status: "completed" })]; + const r2 = inc.update(next, true); + + const tool1 = r1.items.find((i) => i.type === "session_update"); + const tool2 = r2.items.find((i) => i.type === "session_update"); + expect(tool1?.type).toBe("session_update"); + // Same logical row, fresh object — its memoized view re-renders. + expect(tool2).not.toBe(tool1); + if (tool2?.type === "session_update") { + expect(tool2.turnContext.toolCalls.get("t1")?.status).toBe("completed"); + } + }); +}); diff --git a/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.ts b/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.ts new file mode 100644 index 000000000..9ef97a303 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/incrementalConversationItems.ts @@ -0,0 +1,162 @@ +import type { AcpMessage } from "@shared/types/session-events"; +import { + type BuildConversationOptions, + type BuildResult, + buildConversationItems, + type ConversationItem, + createItemBuilder, + type ItemBuilder, + markThoughtCompletion, + processEvent, + readLastTurnInfo, + type TurnContext, +} from "./buildConversationItems"; + +/** + * Incremental front end for `buildConversationItems`. + * + * A full rebuild re-parses every event on every streamed token — O(n) per + * token, O(n^2) per turn. This processes each event exactly once into a + * persistent builder and, on every call, freezes completed turns (their item + * objects keep identity, so memoized rows skip re-render) while re-deriving + * only the active turn. Per call the cost is proportional to the active turn, + * not the whole thread. + * + * Output is content-equivalent to `buildConversationItems` for every prefix of + * events — see incrementalConversationItems.test.ts. It falls back to a full + * rebuild whenever the append-only fast path can't faithfully represent the + * state (idle, non-append event change, options change, or a progress card in + * an already-frozen turn being mutated). + */ +export function createIncrementalConversationBuilder() { + let b: ItemBuilder | null = null; + let processedCount = 0; + let firstEventRef: AcpMessage | null = null; + let boundaryEventRef: AcpMessage | null = null; + let showDebugLogs: boolean | undefined; + + function reset() { + b = null; + processedCount = 0; + firstEventRef = null; + boundaryEventRef = null; + } + + function update( + events: AcpMessage[], + isPromptPending: boolean | null, + options?: BuildConversationOptions, + ): BuildResult { + const debug = options?.showDebugLogs; + + // Idle (not streaming): cheap to rebuild, and it sidesteps the speculative + // end-of-stream completions that only `buildConversationItems` resolves. + if (isPromptPending === false) { + reset(); + return buildConversationItems(events, isPromptPending, options); + } + + // The fast path is valid only when this call appends to the exact prefix we + // already processed (events is append-only during streaming, immer hands us + // a new array each push but keeps element identity). + const canAppend = + b !== null && + debug === showDebugLogs && + events.length >= processedCount && + (processedCount === 0 || events[0] === firstEventRef) && + (processedCount === 0 || events[processedCount - 1] === boundaryEventRef); + + if (!canAppend) { + b = createItemBuilder(); + processedCount = 0; + showDebugLogs = debug; + } + + const builder = b as ItemBuilder; + builder.lowestTouchedProgressIndex = Number.POSITIVE_INFINITY; + for (let i = processedCount; i < events.length; i++) { + processEvent(builder, events[i], options); + } + processedCount = events.length; + firstEventRef = events[0] ?? null; + boundaryEventRef = events[processedCount - 1] ?? null; + + const turn = builder.currentTurn; + const activeStart = + turn && !turn.isComplete + ? builder.currentTurnStartIndex + : builder.items.length; + + // A progress card living in the frozen region was mutated by this batch — + // an event reached back across a turn boundary. The append-only view can't + // show that, so rebuild fully this frame (the persistent builder stays + // valid for the next one). + if (builder.lowestTouchedProgressIndex < activeStart) { + return buildConversationItems(events, isPromptPending, options); + } + + // `buildConversationItems` always marks a trailing implicit turn complete. + // Replicate that on the live turn's context so thought-completion matches; + // it's safe to persist (a later real completion still flows through + // `completePromptTurn`, which gates on `isComplete`, left untouched here). + if (turn && turn.promptId === -1) { + turn.context.turnComplete = true; + } + + markThoughtCompletion(builder.items); + + return { + items: assembleItems(builder, activeStart), + lastTurnInfo: readLastTurnInfoForOutput(builder), + isCompacting: builder.isCompacting, + }; + } + + return { update, reset }; +} + +function assembleItems( + b: ItemBuilder, + activeStart: number, +): ConversationItem[] { + // Completed turns: reuse the builder's own objects. They aren't rebuilt + // across calls, so their identity is stable and memoized rows skip work. + const out = b.items.slice(0, activeStart); + if (activeStart >= b.items.length) return out; + + const turn = b.currentTurn; + // The active turn streams: clone its rows onto a fresh shared context each + // call so their memoized views re-render and read the latest tool/child + // state — matching the all-new-objects behavior a full rebuild gives the + // live turn. Non-update rows (the user message, git actions) never change, + // so pass them through by reference. + const activeContext: TurnContext | null = turn + ? { + toolCalls: turn.context.toolCalls, + childItems: turn.context.childItems, + turnCancelled: turn.context.turnCancelled, + turnComplete: turn.context.turnComplete, + } + : null; + + for (let i = activeStart; i < b.items.length; i++) { + const item = b.items[i]; + if (item.type === "session_update" && activeContext) { + out.push({ ...item, turnContext: activeContext }); + } else { + out.push(item); + } + } + return out; +} + +function readLastTurnInfoForOutput(b: ItemBuilder) { + const info = readLastTurnInfo(b); + if (!info) return null; + // A trailing implicit turn reports complete (no prompt response will arrive + // to flip it), mirroring `buildConversationItems`' finalization. + if (b.currentTurn?.promptId === -1) { + return { ...info, isComplete: true }; + } + return info; +} diff --git a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx index 536bebff1..698f9255a 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/UserMessage.tsx @@ -10,7 +10,7 @@ import { } from "@phosphor-icons/react"; import { Box, Flex, IconButton } from "@radix-ui/themes"; import { motion } from "framer-motion"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { hasFileMentions, MentionChip, @@ -43,7 +43,12 @@ function formatTimestamp(ts: number): string { }); } -export function UserMessage({ +// Rendered directly by the conversation's renderItem (no memoized wrapper, unlike +// agent messages which sit under SessionUpdateRow), so without memo every visible +// user message re-runs MarkdownRenderer on every parent render — and the +// virtualizer flushSync-renders on every scroll event. Props are referentially +// stable for completed turns (incremental parser), so memo skips them on scroll. +export const UserMessage = memo(function UserMessage({ content, timestamp, sourceUrl, @@ -175,4 +180,4 @@ export function UserMessage({ ); -} +}); diff --git a/apps/code/src/renderer/features/sessions/hooks/useConversationItems.ts b/apps/code/src/renderer/features/sessions/hooks/useConversationItems.ts new file mode 100644 index 000000000..dd9fa9008 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/hooks/useConversationItems.ts @@ -0,0 +1,57 @@ +import type { + BuildConversationOptions, + BuildResult, +} from "@features/sessions/components/buildConversationItems"; +import { createIncrementalConversationBuilder } from "@features/sessions/components/incrementalConversationItems"; +import type { AcpMessage } from "@shared/types/session-events"; +import { useRef } from "react"; + +interface Cache { + impl: ReturnType; + events: AcpMessage[] | null; + pending: boolean | null; + debug: boolean | undefined; + result: BuildResult | null; +} + +/** + * Builds conversation items incrementally — each event is parsed once and + * completed turns are reused by reference, so a streamed token costs work + * proportional to the active turn rather than the whole thread. The persistent + * builder lives in a ref; results are memoized on the (events, pending, debug) + * triple so unrelated re-renders don't re-derive. + */ +export function useConversationItems( + events: AcpMessage[], + isPromptPending: boolean | null, + options?: BuildConversationOptions, +): BuildResult { + const ref = useRef(null); + if (!ref.current) { + ref.current = { + impl: createIncrementalConversationBuilder(), + events: null, + pending: null, + debug: undefined, + result: null, + }; + } + const cache = ref.current; + const debug = options?.showDebugLogs; + + if ( + cache.result && + cache.events === events && + cache.pending === isPromptPending && + cache.debug === debug + ) { + return cache.result; + } + + const result = cache.impl.update(events, isPromptPending, options); + cache.events = events; + cache.pending = isPromptPending; + cache.debug = debug; + cache.result = result; + return result; +}