Compare commits

...

1 Commits

Author SHA1 Message Date
Aiden Cline a1e99be863 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.
2026-09-11 16:26:20 -05:00
2 changed files with 45 additions and 9 deletions
@@ -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)
}),
)
}
+30 -9
View File
@@ -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<string>, 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<string>()
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<string>, 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<string>, onSynced?: (sessi
on(
revertBoundary,
() => {
setRows(reconcile(reduce()))
syncRows()
},
{ defer: true },
),
@@ -106,7 +120,7 @@ export function createSessionRows(sessionID: Accessor<string>, 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<string>, 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<string>, 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<string>, 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<string>, 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()))