From d1c7b1a917aadc0e7766e02a6cbf97d30439bb8f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sat, 24 Oct 2026 18:37:38 -0700 Subject: [PATCH 1/4] fix(mobile): keep thread lists stable during streaming --- .../src/state/threadShell.test.ts | 88 +++++++++++++++++++ .../client-runtime/src/state/threadShell.ts | 9 +- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index fda1054064f78..67697421d1cb8 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + MessageId, ProjectId, RunId, RuntimeRequestId, @@ -108,6 +109,93 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); + it("does not notify navigation subscribers for streaming-only updates", () => { + const { registry, threads, snapshotAtom } = makeHarness([environmentId, remoteEnvironmentId]); + const runId = RunId.make("navigation-run"); + const running: OrchestrationV2ThreadShell = { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status: "running", + itemCount: 1, + visibleItemCount: 1, + }; + const setThread = (thread: OrchestrationV2ThreadShell, sequence: number) => + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: sequence, + threads: [thread], + }); + setThread(running, 1); + let notifications = 0; + const dispose = registry.subscribe(threads.navigationThreadShellsAtom, () => notifications++); + try { + const before = registry.get(threads.navigationThreadShellsAtom); + notifications = 0; + for (let index = 1; index <= 100; index++) { + const updatedAt = DateTime.add(v2Now, { seconds: index }); + setThread( + { + ...running, + updatedAt, + itemCount: index + 1, + visibleItemCount: index + 1, + latestVisibleMessage: { + id: MessageId.make("streaming-message"), + role: "assistant", + text: `Chunk ${index}`, + updatedAt, + }, + }, + index + 1, + ); + registry.get(threads.navigationThreadShellsAtom); + } + expect(notifications).toBe(0); + expect(registry.get(threads.navigationThreadShellsAtom)).toBe(before); + expect( + registry.get(threads.threadShellAtom({ environmentId, threadId: running.id }))?.itemCount, + ).toBe(101); + + setThread({ ...running, title: "Renamed" }, 102); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.title).toBe("Renamed"); + expect(notifications).toBe(1); + expect(registry.get(threads.navigationThreadShellsAtom)[1]).toBe(before[1]); + + setThread({ ...running, itemCount: 0, visibleItemCount: 0 }, 103); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.itemCount).toBe(0); + setThread(running, 104); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.itemCount).toBe(1); + const waiting = { ...running, status: "waiting" as const }; + setThread(waiting, 105); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.source.status).toBe("waiting"); + setThread( + { + ...waiting, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("navigation-approval"), + kind: "command", + createdAt: v2Now, + }, + }, + 106, + ); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.hasPendingApprovals).toBe(true); + const completed = { ...running, activeRunId: null, status: "completed" as const }; + setThread(completed, 107); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.source.status).toBe("completed"); + const updatedAt = DateTime.add(v2Now, { seconds: 108 }); + setThread({ ...completed, updatedAt }, 108); + expect(registry.get(threads.navigationThreadShellsAtom)[0]?.updatedAt).toBe( + DateTime.formatIso(updatedAt), + ); + expect(notifications).toBe(7); + } finally { + dispose(); + registry.dispose(); + } + }); + it("keeps a live subagent question inside the parent without flagging its thread", () => { const { registry, threads, snapshotAtom } = makeHarness(); const root = v2ThreadShell; diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 15718da1eca65..59cc808c6ea5a 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -559,7 +559,14 @@ export function createEnvironmentThreadShellAtoms(input: { ) { continue; } - next.push(scopedThread(environmentId, thread, subagentRequests)); + const shell = scopedThread(environmentId, thread, subagentRequests); + const previous = previousNavigationShells[next.length]; + // Token ticks must not rebuild navigation; threadShellAtom still supplies the live values. + next.push( + previous !== undefined && previous !== shell && sameApartFromStreaming(previous, shell) + ? previous + : shell, + ); } } if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells; From b1094d0741c1032a1695f18f48d8b92c58b491f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 07:49:12 +0000 Subject: [PATCH 2/4] test(client-runtime): takeover tests for stable navigation thread shells Replace the PR's single all-in-one navigation test with two focused tests for #24651: navigation keeps its list and row identities through streaming ticks while threadShellAtom follows live values, and changes a list shows (count zero crossing, status, pending request, run stop, stopped thread's timestamp) still rebuild it. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadShell.test.ts | 129 +++++++++++------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index 67697421d1cb8..312a24ce9edbb 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -109,8 +109,8 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); - it("does not notify navigation subscribers for streaming-only updates", () => { - const { registry, threads, snapshotAtom } = makeHarness([environmentId, remoteEnvironmentId]); + it("keeps navigation and its rows through streaming ticks while the live shell follows", () => { + const { registry, threads, snapshotAtom } = makeHarness(); const runId = RunId.make("navigation-run"); const running: OrchestrationV2ThreadShell = { ...v2ThreadShell, @@ -120,76 +120,101 @@ describe("v2 thread shell lists", () => { itemCount: 1, visibleItemCount: 1, }; - const setThread = (thread: OrchestrationV2ThreadShell, sequence: number) => + const idle = { ...v2ThreadShell, id: ThreadId.make("idle") }; + const setRunning = (thread: OrchestrationV2ThreadShell, sequence: number) => registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, snapshotSequence: sequence, - threads: [thread], + threads: [thread, idle], }); - setThread(running, 1); - let notifications = 0; - const dispose = registry.subscribe(threads.navigationThreadShellsAtom, () => notifications++); + setRunning(running, 1); + const ref = { environmentId, threadId: running.id }; + const dispose = registry.mount(threads.navigationThreadShellsAtom); try { const before = registry.get(threads.navigationThreadShellsAtom); - notifications = 0; - for (let index = 1; index <= 100; index++) { - const updatedAt = DateTime.add(v2Now, { seconds: index }); - setThread( + const [runningRow, idleRow] = before; + for (let tick = 1; tick <= 3; tick++) { + const updatedAt = DateTime.add(v2Now, { seconds: tick }); + setRunning( { ...running, updatedAt, - itemCount: index + 1, - visibleItemCount: index + 1, + itemCount: tick + 1, + visibleItemCount: tick + 1, latestVisibleMessage: { id: MessageId.make("streaming-message"), role: "assistant", - text: `Chunk ${index}`, + text: `Chunk ${tick}`, updatedAt, }, }, - index + 1, + tick + 1, ); - registry.get(threads.navigationThreadShellsAtom); + expect(registry.get(threads.navigationThreadShellsAtom)).toBe(before); } - expect(notifications).toBe(0); - expect(registry.get(threads.navigationThreadShellsAtom)).toBe(before); - expect( - registry.get(threads.threadShellAtom({ environmentId, threadId: running.id }))?.itemCount, - ).toBe(101); + expect(registry.get(threads.threadShellAtom(ref))?.itemCount).toBe(4); + expect(registry.get(threads.threadShellAtom(ref))?.source.latestVisibleMessage?.text).toBe( + "Chunk 3", + ); - setThread({ ...running, title: "Renamed" }, 102); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.title).toBe("Renamed"); - expect(notifications).toBe(1); - expect(registry.get(threads.navigationThreadShellsAtom)[1]).toBe(before[1]); + setRunning({ ...running, title: "Renamed" }, 5); + const renamed = registry.get(threads.navigationThreadShellsAtom); + expect(renamed).not.toBe(before); + expect(renamed[0]).not.toBe(runningRow); + expect(renamed[0]?.title).toBe("Renamed"); + expect(renamed[1]).toBe(idleRow); + } finally { + dispose(); + registry.dispose(); + } + }); - setThread({ ...running, itemCount: 0, visibleItemCount: 0 }, 103); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.itemCount).toBe(0); - setThread(running, 104); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.itemCount).toBe(1); - const waiting = { ...running, status: "waiting" as const }; - setThread(waiting, 105); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.source.status).toBe("waiting"); - setThread( - { - ...waiting, - pendingRuntimeRequest: { - id: RuntimeRequestId.make("navigation-approval"), - kind: "command", - createdAt: v2Now, - }, + it("rebuilds navigation for running-thread changes a list shows", () => { + const { registry, threads, snapshotAtom } = makeHarness(); + const runId = RunId.make("navigation-run"); + const running: OrchestrationV2ThreadShell = { + ...v2ThreadShell, + latestRunId: runId, + activeRunId: runId, + status: "running", + itemCount: 1, + visibleItemCount: 1, + }; + const completed = { ...running, activeRunId: null, status: "completed" as const }; + const changes: ReadonlyArray = [ + { ...running, itemCount: 0, visibleItemCount: 0 }, + { ...running, status: "waiting" }, + { + ...running, + pendingRuntimeRequest: { + id: RuntimeRequestId.make("navigation-approval"), + kind: "command", + createdAt: v2Now, }, - 106, - ); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.hasPendingApprovals).toBe(true); - const completed = { ...running, activeRunId: null, status: "completed" as const }; - setThread(completed, 107); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.source.status).toBe("completed"); - const updatedAt = DateTime.add(v2Now, { seconds: 108 }); - setThread({ ...completed, updatedAt }, 108); - expect(registry.get(threads.navigationThreadShellsAtom)[0]?.updatedAt).toBe( - DateTime.formatIso(updatedAt), - ); - expect(notifications).toBe(7); + }, + completed, + { ...completed, updatedAt: DateTime.add(v2Now, { seconds: 1 }) }, + ]; + const dispose = registry.mount(threads.navigationThreadShellsAtom); + try { + let sequence = 0; + for (const [index, change] of changes.entries()) { + const from = index === changes.length - 1 ? completed : running; + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: ++sequence, + threads: [from], + }); + const before = registry.get(threads.navigationThreadShellsAtom); + registry.set(snapshotAtom(environmentId), { + ...v2ShellSnapshot, + snapshotSequence: ++sequence, + threads: [change], + }); + const after = registry.get(threads.navigationThreadShellsAtom); + expect(after).not.toBe(before); + expect(after[0]?.source).toBe(change); + } } finally { dispose(); registry.dispose(); From 17e450d08e479364c115a298285125abbd88badf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 07:52:17 +0000 Subject: [PATCH 3/4] fix(client-runtime): navigation thread shells reuse the quiet per-thread shells Derive navigationThreadShellsAtom from quietThreadShellsAtom instead of comparing each row against the previous list's row at the same index. Rows are now matched by thread key through the existing quietThreadShellAtom family, so a streaming thread keeps its row when threads are inserted, removed or reordered, and there is one streaming comparison site. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/state/threadShell.ts | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 59cc808c6ea5a..f00ca50f39f0d 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -548,27 +548,12 @@ export function createEnvironmentThreadShellAtoms(input: { let previousNavigationShells: ReadonlyArray = []; const navigationThreadShellsAtom = Atom.make((get) => { - const next: EnvironmentThreadShell[] = []; const underParent = get(delegatedUnderParentAtom); - for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { - const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); - for (const thread of get(environmentThreadsAtom(environmentId))) { - if ( - thread.archivedAt !== null || - isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) - ) { - continue; - } - const shell = scopedThread(environmentId, thread, subagentRequests); - const previous = previousNavigationShells[next.length]; - // Token ticks must not rebuild navigation; threadShellAtom still supplies the live values. - next.push( - previous !== undefined && previous !== shell && sameApartFromStreaming(previous, shell) - ? previous - : shell, - ); - } - } + const next = get(quietThreadShellsAtom).filter( + (thread) => + thread.archivedAt === null && + !isShownUnderParent({ environmentId: thread.environmentId, threadId: thread.id }, thread.lineage, underParent), + ); if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells; previousNavigationShells = next; return next; From e8a926ab707397a464ad1951485a6180771d20d0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 07:59:26 +0000 Subject: [PATCH 4/4] fix(client-runtime): keep the contributor's positional navigation reuse Building navigation from quietThreadShellsAtom mounts one per-thread atom for every thread on mobile and measured 6-25x more derivation work per streaming tick than main. The positional reuse costs what main costs, since unchanged sources keep their cached shell and only changed rows are compared. Co-Authored-By: Claude Opus 5.5 --- .../client-runtime/src/state/threadShell.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index f00ca50f39f0d..59cc808c6ea5a 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -548,12 +548,27 @@ export function createEnvironmentThreadShellAtoms(input: { let previousNavigationShells: ReadonlyArray = []; const navigationThreadShellsAtom = Atom.make((get) => { + const next: EnvironmentThreadShell[] = []; const underParent = get(delegatedUnderParentAtom); - const next = get(quietThreadShellsAtom).filter( - (thread) => - thread.archivedAt === null && - !isShownUnderParent({ environmentId: thread.environmentId, threadId: thread.id }, thread.lineage, underParent), - ); + for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) { + const subagentRequests = get(pendingSubagentRequestsAtom(environmentId)); + for (const thread of get(environmentThreadsAtom(environmentId))) { + if ( + thread.archivedAt !== null || + isShownUnderParent({ environmentId, threadId: thread.id }, thread.lineage, underParent) + ) { + continue; + } + const shell = scopedThread(environmentId, thread, subagentRequests); + const previous = previousNavigationShells[next.length]; + // Token ticks must not rebuild navigation; threadShellAtom still supplies the live values. + next.push( + previous !== undefined && previous !== shell && sameApartFromStreaming(previous, shell) + ? previous + : shell, + ); + } + } if (arrayElementsEqual(previousNavigationShells, next)) return previousNavigationShells; previousNavigationShells = next; return next;