From a1e99be863959de1deead8fbb1f90192252a5501 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 11 Sep 2026 16:26:20 -0500 Subject: [PATCH] fix(tui): guard streaming part appends with a part index Every session.text.delta calls appendPart, which ran setRows(produce(...)) plus a hasPart full-tree scan even when the part already existed. On a root-array Solid store the no-op update still unwraps the whole rows tree, so per-delta cost scaled with loaded history (~580ms per 50-delta burst at 4000 messages). Maintain a lazily-rebuilt Set of part keys: rebuilt from the plain reduce() output on each reconcile, extended on append, and checked O(1) before touching the store. Reasoning completion still flows through to flip the group flag. Measured with a headless createTestRenderer probe (mixed markdown + tools, 50 deltas + settle): 578ms -> 34ms at 4000 messages, 133ms -> 34ms at 1000 messages. --- .../src/routes/session/grouping/session.ts | 15 +++++++ packages/tui/src/routes/session/rows.ts | 39 ++++++++++++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/routes/session/grouping/session.ts b/packages/tui/src/routes/session/grouping/session.ts index e81901a7c2c5..7542683472eb 100644 --- a/packages/tui/src/routes/session/grouping/session.ts +++ b/packages/tui/src/routes/session/grouping/session.ts @@ -139,3 +139,18 @@ export function hasPart(rows: SessionRow[], ref: PartRef) { return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID) }) } + +export function partKey(ref: PartRef) { + return `${ref.messageID} ${ref.partID}` +} + +/** Index of every part in the rows, mirroring hasPart membership. */ +export function collectPartKeys(rows: SessionRow[]) { + return new Set( + rows.flatMap((row) => { + if (row.type === "part") return [partKey(row.ref)] + if (row.type !== "group") return [] + return groupRefs(row, true).map(partKey) + }), + ) +} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index b5765a7b530a..519f95aad55c 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -6,9 +6,11 @@ import { useData } from "../../context/data" import { useClient } from "../../context/client" import { append, + collectPartKeys, completePrevious, groupRefs, hasPart, + partKey, partitionPending, projectEntries, type AppendPart, @@ -61,6 +63,18 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi ) } + // Index of every part in the rows. Rebuilt from the plain reduce() output on + // each reconcile and extended on appendPart, so the streaming fast path below + // can skip no-op store updates with an O(1) lookup. The other mutation sites + // only add message/footer rows or reorder group children, never part membership. + let parts = new Set() + + const syncRows = () => { + const next = reduce() + parts = collectPartKeys(next) + setRows(reconcile(next)) + } + createEffect(() => { const pending = pendingPermissions() setRows( @@ -73,12 +87,12 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi createEffect( on([sessionID, () => client.connection.status()], ([id, status]) => { if (status !== "connected") return - setRows(reconcile(reduce())) + syncRows() void data.session.pending.sync(id).catch(() => undefined) void data.session.message.sync(id).then( () => { if (sessionID() !== id) return - setRows(reconcile(reduce())) + syncRows() onSynced?.(id) }, () => undefined, @@ -92,7 +106,7 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi on( revertBoundary, () => { - setRows(reconcile(reduce())) + syncRows() }, { defer: true }, ), @@ -106,7 +120,7 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi if (item.type === "user" && item.delivery === "queue") return [`${item.id}:queue`] return [] }), - () => setRows(reconcile(reduce())), + () => syncRows(), { defer: true }, ), ) @@ -132,12 +146,12 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi ] : [], ), - () => setRows(reconcile(reduce())), + () => syncRows(), { defer: true }, ), ) - createEffect(on(turnTokens, () => setRows(reconcile(reduce())), { defer: true })) + createEffect(on(turnTokens, () => syncRows(), { defer: true })) const appendMessage = (messageID: string) => setRows( @@ -152,7 +166,12 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi }), ) - const appendPart = (ref: PartRef, part: AppendPart) => + const appendPart = (ref: PartRef, part: AppendPart) => { + const key = partKey(ref) + // Streaming deltas re-assert an existing part. Skip the store update without + // touching it: even a no-op root-array setRows traverses the whole tree. + // Reasoning completion still flows through to flip the group flag below. + if ((part.type !== "reasoning" || part.time?.completed === undefined) && parts.has(key)) return setRows( produce((draft) => { if (!hasPart(draft, ref)) { @@ -169,6 +188,8 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi if (row?.type === "group" && row.kind === "reasoning") row.completed = true }), ) + parts.add(key) + } const appendFooter = (messageID: string) => setRows( @@ -263,12 +284,12 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) - if (turnTokens()) setRows(reconcile(reduce())) + if (turnTokens()) syncRows() }), data.on("session.step.failed", (event) => { if (event.data.sessionID !== sessionID()) return appendFooter(event.data.assistantMessageID) - if (turnTokens()) setRows(reconcile(reduce())) + if (turnTokens()) syncRows() }), ] onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))