From 5854dbf46a6668e4ab3f9b04235d70ee89d1e901 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 23:49:17 +0000 Subject: [PATCH 01/17] fix(web): sidebar reveals the active thread after back/forward navigation The new sidebar widens a project scope that hides the newly opened thread, once per route change, and scrolls its row into view. The legacy sidebar keeps the open thread listed past the preview cut, as collapsed projects already did, and scrolls it into view on route change. Fixes #16534 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/LegacySidebar.tsx | 42 ++++++++++++++++------- apps/web/src/components/Sidebar.tsx | 30 ++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 9076b9c127ce5..9594d5b0674a7 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1199,20 +1199,22 @@ function useProjectThreadPanel(input: { (settings) => settings.sidebarThreadPreviewCount, ); const activeThreadKey = activeRouteThreadKey ?? undefined; - const pinnedCollapsedThread = - !activeThreadKey || projectExpanded - ? null - : (visibleProjectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null); + const activeThread = activeThreadKey + ? (visibleProjectThreads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, + ) ?? null) + : null; + const pinnedCollapsedThread = projectExpanded ? null : activeThread; const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? visibleProjectThreads : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); + // The open thread stays listed past the preview cut, as it does in a + // collapsed project. const visibleThreadKeys = new Set( - [...previewThreads, ...(pinnedCollapsedThread ? [pinnedCollapsedThread] : [])].map((thread) => + [...previewThreads, ...(activeThread ? [activeThread] : [])].map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), ); @@ -3202,10 +3204,17 @@ function useLegacySidebarThreads(input: { isThreadListExpanded || !hasOverflowingThreads ? projectThreads : projectThreads.slice(0, sidebarThreadPreviewCount); - const renderedThreads = pinnedCollapsedThread ? [pinnedCollapsedThread] : previewThreads; - return renderedThreads.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); + if (pinnedCollapsedThread) { + return [ + scopedThreadKey( + scopeThreadRef(pinnedCollapsedThread.environmentId, pinnedCollapsedThread.id), + ), + ]; + } + return projectThreads.flatMap((thread, index) => { + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return index < previewThreads.length || threadKey === activeThreadKey ? [threadKey] : []; + }); }); const threadJumpCommandByKey = new Map< string, @@ -3249,6 +3258,15 @@ export default function LegacySidebar() { ); const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; + // History and the command palette can open a thread scrolled out of view. + const scrolledRouteThreadKeyRef = useRef(routeThreadKey); + useEffect(() => { + if (scrolledRouteThreadKeyRef.current === routeThreadKey) return; + scrolledRouteThreadKeyRef.current = routeThreadKey; + document + .querySelector('[data-sidebar="content"] [data-thread-item] [data-active="true"]') + ?.scrollIntoView({ block: "nearest" }); + }, [routeThreadKey]); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9447ce0ba6611..0a8a86405d18e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1060,6 +1060,36 @@ export default function Sidebar() { routeThreadRef, }); + // History, the command palette, and links can open a thread the project + // scope hides or the list has scrolled away. Reveal it once per route + // change, so a scope picked while that thread stays open is left alone. + const revealedRouteRowKeyRef = useRef(routeRowThreadKey); + const pendingRouteRevealKeyRef = useRef(null); + const revealRouteRow = useStableCallback(() => { + if (revealedRouteRowKeyRef.current !== routeRowThreadKey) { + revealedRouteRowKeyRef.current = routeRowThreadKey; + pendingRouteRevealKeyRef.current = routeRowThreadKey; + } + const pendingKey = pendingRouteRevealKeyRef.current; + if (pendingKey === null) return; + const thread = threads.find((candidate) => threadShellKey(candidate) === pendingKey); + if (thread === undefined) return; + if ( + scopedProjectKeys !== null && + filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && + filterSidebarV2VisibleThreads([thread], null).length === 1 + ) { + // The row renders under the widened scope; scroll on that pass. + setProjectScopeKey(null); + return; + } + pendingRouteRevealKeyRef.current = null; + document + .querySelector('[data-sidebar="content"] [aria-current="page"]') + ?.scrollIntoView({ block: "nearest" }); + }); + useEffect(revealRouteRow, [revealRouteRow, routeRowThreadKey, threads, projectScopeKey]); + const isSearchingThreads = threadSearchQuery.trim().length > 0; const threadSearchMatchByKey = new Map( threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const), From 384385408b73e4033e9a627e486e571c394f6186 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 23:58:50 +0000 Subject: [PATCH 02/17] fix(web): sidebar reveals the active thread after returning from Settings Leaving Settings remounts the sidebar, and the reveal refs started at the current route, so the open thread stayed hidden behind the chosen project scope and the legacy list skipped its scroll. Mounting now counts as a route change. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/LegacySidebar.tsx | 3 ++- apps/web/src/components/Sidebar.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 9594d5b0674a7..1502abb77ccfc 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -3259,7 +3259,8 @@ export default function LegacySidebar() { const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; // History and the command palette can open a thread scrolled out of view. - const scrolledRouteThreadKeyRef = useRef(routeThreadKey); + // Mounting counts as a route change: leaving Settings unmounts the sidebar. + const scrolledRouteThreadKeyRef = useRef(null); useEffect(() => { if (scrolledRouteThreadKeyRef.current === routeThreadKey) return; scrolledRouteThreadKeyRef.current = routeThreadKey; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0a8a86405d18e..b15cb390fbfe8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1063,7 +1063,8 @@ export default function Sidebar() { // History, the command palette, and links can open a thread the project // scope hides or the list has scrolled away. Reveal it once per route // change, so a scope picked while that thread stays open is left alone. - const revealedRouteRowKeyRef = useRef(routeRowThreadKey); + // Mounting counts as a route change: leaving Settings unmounts the sidebar. + const revealedRouteRowKeyRef = useRef(null); const pendingRouteRevealKeyRef = useRef(null); const revealRouteRow = useStableCallback(() => { if (revealedRouteRowKeyRef.current !== routeRowThreadKey) { From a39cce209edab1b60630fca4f9d9c33ffbf93d98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:46:27 +0000 Subject: [PATCH 03/17] test(web): add synthetic sidebar navigation fixtures --- scripts/fixtures/issue-16534/README.md | 31 ++ scripts/fixtures/issue-16534/bootstrap.js | 27 ++ scripts/fixtures/issue-16534/plain.json | 483 ++++++++++++++++++++++ scripts/fixtures/issue-16534/seed.py | 95 +++++ 4 files changed, 636 insertions(+) create mode 100644 scripts/fixtures/issue-16534/README.md create mode 100644 scripts/fixtures/issue-16534/bootstrap.js create mode 100644 scripts/fixtures/issue-16534/plain.json create mode 100644 scripts/fixtures/issue-16534/seed.py diff --git a/scripts/fixtures/issue-16534/README.md b/scripts/fixtures/issue-16534/README.md new file mode 100644 index 0000000000000..53dfcc59c9341 --- /dev/null +++ b/scripts/fixtures/issue-16534/README.md @@ -0,0 +1,31 @@ +Synthetic projection fixture for sidebar navigation evidence. No provider starts, +event history, copied user data, or auth values are included. `plain.json` is the +plain generated fixture; regenerate it with: + +```sh +python3 scripts/fixtures/issue-16534/seed.py --output /tmp/sidebar-seed +``` + +Use `--variant` for spacers (40 additional stopped threads), pinned, snoozed, +settled (16 settled A threads, beyond the ten-row tail), lineage, archived, or +orphan. Add `--legacy` to select the legacy sidebar. Generated timestamps and +hashes are deterministic. JSON payloads use OrchestrationV2AppThreadJson. + +Only after the isolated server owner stops it, apply the generated files in +order: reset-threads.sql, hide-unrelated-projects.sql, projects.sql, threads.sql. +Use `node apps/server/scripts/t3-sqlite-state.ts exec --base-dir +--file `, once per file. The helper backs up each mutation. Never use the +live home. Restart through the owner. These writes intentionally do not create +event history and cannot prove backend behavior. + +Client settings and UI reset inputs are in fixture.json. Environment-scoped keys, +project order, initial route/history, shelf/list expansion, search, scroll offsets, +and keyboard bindings must be measured and recorded for each scenario before +capture. The generated empty scoped-key maps are starting inputs, not a claim +that all matrix scenarios are initialized. UI interactions establish those states. + +Working/status/grouping and delayed protocol replay variants are not implemented +here. Record those segments as blocked until valid fixtures exist. `bootstrap.js` +is a proposed capture-tab document reboot for pre-module clock/storage setup; +validate it in the real app before accepting any frozen-clock evidence. It does +not change the served source or any timer API. Server clock is not frozen. diff --git a/scripts/fixtures/issue-16534/bootstrap.js b/scripts/fixtures/issue-16534/bootstrap.js new file mode 100644 index 0000000000000..6104e7a9ba174 --- /dev/null +++ b/scripts/fixtures/issue-16534/bootstrap.js @@ -0,0 +1,27 @@ +/** Reboot the real Vite document with a deterministic client clock and storage. + * Run in the capture tab through preview_evaluate after pairing. The returned + * source document is unmodified except for this pre-module fixture bootstrap. + * No timer API is replaced. Auth and connection storage are preserved. + */ +async function rebootSidebarFixture(fixture) { + const response = await fetch(location.href, { cache: "no-store" }); + if (!response.ok) throw new Error(`Fixture document fetch failed: ${response.status}`); + const html = await response.text(); + const source = `(() => { + const fixture = ${JSON.stringify(fixture).replaceAll("<", "\\u003c")}; + const NativeDate = Date; + const epoch = NativeDate.parse(fixture.clock); + class FixtureDate extends NativeDate { + constructor(...args) { super(...(args.length ? args : [epoch])); } + static now() { return epoch; } + } + globalThis.Date = FixtureDate; + globalThis.__sidebarCaptureClock = fixture.clock; + localStorage.setItem('t3code:client-settings:v1', JSON.stringify(fixture.settings)); + localStorage.setItem('t3code:ui-state:v1', JSON.stringify(fixture.uiState)); + })();`; + if (!html.includes("")) throw new Error("Expected the real application document"); + document.open(); + document.write(html.replace("", ``)); + document.close(); +} diff --git a/scripts/fixtures/issue-16534/plain.json b/scripts/fixtures/issue-16534/plain.json new file mode 100644 index 0000000000000..c847fcd56de43 --- /dev/null +++ b/scripts/fixtures/issue-16534/plain.json @@ -0,0 +1,483 @@ +{ + "clock": "2026-10-22T00:00:00.000Z", + "settings": { + "legacySidebarEnabled": false, + "sidebarProjectGroupingMode": "separate", + "sidebarProjectSortOrder": "manual", + "sidebarThreadPreviewCount": 6, + "sidebarThreadSortOrder": "updated_at" + }, + "threads": [ + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:01:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-1", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-1" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 1", + "updatedAt": "2026-10-21T20:01:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:02:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-2", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-2" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 2", + "updatedAt": "2026-10-21T20:02:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:03:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-3", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-3" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 3", + "updatedAt": "2026-10-21T20:03:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:04:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-4", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-4" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 4", + "updatedAt": "2026-10-21T20:04:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:05:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-5", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-5" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 5", + "updatedAt": "2026-10-21T20:05:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:06:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-6", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-6" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 6", + "updatedAt": "2026-10-21T20:06:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:07:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-7", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-7" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 7", + "updatedAt": "2026-10-21T20:07:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:08:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-A-8", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-A-8" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-A", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage A thread 8", + "updatedAt": "2026-10-21T20:08:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:01:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-1", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-1" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 1", + "updatedAt": "2026-10-21T20:01:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:02:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-2", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-2" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 2", + "updatedAt": "2026-10-21T20:02:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:03:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-3", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-3" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 3", + "updatedAt": "2026-10-21T20:03:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:04:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-4", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-4" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 4", + "updatedAt": "2026-10-21T20:04:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:05:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-5", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-5" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 5", + "updatedAt": "2026-10-21T20:05:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:06:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-6", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-6" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 6", + "updatedAt": "2026-10-21T20:06:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:07:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-7", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-7" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 7", + "updatedAt": "2026-10-21T20:07:00.000Z", + "worktreePath": null + }, + { + "activeProviderThreadId": null, + "archivedAt": null, + "branch": null, + "createdAt": "2026-10-21T20:08:00.000Z", + "createdBy": "user", + "creationSource": "web", + "deletedAt": null, + "forkedFrom": null, + "id": "triage-B-8", + "interactionMode": "default", + "lastVisitedAt": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "triage-B-8" + }, + "modelSelection": { "instanceId": "codex", "model": "gpt-6.1-sol" }, + "projectId": "triage-project-B", + "providerInstanceId": "codex", + "runtimeMode": "full-access", + "settledAt": null, + "settledOverride": null, + "snoozedAt": null, + "snoozedUntil": null, + "title": "Triage B thread 8", + "updatedAt": "2026-10-21T20:08:00.000Z", + "worktreePath": null + } + ], + "uiState": { + "projectExpandedById": {}, + "projectOrder": [], + "sidebarProjectScopeKey": null, + "threadLastVisitedAtById": {} + }, + "variant": "plain" +} diff --git a/scripts/fixtures/issue-16534/seed.py b/scripts/fixtures/issue-16534/seed.py new file mode 100644 index 0000000000000..63bd111e0938d --- /dev/null +++ b/scripts/fixtures/issue-16534/seed.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Generate synthetic sidebar projections without reading personal application data. + +Generation never touches a database. Apply seed.sql with the documented SQLite +helper only after the environment owner has stopped the isolated server. These +are projection fixtures, not event-history or provider integration fixtures. +""" +import argparse +import hashlib +import json +from pathlib import Path + +CLOCK = '2026-10-22T00:00:00.000Z' + + +def encoded(value): + return json.dumps(value, sort_keys=True, separators=(',', ':')) + + +def quote(value): + return 'NULL' if value is None else "'" + str(value).replace("'", "''") + "'" + + +def thread(group, number): + key = f'triage-{group}-{number}' + stamp = f'2026-10-21T20:{number:02d}:00.000Z' + return dict(createdBy='user', creationSource='web', id=key, + projectId=f'triage-project-{group}', title=f'Triage {group} thread {number}', + providerInstanceId='codex', modelSelection=dict(instanceId='codex', model='gpt-6.1-sol'), + runtimeMode='full-access', interactionMode='default', branch=None, + worktreePath=None, activeProviderThreadId=None, + lineage=dict(parentThreadId=None, relationshipToParent=None, rootThreadId=key), + forkedFrom=None, createdAt=stamp, updatedAt=stamp, archivedAt=None, + settledOverride=None, settledAt=None, snoozedUntil=None, snoozedAt=None, + lastVisitedAt=None, deletedAt=None) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('--variant', choices=['plain', 'spacers', 'pinned', 'snoozed', 'settled', 'lineage', 'archived', 'orphan'], default='plain') + parser.add_argument('--legacy', action='store_true') + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + threads = [thread(group, n) for group in ['A', 'B'] for n in range(1, 9)] + projects = ['A', 'B'] + target = threads[0] + if args.variant == 'spacers': + projects.append('Spacer') + threads += [thread('Spacer', n) for n in range(1, 41)] + if args.variant == 'pinned': + target.update(pinnedAt=CLOCK, pinOrderKey='a0') + if args.variant == 'snoozed': + target.update(snoozedAt=CLOCK, snoozedUntil='2099-01-01T00:00:00.000Z') + if args.variant == 'settled': + target.update(settledOverride='settled', settledAt=CLOCK) + for n in range(2, 17): + settled = threads[n-1] if n <= 8 else thread('A', n) + settled.update(settledOverride='settled', settledAt=f'2026-10-21T21:{n:02d}:00.000Z') + if n > 8: + threads.append(settled) + if args.variant == 'archived': + target['archivedAt'] = CLOCK + if args.variant in ['lineage', 'orphan']: + for n, parent, relationship in [(9, target['id'], 'subagent'), (10, 'triage-A-9', 'subagent'), (11, target['id'], 'fork'), (12, target['id'], 'subagent')]: + child = thread('A', n) + child['lineage'] = dict(parentThreadId=parent, relationshipToParent=relationship, rootThreadId=target['id']) + threads.append(child) + if args.variant == 'orphan': + threads[16]['lineage']['parentThreadId'] = 'triage-missing-root' + threads[16]['lineage']['rootThreadId'] = 'triage-missing-root' + # One statement, so the state helper can apply it as one atomic transaction. + project_values = [] + for group in projects: + project_values.append('(' + ','.join(map(quote, [f'triage-project-{group}', f'Triage {group}', f'/tmp/triage-16534-{group}', '[]', '2026-10-21T20:00:00.000Z', '2026-10-21T20:00:00.000Z'])) + ')') + project_sql = 'INSERT OR REPLACE INTO projection_projects (project_id,title,workspace_root,scripts_json,created_at,updated_at) VALUES ' + ',\n'.join(project_values) + ';\n' + values = [] + for t in threads: + values.append('(' + ','.join(map(quote, [t['id'],t['projectId'],t['title'],'codex','full-access','default',None,t['createdAt'],t['updatedAt'],t['archivedAt'],None,encoded(t),'codex'])) + ')') + sql = 'INSERT OR REPLACE INTO orchestration_v2_projection_threads (thread_id,project_id,title,default_provider,runtime_mode,interaction_mode,active_provider_thread_id,created_at,updated_at,archived_at,deleted_at,payload_json,provider_instance_id) VALUES ' + ',\n'.join(values) + ';\n' + fixture = dict(clock=CLOCK, variant=args.variant, threads=threads, + settings=dict(legacySidebarEnabled=args.legacy,sidebarThreadSortOrder='updated_at',sidebarProjectSortOrder='manual',sidebarThreadPreviewCount=6,sidebarProjectGroupingMode='separate'), + uiState=dict(projectExpandedById={},projectOrder=[],threadLastVisitedAtById={},sidebarProjectScopeKey=None)) + (args.output/'reset-threads.sql').write_text("DELETE FROM orchestration_v2_projection_threads WHERE thread_id LIKE 'triage-%';\n") + (args.output/'hide-unrelated-projects.sql').write_text("UPDATE projection_projects SET deleted_at=" + quote(CLOCK) + " WHERE project_id NOT IN (" + ','.join(quote('triage-project-' + group) for group in projects) + ");\n") + (args.output/'projects.sql').write_text(project_sql) + (args.output/'threads.sql').write_text(sql) + (args.output/'fixture.json').write_text(encoded(fixture)+'\n') + digest = hashlib.sha256(encoded(fixture).encode()).hexdigest() + (args.output/'sha256.txt').write_text(digest+'\n') + print(digest) + + +if __name__ == '__main__': + main() From 32099f62b32b1d6e0bb36b6b8501ff5db63de01a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 01:13:33 +0000 Subject: [PATCH 04/17] fix(web): sidebar reveal survives drawer remounts and late rows The reveal state lived in the sidebar, which the narrow drawer and Settings unmount without a route change, so reopening the drawer widened a manually picked project scope. AppSidebarLayout now owns it, and only a route change requests a reveal. The legacy sidebar also keeps its reveal pending until the active row renders, so a thread whose environment shells arrive late still scrolls into view once. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 9 ++++-- apps/web/src/components/LegacySidebar.tsx | 32 ++++++++++++------- apps/web/src/components/Sidebar.tsx | 21 ++++++------ .../web/src/components/sidebar/routeReveal.ts | 22 +++++++++++++ 4 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/sidebar/routeReveal.ts diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 526ac86a6c9ad..03f1903ae4b99 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -43,6 +43,7 @@ import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; +import type { SidebarRouteReveal } from "./sidebar/routeReveal"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { @@ -330,6 +331,10 @@ function ProjectProjectionRetention() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); + const [sidebarRouteReveal] = useState(() => ({ + observedKey: null, + pendingKey: null, + })); const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); useThreadVisitedMigration(); @@ -422,9 +427,9 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { ) : legacySidebarEnabled ? ( - + ) : ( - + )} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1502abb77ccfc..226c508c7732c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -223,6 +223,11 @@ import { } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { + completeSidebarRouteReveal, + pendingSidebarRouteReveal, + type SidebarRouteReveal, +} from "./sidebar/routeReveal"; const SIDEBAR_SORT_LABELS: Record = { updated_at: "Last user message", created_at: "Created at", @@ -3243,7 +3248,7 @@ function useLegacySidebarThreads(input: { }; } -export default function LegacySidebar() { +export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const reorderProjects = useUiStateStore((store) => store.reorderProjects); const navigate = useNavigate(); const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); @@ -3258,16 +3263,6 @@ export default function LegacySidebar() { ); const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - // History and the command palette can open a thread scrolled out of view. - // Mounting counts as a route change: leaving Settings unmounts the sidebar. - const scrolledRouteThreadKeyRef = useRef(null); - useEffect(() => { - if (scrolledRouteThreadKeyRef.current === routeThreadKey) return; - scrolledRouteThreadKeyRef.current = routeThreadKey; - document - .querySelector('[data-sidebar="content"] [data-thread-item] [data-active="true"]') - ?.scrollIntoView({ block: "nearest" }); - }, [routeThreadKey]); const routeTerminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -3317,6 +3312,21 @@ export default function LegacySidebar() { sidebarThreadPreviewCount, expandedThreadListsByProject, }); + // History and the command palette can open a thread scrolled out of view. + // Its row can mount after the route does, once its environment's shells + // arrive, so the reveal stays pending until the row renders. + const routeRowRendered = + routeThreadKey !== null && visibleSidebarThreadKeys.includes(routeThreadKey); + const revealRouteRow = useStableCallback(() => { + if (pendingSidebarRouteReveal(routeReveal, routeThreadKey) === null) return; + const activeRow = document.querySelector( + '[data-sidebar="content"] [data-thread-item] [data-active="true"]', + ); + if (!activeRow) return; + completeSidebarRouteReveal(routeReveal); + activeRow.scrollIntoView({ block: "nearest" }); + }); + useEffect(revealRouteRow, [revealRouteRow, routeThreadKey, routeRowRendered]); const newThreadShortcutLabelOptions = { platform, context: { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b15cb390fbfe8..b086e11177b14 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -179,6 +179,11 @@ import { resolveSnoozePresets, snoozeWakeLabel, type SnoozePreset } from "./Side import { ProjectFavicon } from "./ProjectFavicon"; import { deriveProviderEntriesByEnvironment } from "../providerInstances"; import { useStableCallback } from "../hooks/useStableCallback"; +import { + completeSidebarRouteReveal, + pendingSidebarRouteReveal, + type SidebarRouteReveal, +} from "./sidebar/routeReveal"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { @@ -741,7 +746,7 @@ const SIDEBAR_DRAG_DISTANCE = 6; const SIDEBAR_DRAG_LABEL_HEIGHT = 24; -export default function Sidebar() { +export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const { projects, serverConfigs, @@ -1061,17 +1066,9 @@ export default function Sidebar() { }); // History, the command palette, and links can open a thread the project - // scope hides or the list has scrolled away. Reveal it once per route - // change, so a scope picked while that thread stays open is left alone. - // Mounting counts as a route change: leaving Settings unmounts the sidebar. - const revealedRouteRowKeyRef = useRef(null); - const pendingRouteRevealKeyRef = useRef(null); + // scope hides or the list has scrolled away. const revealRouteRow = useStableCallback(() => { - if (revealedRouteRowKeyRef.current !== routeRowThreadKey) { - revealedRouteRowKeyRef.current = routeRowThreadKey; - pendingRouteRevealKeyRef.current = routeRowThreadKey; - } - const pendingKey = pendingRouteRevealKeyRef.current; + const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); if (pendingKey === null) return; const thread = threads.find((candidate) => threadShellKey(candidate) === pendingKey); if (thread === undefined) return; @@ -1084,7 +1081,7 @@ export default function Sidebar() { setProjectScopeKey(null); return; } - pendingRouteRevealKeyRef.current = null; + completeSidebarRouteReveal(routeReveal); document .querySelector('[data-sidebar="content"] [aria-current="page"]') ?.scrollIntoView({ block: "nearest" }); diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts new file mode 100644 index 0000000000000..4a84aa8133386 --- /dev/null +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -0,0 +1,22 @@ +// Owned by AppSidebarLayout because the narrow drawer and Settings unmount the +// thread sidebar without changing the route. Only a route change requests a +// reveal, so a scope or scroll chosen while one thread stays open survives. +export interface SidebarRouteReveal { + observedKey: string | null; + pendingKey: string | null; +} + +export function pendingSidebarRouteReveal( + reveal: SidebarRouteReveal, + routeKey: string | null, +): string | null { + if (reveal.observedKey !== routeKey) { + reveal.observedKey = routeKey; + reveal.pendingKey = routeKey; + } + return reveal.pendingKey; +} + +export function completeSidebarRouteReveal(reveal: SidebarRouteReveal): void { + reveal.pendingKey = null; +} From 2c84c2782957dfb058e64cfd7b7a7381cd7ef604 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:32:49 +0000 Subject: [PATCH 05/17] fix(web): sidebar scrolls to the open thread after a remount Settings and the narrow drawer unmount the thread sidebar, so it remounts at the top. Each mount now scrolls to the active row once, without widening a manually chosen project scope. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/LegacySidebar.tsx | 12 ++++++++---- apps/web/src/components/Sidebar.tsx | 12 +++++++++--- apps/web/src/components/sidebar/routeReveal.ts | 3 ++- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 226c508c7732c..db596c16851ed 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -3312,18 +3312,22 @@ export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRou sidebarThreadPreviewCount, expandedThreadListsByProject, }); - // History and the command palette can open a thread scrolled out of view. - // Its row can mount after the route does, once its environment's shells - // arrive, so the reveal stays pending until the row renders. + // History and the command palette can open a thread scrolled out of view, + // and a remount starts the list at the top, so each route change and each + // mount scroll to the open thread once. Its row can mount after the route + // does, once its environment's shells arrive, so the reveal waits for it. + const mountRevealOwed = useRef(true); const routeRowRendered = routeThreadKey !== null && visibleSidebarThreadKeys.includes(routeThreadKey); const revealRouteRow = useStableCallback(() => { - if (pendingSidebarRouteReveal(routeReveal, routeThreadKey) === null) return; + const pendingKey = pendingSidebarRouteReveal(routeReveal, routeThreadKey); + if (pendingKey === null && (!mountRevealOwed.current || routeThreadKey === null)) return; const activeRow = document.querySelector( '[data-sidebar="content"] [data-thread-item] [data-active="true"]', ); if (!activeRow) return; completeSidebarRouteReveal(routeReveal); + mountRevealOwed.current = false; activeRow.scrollIntoView({ block: "nearest" }); }); useEffect(revealRouteRow, [revealRouteRow, routeThreadKey, routeRowRendered]); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index b086e11177b14..10f5ee4b99533 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1066,13 +1066,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); // History, the command palette, and links can open a thread the project - // scope hides or the list has scrolled away. + // scope hides or the list has scrolled away. A remount starts the list at + // the top, so each mount also scrolls to the open thread once, without + // widening the scope. + const mountRevealOwed = useRef(true); const revealRouteRow = useStableCallback(() => { const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); - if (pendingKey === null) return; - const thread = threads.find((candidate) => threadShellKey(candidate) === pendingKey); + const revealKey = pendingKey ?? (mountRevealOwed.current ? routeRowThreadKey : null); + if (revealKey === null) return; + const thread = threads.find((candidate) => threadShellKey(candidate) === revealKey); if (thread === undefined) return; if ( + pendingKey !== null && scopedProjectKeys !== null && filterSidebarV2VisibleThreads([thread], scopedProjectKeys).length === 0 && filterSidebarV2VisibleThreads([thread], null).length === 1 @@ -1082,6 +1087,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve return; } completeSidebarRouteReveal(routeReveal); + mountRevealOwed.current = false; document .querySelector('[data-sidebar="content"] [aria-current="page"]') ?.scrollIntoView({ block: "nearest" }); diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index 4a84aa8133386..327154fcae138 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -1,6 +1,7 @@ // Owned by AppSidebarLayout because the narrow drawer and Settings unmount the // thread sidebar without changing the route. Only a route change requests a -// reveal, so a scope or scroll chosen while one thread stays open survives. +// reveal that may widen the project scope, so a scope chosen while one thread +// stays open survives a remount. Scroll position does not survive one. export interface SidebarRouteReveal { observedKey: string | null; pendingKey: string | null; From 2d9541442277478e938286c883a9ba662923887b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:44:08 +0000 Subject: [PATCH 06/17] test(web): extend synthetic sidebar capture fixtures --- scripts/fixtures/issue-16534/README.md | 54 ++--- scripts/fixtures/issue-16534/bootstrap.js | 46 ++-- scripts/fixtures/issue-16534/replay.js | 262 ++++++++++++++++++++++ scripts/fixtures/issue-16534/seed.py | 16 +- 4 files changed, 321 insertions(+), 57 deletions(-) create mode 100644 scripts/fixtures/issue-16534/replay.js diff --git a/scripts/fixtures/issue-16534/README.md b/scripts/fixtures/issue-16534/README.md index 53dfcc59c9341..e5daa35ad43e1 100644 --- a/scripts/fixtures/issue-16534/README.md +++ b/scripts/fixtures/issue-16534/README.md @@ -1,31 +1,31 @@ -Synthetic projection fixture for sidebar navigation evidence. No provider starts, -event history, copied user data, or auth values are included. `plain.json` is the -plain generated fixture; regenerate it with: +These fixtures reconstruct synthetic sidebar projections and client reset inputs. +They never start a provider or read personal application data. Projection-only +fixtures prove rendering/navigation, not event-sourced backend behavior. -```sh -python3 scripts/fixtures/issue-16534/seed.py --output /tmp/sidebar-seed -``` +Generate with `python3 scripts/fixtures/issue-16534/seed.py --output /tmp/sidebar-seed +--variant all`. Stop the isolated server before applying reset-threads.sql, +archive-unrelated-threads.sql, hide-v1-threads.sql, hide-unrelated-projects.sql, +projects.sql, threads.sql and messages.sql, in that order, with the documented +`t3-sqlite-state.ts exec` helper, once per file. Never target the live T3 home. -Use `--variant` for spacers (40 additional stopped threads), pinned, snoozed, -settled (16 settled A threads, beyond the ten-row tail), lineage, archived, or -orphan. Add `--legacy` to select the legacy sidebar. Generated timestamps and -hashes are deterministic. JSON payloads use OrchestrationV2AppThreadJson. +`all` includes two eight-thread projects, forty spacers, child/grandchild/fork +lineage and synthetic markdown navigation messages. Other variants isolate shelf, +archive and lineage states. `fixture.json` records deterministic clock/settings. +The replay option records stopped synthetic shell representations for working, +status and delayed arrival without spawning providers. `replay.js` transforms +only real shell frames before the real RPC decoder/reducer receives them; delayed +shell frames are released explicitly through `__sidebarReplay.release()`. -Only after the isolated server owner stops it, apply the generated files in -order: reset-threads.sql, hide-unrelated-projects.sql, projects.sql, threads.sql. -Use `node apps/server/scripts/t3-sqlite-state.ts exec --base-dir ---file `, once per file. The helper backs up each mutation. Never use the -live home. Restart through the owner. These writes intentionally do not create -event history and cannot prove backend behavior. +Install bootstrap.js and replay.js as identical pre-module scripts in the served +HTML only for capture. Remove both before committing or switching checkouts. +Use a fresh navigation, never document.write over an evaluated module graph. +Configure `sessionStorage['sidebar-capture-config']` with `{storage, replay}`. +Clear only projection caches (shell/thread/vcs-refs in t3code:connection-runtime) +from a same-origin inert document before navigating to the app. Preserve auth. +Timers and performance.now remain native; only Date is frozen. Record the exact +injection externally with the artifacts. -Client settings and UI reset inputs are in fixture.json. Environment-scoped keys, -project order, initial route/history, shelf/list expansion, search, scroll offsets, -and keyboard bindings must be measured and recorded for each scenario before -capture. The generated empty scoped-key maps are starting inputs, not a claim -that all matrix scenarios are initialized. UI interactions establish those states. - -Working/status/grouping and delayed protocol replay variants are not implemented -here. Record those segments as blocked until valid fixtures exist. `bootstrap.js` -is a proposed capture-tab document reboot for pre-module clock/storage setup; -validate it in the real app before accepting any frozen-clock evidence. It does -not change the served source or any timer API. Server clock is not frozen. +For grouping, initialize the three synthetic /tmp/triage-16534-{A,B,Spacer} +paths as git repositories with the same synthetic origin, then use repository +grouping and an explicit separate override for Spacer. No network fetch needed. +All navigation links are synthetic t3-thread links rendered by the real client. diff --git a/scripts/fixtures/issue-16534/bootstrap.js b/scripts/fixtures/issue-16534/bootstrap.js index 6104e7a9ba174..e5d8e61f666b6 100644 --- a/scripts/fixtures/issue-16534/bootstrap.js +++ b/scripts/fixtures/issue-16534/bootstrap.js @@ -1,27 +1,21 @@ -/** Reboot the real Vite document with a deterministic client clock and storage. - * Run in the capture tab through preview_evaluate after pairing. The returned - * source document is unmodified except for this pre-module fixture bootstrap. - * No timer API is replaced. Auth and connection storage are preserved. - */ -async function rebootSidebarFixture(fixture) { - const response = await fetch(location.href, { cache: "no-store" }); - if (!response.ok) throw new Error(`Fixture document fetch failed: ${response.status}`); - const html = await response.text(); - const source = `(() => { - const fixture = ${JSON.stringify(fixture).replaceAll("<", "\\u003c")}; - const NativeDate = Date; - const epoch = NativeDate.parse(fixture.clock); - class FixtureDate extends NativeDate { - constructor(...args) { super(...(args.length ? args : [epoch])); } - static now() { return epoch; } +(() => { + const NativeDate = Date, + epoch = NativeDate.parse("2026-10-22T00:00:00.000Z"); + class FixtureDate extends NativeDate { + constructor(...args) { + super(...(args.length ? args : [epoch])); } - globalThis.Date = FixtureDate; - globalThis.__sidebarCaptureClock = fixture.clock; - localStorage.setItem('t3code:client-settings:v1', JSON.stringify(fixture.settings)); - localStorage.setItem('t3code:ui-state:v1', JSON.stringify(fixture.uiState)); - })();`; - if (!html.includes("")) throw new Error("Expected the real application document"); - document.open(); - document.write(html.replace("", ``)); - document.close(); -} + static now() { + return epoch; + } + } + globalThis.Date = FixtureDate; + globalThis.__sidebarCaptureClock = { + epoch, + readyState: document.readyState, + moduleScripts: document.querySelectorAll('script[type="module"]').length, + }; + const config = JSON.parse(sessionStorage.getItem("sidebar-capture-config") || "{}"); + for (const [key, value] of Object.entries(config.storage || {})) + localStorage.setItem(key, typeof value === "string" ? value : JSON.stringify(value)); +})(); diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js new file mode 100644 index 0000000000000..60cfc61d6d27f --- /dev/null +++ b/scripts/fixtures/issue-16534/replay.js @@ -0,0 +1,262 @@ +/** Capture-only protocol fixture. Install before modules; never import in product code. + * Pass options through sessionStorage sidebar-capture-config.replay. Real server + * frames still travel through the real RPC decoder and client reducers. + */ +(() => { + const options = JSON.parse(sessionStorage.getItem("sidebar-capture-config") || "{}").replay || {}; + const NativeWebSocket = WebSocket; + const held = []; + globalThis.__sidebarWire = []; + const frames = []; + globalThis.__sidebarReplay = { + frames, + held, + release() { + if (delayedThread && shellSocket && shellRequestId !== null) { + shellSocket.dispatchEvent( + new MessageEvent("message", { + data: JSON.stringify({ + _tag: "Chunk", + requestId: shellRequestId, + values: [ + { + kind: "thread.updated", + sequence: shellSequence + 1, + location: "active", + thread: delayedThread, + }, + ], + }), + }), + ); + delayedThread = null; + } + for (const run of held.splice(0)) run(); + }, + }; + + let delayedThread = null; + let shellSocket = null; + let shellRequestId = null; + let shellSequence = 0; + const transformSnapshot = (snapshot) => { + snapshot.threads = snapshot.threads.filter( + (t) => + t.id.startsWith("triage-") && (options.lineage || !/^triage-A-(9|10|11|12)$/.test(t.id)), + ); + snapshot.projects = snapshot.projects?.filter((p) => p.id?.startsWith("triage-")) || []; + shellSequence = snapshot.snapshotSequence; + if (options.delay) { + delayedThread = snapshot.threads.find((t) => t.id === "triage-A-1"); + snapshot.threads = snapshot.threads.filter((t) => t.id !== "triage-A-1"); + } + for (const thread of snapshot.threads) { + if (thread.id === "triage-A-1" && options.shelf) { + Object.assign(thread, { + pinnedAt: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + }); + if (options.shelf === "pinned") + Object.assign(thread, { pinnedAt: "2026-10-22T00:00:00.000Z", pinOrderKey: "a0" }); + if (options.shelf === "snoozed") + Object.assign(thread, { + snoozedUntil: "2099-01-01T00:00:00.000Z", + snoozedAt: "2026-10-22T00:00:00.000Z", + }); + if (options.shelf === "working") + Object.assign(thread, { + status: "running", + activeRunId: "triage-run-working", + latestRunId: "triage-run-working", + activityRunStatus: "running", + latestRunRequestedAt: "2026-10-21T23:00:00.000Z", + latestRunStartedAt: "2026-10-21T23:00:00.000Z", + }); + } + if (options.shelf === "settled" && thread.projectId === "triage-project-A") { + Object.assign(thread, { settledOverride: "settled", settledAt: thread.createdAt }); + } + if (options.status && ["triage-A-1", "triage-A-2"].includes(thread.id)) { + Object.assign(thread, { + status: thread.id === "triage-A-1" ? "failed" : "completed", + latestRunId: "triage-run-" + thread.id, + latestRunRequestedAt: "2026-10-21T23:00:00.000Z", + latestRunCompletedAt: "2026-10-21T23:01:00.000Z", + lastVisitedAt: null, + }); + } + } + return snapshot; + }; + const nativeFetch = fetch; + globalThis.fetch = async (...args) => { + const response = await nativeFetch(...args); + const url = typeof args[0] === "string" ? args[0] : args[0]?.url; + if (!url?.includes("/api/orchestration/shell") || !response.ok) return response; + const value = await response.clone().json(); + if (!Array.isArray(value.threads)) return response; + frames.push({ + kind: "http.shell", + count: value.threads.length, + sequence: value.snapshotSequence, + }); + transformSnapshot(value); + return new Response(JSON.stringify(value), { + status: response.status, + headers: { "content-type": "application/json" }, + }); + }; + + globalThis.WebSocket = class extends NativeWebSocket { + constructor(...args) { + super(...args); + let replaying = false; + this.addEventListener("message", (event) => { + if (replaying) return; + frames.push({ + kind: "frame", + type: typeof event.data, + constructor: event.data?.constructor?.name, + length: event.data?.length, + }); + if (typeof event.data !== "string") return; + let value; + let ndjson = false; + try { + value = JSON.parse(event.data); + } catch { + try { + value = event.data + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + ndjson = true; + } catch { + frames.push({ kind: "unparsed", length: event.data.length }); + return; + } + } + globalThis.__sidebarWire.push(value); + if ( + value._tag === "Chunk" && + value.values?.some( + (x) => x?.kind === "synchronized" || (x?.kind === "snapshot" && x.snapshot?.threads), + ) + ) { + shellSocket = this; + shellRequestId = value.requestId; + } + frames.push({ + kind: "parsed", + tag: value?._tag, + keys: Object.keys(value || {}), + count: Array.isArray(value) ? value.length : undefined, + }); + let changed = false; + const visit = (node) => { + if (!node || typeof node !== "object") return; + if (node.kind === "snapshot" && node.snapshot && Array.isArray(node.snapshot.threads)) { + const snapshot = node.snapshot; + frames.push({ + kind: "shell.snapshot", + count: snapshot.threads.length, + sequence: snapshot.snapshotSequence, + }); + snapshot.threads = snapshot.threads.filter( + (t) => + t.id.startsWith("triage-") && + (options.lineage || !/^triage-A-(9|10|11|12)$/.test(t.id)), + ); + changed = true; + const target = snapshot.threads.find((t) => t.id === "triage-A-1"); + if (options.delay && target) { + snapshot.threads = snapshot.threads.filter((t) => t !== target); + const original = JSON.parse(JSON.stringify(value)); + const replace = (n) => { + if (!n || typeof n !== "object") return; + if (n.kind === "snapshot" && n.snapshot && Array.isArray(n.snapshot.threads)) { + delete n.snapshot; + n.kind = "thread.updated"; + n.sequence = snapshot.snapshotSequence + 1; + n.location = "active"; + n.thread = target; + } else Object.values(n).forEach(replace); + }; + replace(original); + held.push(() => deliver(original)); + changed = true; + } + for (const thread of snapshot.threads) { + if (thread.id === "triage-A-1" && options.shelf) { + Object.assign(thread, { + pinnedAt: null, + snoozedUntil: null, + settledOverride: null, + settledAt: null, + }); + if (options.shelf === "pinned") + Object.assign(thread, { + pinnedAt: "2026-10-22T00:00:00.000Z", + pinOrderKey: "a0", + }); + if (options.shelf === "snoozed") + Object.assign(thread, { + snoozedUntil: "2099-01-01T00:00:00.000Z", + snoozedAt: "2026-10-22T00:00:00.000Z", + }); + if (options.shelf === "working") + Object.assign(thread, { + status: "running", + activeRunId: "triage-run-working", + latestRunId: "triage-run-working", + activityRunStatus: "running", + latestRunRequestedAt: "2026-10-21T23:00:00.000Z", + latestRunStartedAt: "2026-10-21T23:00:00.000Z", + }); + changed = true; + } + if (options.shelf === "settled" && thread.projectId === "triage-project-A") { + Object.assign(thread, { settledOverride: "settled", settledAt: thread.createdAt }); + changed = true; + } + if (options.status && ["triage-A-1", "triage-A-2"].includes(thread.id)) { + Object.assign(thread, { + status: thread.id === "triage-A-1" ? "failed" : "completed", + latestRunId: "triage-run-" + thread.id, + latestRunRequestedAt: "2026-10-21T23:00:00.000Z", + latestRunCompletedAt: "2026-10-21T23:01:00.000Z", + lastVisitedAt: null, + }); + changed = true; + } + } + } + Object.values(node).forEach((v) => { + if (v && typeof v === "object") Array.isArray(v) ? v.forEach(visit) : visit(v); + }); + }; + const deliver = (data) => { + replaying = true; + try { + this.dispatchEvent( + new MessageEvent("message", { + data: ndjson + ? data.map((item) => JSON.stringify(item)).join("\n") + "\n" + : JSON.stringify(data), + }), + ); + } finally { + replaying = false; + } + }; + visit(value); + if (changed) { + event.stopImmediatePropagation(); + deliver(value); + } + }); + } + }; +})(); diff --git a/scripts/fixtures/issue-16534/seed.py b/scripts/fixtures/issue-16534/seed.py index 63bd111e0938d..04be4d9c51001 100644 --- a/scripts/fixtures/issue-16534/seed.py +++ b/scripts/fixtures/issue-16534/seed.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """Generate synthetic sidebar projections without reading personal application data. -Generation never touches a database. Apply seed.sql with the documented SQLite +Generation never touches a database. Apply the generated SQL files with the documented SQLite helper only after the environment owner has stopped the isolated server. These are projection fixtures, not event-history or provider integration fixtures. """ @@ -38,14 +38,14 @@ def thread(group, number): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--output', type=Path, required=True) - parser.add_argument('--variant', choices=['plain', 'spacers', 'pinned', 'snoozed', 'settled', 'lineage', 'archived', 'orphan'], default='plain') + parser.add_argument('--variant', choices=['plain', 'spacers', 'pinned', 'snoozed', 'settled', 'lineage', 'archived', 'orphan', 'working', 'status', 'grouped', 'all'], default='plain') parser.add_argument('--legacy', action='store_true') args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) threads = [thread(group, n) for group in ['A', 'B'] for n in range(1, 9)] projects = ['A', 'B'] target = threads[0] - if args.variant == 'spacers': + if args.variant in ['spacers', 'all']: projects.append('Spacer') threads += [thread('Spacer', n) for n in range(1, 41)] if args.variant == 'pinned': @@ -61,7 +61,7 @@ def main(): threads.append(settled) if args.variant == 'archived': target['archivedAt'] = CLOCK - if args.variant in ['lineage', 'orphan']: + if args.variant in ['lineage', 'orphan', 'all']: for n, parent, relationship in [(9, target['id'], 'subagent'), (10, 'triage-A-9', 'subagent'), (11, target['id'], 'fork'), (12, target['id'], 'subagent')]: child = thread('A', n) child['lineage'] = dict(parentThreadId=parent, relationshipToParent=relationship, rootThreadId=target['id']) @@ -69,6 +69,12 @@ def main(): if args.variant == 'orphan': threads[16]['lineage']['parentThreadId'] = 'triage-missing-root' threads[16]['lineage']['rootThreadId'] = 'triage-missing-root' + messages = [] + for group in ['A', 'B']: + text = "Synthetic navigation links: " + " | ".join(f"[Open {label}](t3-thread://v1/{target_id})" for label,target_id in [('A1','triage-A-1'),('A8','triage-A-8'),('child','triage-A-9'),('grandchild','triage-A-10'),('sibling','triage-A-12'),('fork','triage-A-11'),('missing','triage-missing')]) + messages.append(dict(id=f'triage-link-{group}',threadId=f'triage-{group}-8',runId=None,nodeId=None,role='assistant',text=text,attachments=[],streaming=False,createdAt=CLOCK,updatedAt=CLOCK,createdBy='agent',creationSource='web')) + message_values = ['(' + ','.join(map(quote,[m['id'],m['threadId'],None,None,m['role'],0,CLOCK,CLOCK,encoded(m)])) + ')' for m in messages] + (args.output/'messages.sql').write_text('INSERT OR REPLACE INTO orchestration_v2_projection_messages (message_id,thread_id,run_id,node_id,role,streaming,created_at,updated_at,payload_json) VALUES ' + ','.join(message_values) + ';\n') # One statement, so the state helper can apply it as one atomic transaction. project_values = [] for group in projects: @@ -83,6 +89,8 @@ def main(): uiState=dict(projectExpandedById={},projectOrder=[],threadLastVisitedAtById={},sidebarProjectScopeKey=None)) (args.output/'reset-threads.sql').write_text("DELETE FROM orchestration_v2_projection_threads WHERE thread_id LIKE 'triage-%';\n") (args.output/'hide-unrelated-projects.sql').write_text("UPDATE projection_projects SET deleted_at=" + quote(CLOCK) + " WHERE project_id NOT IN (" + ','.join(quote('triage-project-' + group) for group in projects) + ");\n") + (args.output/'archive-unrelated-threads.sql').write_text("UPDATE orchestration_v2_projection_threads SET archived_at=" + quote(CLOCK) + ",payload_json=json_set(payload_json,'$.archivedAt'," + quote(CLOCK) + ") WHERE thread_id NOT LIKE 'triage-%';\n") + (args.output/'hide-v1-threads.sql').write_text("UPDATE projection_threads SET deleted_at="+quote(CLOCK)+" WHERE thread_id NOT LIKE 'triage-%';\n") (args.output/'projects.sql').write_text(project_sql) (args.output/'threads.sql').write_text(sql) (args.output/'fixture.json').write_text(encoded(fixture)+'\n') From a0462dfc86603e0e24958e8c1ce2e999d9d0339c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:49:13 +0000 Subject: [PATCH 07/17] test(web): handle URL inputs in sidebar snapshot replay --- scripts/fixtures/issue-16534/replay.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index 60cfc61d6d27f..ab9a5d45ad5a1 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -93,8 +93,9 @@ const nativeFetch = fetch; globalThis.fetch = async (...args) => { const response = await nativeFetch(...args); - const url = typeof args[0] === "string" ? args[0] : args[0]?.url; - if (!url?.includes("/api/orchestration/shell") || !response.ok) return response; + const url = String(args[0]?.url ?? args[0]); + if (!options.enabled || !url.includes("/api/orchestration/shell") || !response.ok) + return response; const value = await response.clone().json(); if (!Array.isArray(value.threads)) return response; frames.push({ From 8bc79af8d5a027940dc140a46e4580ed98ff2598 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 08:30:03 +0000 Subject: [PATCH 08/17] test(web): seed rendered navigation transcripts and settled tail --- scripts/fixtures/issue-16534/README.md | 3 ++- scripts/fixtures/issue-16534/replay.js | 11 ++++++++++ scripts/fixtures/issue-16534/seed.py | 7 ++++++ scripts/fixtures/issue-16534/transcript.py | 25 ++++++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 scripts/fixtures/issue-16534/transcript.py diff --git a/scripts/fixtures/issue-16534/README.md b/scripts/fixtures/issue-16534/README.md index e5daa35ad43e1..0ec2a42943be1 100644 --- a/scripts/fixtures/issue-16534/README.md +++ b/scripts/fixtures/issue-16534/README.md @@ -5,7 +5,8 @@ fixtures prove rendering/navigation, not event-sourced backend behavior. Generate with `python3 scripts/fixtures/issue-16534/seed.py --output /tmp/sidebar-seed --variant all`. Stop the isolated server before applying reset-threads.sql, archive-unrelated-threads.sql, hide-v1-threads.sql, hide-unrelated-projects.sql, -projects.sql, threads.sql and messages.sql, in that order, with the documented +projects.sql, threads.sql, messages.sql, turn-items.sql, transcript-runs.sql, +transcript-messages.sql and transcript-turn-items.sql, in that order, with the documented `t3-sqlite-state.ts exec` helper, once per file. Never target the live T3 home. `all` includes two eight-thread projects, forty spacers, child/grandchild/fork diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index ab9a5d45ad5a1..1b5fec5175087 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -44,6 +44,17 @@ (t) => t.id.startsWith("triage-") && (options.lineage || !/^triage-A-(9|10|11|12)$/.test(t.id)), ); + if (options.shelf === "settled") { + const source = snapshot.threads.find((t) => t.id === "triage-A-8"); + for (let i = 13; i <= 26; i++) + snapshot.threads.push({ + ...source, + id: "triage-A-" + i, + title: "Triage A thread " + i, + latestMessageAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", + updatedAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", + }); + } snapshot.projects = snapshot.projects?.filter((p) => p.id?.startsWith("triage-")) || []; shellSequence = snapshot.snapshotSequence; if (options.delay) { diff --git a/scripts/fixtures/issue-16534/seed.py b/scripts/fixtures/issue-16534/seed.py index 04be4d9c51001..93aec53c87410 100644 --- a/scripts/fixtures/issue-16534/seed.py +++ b/scripts/fixtures/issue-16534/seed.py @@ -75,6 +75,11 @@ def main(): messages.append(dict(id=f'triage-link-{group}',threadId=f'triage-{group}-8',runId=None,nodeId=None,role='assistant',text=text,attachments=[],streaming=False,createdAt=CLOCK,updatedAt=CLOCK,createdBy='agent',creationSource='web')) message_values = ['(' + ','.join(map(quote,[m['id'],m['threadId'],None,None,m['role'],0,CLOCK,CLOCK,encoded(m)])) + ')' for m in messages] (args.output/'messages.sql').write_text('INSERT OR REPLACE INTO orchestration_v2_projection_messages (message_id,thread_id,run_id,node_id,role,streaming,created_at,updated_at,payload_json) VALUES ' + ','.join(message_values) + ';\n') + item_values = [] + for m in messages: + item=dict(id=m['id']+'-item',threadId=m['threadId'],runId=None,nodeId=None,providerThreadId=None,providerTurnId=None,nativeItemRef=None,parentItemId=None,ordinal=0,status='completed',title=None,startedAt=CLOCK,completedAt=CLOCK,updatedAt=CLOCK,type='assistant_message',messageId=m['id'],text=m['text'],streaming=False) + item_values.append('(' + ','.join(map(quote,[item['id'],item['threadId'],None,None,None,None,None,0,'assistant_message','completed',CLOCK,encoded(item)])) + ')') + (args.output/'turn-items.sql').write_text('INSERT OR REPLACE INTO orchestration_v2_projection_turn_items (turn_item_id,thread_id,run_id,node_id,provider_thread_id,provider_turn_id,parent_item_id,ordinal,type,status,updated_at,payload_json) VALUES '+','.join(item_values)+';\n') # One statement, so the state helper can apply it as one atomic transaction. project_values = [] for group in projects: @@ -96,6 +101,8 @@ def main(): (args.output/'fixture.json').write_text(encoded(fixture)+'\n') digest = hashlib.sha256(encoded(fixture).encode()).hexdigest() (args.output/'sha256.txt').write_text(digest+'\n') + from transcript import write_transcript + write_transcript(args.output) print(digest) diff --git a/scripts/fixtures/issue-16534/transcript.py b/scripts/fixtures/issue-16534/transcript.py new file mode 100644 index 0000000000000..135d5caf37a75 --- /dev/null +++ b/scripts/fixtures/issue-16534/transcript.py @@ -0,0 +1,25 @@ +"""Emit stopped synthetic runs and transcript items for real thread-link rendering.""" +import json +from pathlib import Path + +def write_transcript(output): + clock='2026-10-21T23:00:00.000Z' + def q(v): return 'NULL' if v is None else "'"+str(v).replace("'","''")+"'" + def enc(v):return json.dumps(v,separators=(',',':')) + runs=[];messages=[];items=[] + for group in ['A','B']: + tid=f'triage-{group}-8';rid=f'triage-fixture-run-{group}';uid=rid+'-user';aid=rid+'-assistant' + run=dict(id=rid,threadId=tid,ordinal=1,providerInstanceId='codex',modelSelection=dict(instanceId='codex',model='gpt-6.1-sol'),providerThreadId=None,userMessageId=uid,rootNodeId=None,activeAttemptId=None,status='completed',requestedAt=clock,startedAt=clock,completedAt=clock,checkpointId=None,contextHandoffId=None) + runs.append('('+','.join(map(q,[rid,tid,1,'codex',None,'completed',clock,clock,enc(run),'codex']))+')') + linktext='Synthetic fixture navigation: '+' | '.join(f'[{label}](t3-thread://v1/{target})' for label,target in [('Open A1','triage-A-1'),('Open A8','triage-A-8'),('Open child','triage-A-9'),('Open grandchild','triage-A-10'),('Open sibling','triage-A-12'),('Open fork','triage-A-11'),('Open missing','triage-missing')]) + for ordinal,(mid,role,text) in enumerate([(uid,'user','Use these synthetic navigation links.'),(aid,'assistant',linktext)]): + m=dict(id=mid,threadId=tid,runId=rid,nodeId=None,role=role,text=text,attachments=[],streaming=False,createdAt=clock,updatedAt=clock,createdBy='user' if role=='user' else 'agent',creationSource='web') + messages.append('('+','.join(map(q,[mid,tid,rid,None,role,0,clock,clock,enc(m)]))+')') + item=dict(id=mid+'-item',threadId=tid,runId=rid,nodeId=None,providerThreadId=None,providerTurnId=None,nativeItemRef=None,parentItemId=None,ordinal=ordinal,status='completed',title=None,startedAt=clock,completedAt=clock,updatedAt=clock,type=role+'_message',messageId=mid,text=text,streaming=False) + if role=='user':item.update(createdBy='user',creationSource='web',inputIntent='turn_start',attachments=[]) + items.append('('+','.join(map(q,[item['id'],tid,rid,None,None,None,None,ordinal,item['type'],'completed',clock,enc(item)]))+')') + tables=[('runs','run_id,thread_id,ordinal,provider,provider_thread_id,status,requested_at,completed_at,payload_json,provider_instance_id',runs),('messages','message_id,thread_id,run_id,node_id,role,streaming,created_at,updated_at,payload_json',messages),('turn_items','turn_item_id,thread_id,run_id,node_id,provider_thread_id,provider_turn_id,parent_item_id,ordinal,type,status,updated_at,payload_json',items)] + for table,columns,values in tables:(output/('transcript-'+table.replace('_','-')+'.sql')).write_text(f'INSERT OR REPLACE INTO orchestration_v2_projection_{table} ({columns}) VALUES '+','.join(values)+';\n') +if __name__=='__main__': + import sys + write_transcript(Path(sys.argv[1])) From 1c7322e0ecba4a7301a170b576157984e88548b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:02:45 +0000 Subject: [PATCH 09/17] fix(web): returning from Settings keeps the sidebar project scope Leaving Settings commits the router location before its matches, so the remounted thread sidebar first rendered with no thread params. The reveal tracker recorded that null key, then treated the same thread as a new route and widened a manually chosen scope. A null route key is now ignored. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 22 +++++++++++++++++++ .../web/src/components/sidebar/routeReveal.ts | 5 +++++ 2 files changed, 27 insertions(+) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 18197317cd192..0485e93ff74c4 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -44,6 +44,7 @@ import { type Thread, } from "../types"; import { makeThreadFixture, type ThreadFixtureOverrides } from "../test-fixtures"; +import { completeSidebarRouteReveal, pendingSidebarRouteReveal } from "./sidebar/routeReveal"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -1216,3 +1217,24 @@ describe("Working shelf (beta)", () => { }); }); }); + +describe("pendingSidebarRouteReveal", () => { + it("keeps a revealed thread settled across a mount that reads no route params", () => { + const reveal = { observedKey: null, pendingKey: null }; + expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBe("env:a1"); + completeSidebarRouteReveal(reveal); + + // Back from Settings: location commits before the thread match does. + expect(pendingSidebarRouteReveal(reveal, null)).toBeNull(); + expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBeNull(); + + expect(pendingSidebarRouteReveal(reveal, "env:b1")).toBe("env:b1"); + }); + + it("keeps a pending reveal through a render without route params", () => { + const reveal = { observedKey: null, pendingKey: null }; + pendingSidebarRouteReveal(reveal, "env:a1"); + expect(pendingSidebarRouteReveal(reveal, null)).toBeNull(); + expect(pendingSidebarRouteReveal(reveal, "env:a1")).toBe("env:a1"); + }); +}); diff --git a/apps/web/src/components/sidebar/routeReveal.ts b/apps/web/src/components/sidebar/routeReveal.ts index 327154fcae138..d1be939ee7226 100644 --- a/apps/web/src/components/sidebar/routeReveal.ts +++ b/apps/web/src/components/sidebar/routeReveal.ts @@ -2,6 +2,10 @@ // thread sidebar without changing the route. Only a route change requests a // reveal that may widen the project scope, so a scope chosen while one thread // stays open survives a remount. Scroll position does not survive one. +// +// A null key is not an observation: leaving Settings commits the router's +// location before its matches, so the sidebar's first render can read no +// thread params. Recording that would make the same thread look new. export interface SidebarRouteReveal { observedKey: string | null; pendingKey: string | null; @@ -11,6 +15,7 @@ export function pendingSidebarRouteReveal( reveal: SidebarRouteReveal, routeKey: string | null, ): string | null { + if (routeKey === null) return null; if (reveal.observedKey !== routeKey) { reveal.observedKey = routeKey; reveal.pendingKey = routeKey; From ab95d441709fb3f632207b7ec3283ca0ce963be5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:47:42 +0000 Subject: [PATCH 10/17] chore(lint): knip ignores injected sidebar fixture scripts Co-Authored-By: Claude Opus 5.5 --- knip.jsonc | 3 ++- scripts/fixtures/issue-16534/replay.js | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index 7fbfe38259951..dbd148d7abcdc 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -30,7 +30,8 @@ ], "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. - "ignore": ["npm-launcher/**"], + // Fixture scripts are injected into a browser page, never imported. + "ignore": ["npm-launcher/**", "fixtures/**/*.js"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index 1b5fec5175087..cf08f00f9a108 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -246,7 +246,9 @@ } } Object.values(node).forEach((v) => { - if (v && typeof v === "object") Array.isArray(v) ? v.forEach(visit) : visit(v); + if (!v || typeof v !== "object") return; + if (Array.isArray(v)) v.forEach(visit); + else visit(v); }); }; const deliver = (data) => { From a9235743bea4359b68be3e5c83df2c96e589dd13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:52:05 +0000 Subject: [PATCH 11/17] fix(web): sidebar reveal waits for search to clear and the row to mount A route reveal completed before the active row existed, so opening a thread while a retained search hid it never scrolled to it once the search was cleared. The reveal now waits while searching and completes only after it finds the row. In the legacy sidebar, the open thread past the preview cut no longer alone shows "Show more". Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/LegacySidebar.tsx | 10 +++++++--- apps/web/src/components/Sidebar.tsx | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index db596c16851ed..35c8cae548ac1 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1211,13 +1211,17 @@ function useProjectThreadPanel(input: { ) ?? null) : null; const pinnedCollapsedThread = projectExpanded ? null : activeThread; - const hasOverflowingThreads = visibleProjectThreads.length > sidebarThreadPreviewCount; + // The open thread stays listed past the preview cut, as it does in a + // collapsed project, so it alone does not overflow the preview. + const hasOverflowingThreads = visibleProjectThreads.some( + (thread, index) => + index >= sidebarThreadPreviewCount && + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) !== activeThreadKey, + ); const previewThreads = isThreadListExpanded || !hasOverflowingThreads ? visibleProjectThreads : visibleProjectThreads.slice(0, sidebarThreadPreviewCount); - // The open thread stays listed past the preview cut, as it does in a - // collapsed project. const visibleThreadKeys = new Set( [...previewThreads, ...(activeThread ? [activeThread] : [])].map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 10f5ee4b99533..7f03b6c5c7857 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1065,6 +1065,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeThreadRef, }); + const isSearchingThreads = threadSearchQuery.trim().length > 0; + // History, the command palette, and links can open a thread the project // scope hides or the list has scrolled away. A remount starts the list at // the top, so each mount also scrolls to the open thread once, without @@ -1072,6 +1074,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const mountRevealOwed = useRef(true); const revealRouteRow = useStableCallback(() => { const pendingKey = pendingSidebarRouteReveal(routeReveal, routeRowThreadKey); + // Search results replace the thread list; reveal once the list returns. + if (isSearchingThreads) return; const revealKey = pendingKey ?? (mountRevealOwed.current ? routeRowThreadKey : null); if (revealKey === null) return; const thread = threads.find((candidate) => threadShellKey(candidate) === revealKey); @@ -1086,15 +1090,20 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve setProjectScopeKey(null); return; } + const activeRow = document.querySelector('[data-sidebar="content"] [aria-current="page"]'); + if (!activeRow) return; completeSidebarRouteReveal(routeReveal); mountRevealOwed.current = false; - document - .querySelector('[data-sidebar="content"] [aria-current="page"]') - ?.scrollIntoView({ block: "nearest" }); + activeRow.scrollIntoView({ block: "nearest" }); }); - useEffect(revealRouteRow, [revealRouteRow, routeRowThreadKey, threads, projectScopeKey]); + useEffect(revealRouteRow, [ + revealRouteRow, + routeRowThreadKey, + threads, + projectScopeKey, + isSearchingThreads, + ]); - const isSearchingThreads = threadSearchQuery.trim().length > 0; const threadSearchMatchByKey = new Map( threadSearch.matches.map((match) => [threadSearchMatchKey(match), match] as const), ); From 1d04ab0f773866475cc4814566d8e62c6e5c0efd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:52:18 +0000 Subject: [PATCH 12/17] fix(fixtures): align sidebar replay across shell transports --- scripts/fixtures/issue-16534/replay.js | 185 ++++++++++--------------- 1 file changed, 74 insertions(+), 111 deletions(-) diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index cf08f00f9a108..2439fb30e8ad5 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -39,7 +39,27 @@ let shellSocket = null; let shellRequestId = null; let shellSequence = 0; - const transformSnapshot = (snapshot) => { + const parseFrame = (data) => { + try { + const value = JSON.parse(data); + const ndjson = data.endsWith("\n") && !Array.isArray(value); + return { value: ndjson ? [value] : value, ndjson }; + } catch { + return { + value: data + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ndjson: true, + }; + } + }; + const transformSnapshot = ( + snapshot, + hold = (thread) => { + delayedThread = thread; + }, + ) => { snapshot.threads = snapshot.threads.filter( (t) => t.id.startsWith("triage-") && (options.lineage || !/^triage-A-(9|10|11|12)$/.test(t.id)), @@ -57,10 +77,6 @@ } snapshot.projects = snapshot.projects?.filter((p) => p.id?.startsWith("triage-")) || []; shellSequence = snapshot.snapshotSequence; - if (options.delay) { - delayedThread = snapshot.threads.find((t) => t.id === "triage-A-1"); - snapshot.threads = snapshot.threads.filter((t) => t.id !== "triage-A-1"); - } for (const thread of snapshot.threads) { if (thread.id === "triage-A-1" && options.shelf) { Object.assign(thread, { @@ -99,6 +115,11 @@ }); } } + if (options.delay) { + const target = snapshot.threads.find((t) => t.id === "triage-A-1"); + if (target) hold(target); + snapshot.threads = snapshot.threads.filter((t) => t.id !== "triage-A-1"); + } return snapshot; }; const nativeFetch = fetch; @@ -122,6 +143,21 @@ }; globalThis.WebSocket = class extends NativeWebSocket { + send(data) { + super.send(data); + if (!options.enabled || typeof data !== "string") return; + try { + const { value } = parseFrame(data); + for (const request of Array.isArray(value) ? value : [value]) { + if (request?._tag === "Request" && request.tag === "orchestration.subscribeShell") { + shellSocket = this; + shellRequestId = request.id; + } + } + } catch { + // Non-JSON socket traffic belongs to the native protocol handler. + } + } constructor(...args) { super(...args); let replaying = false; @@ -135,31 +171,14 @@ }); if (typeof event.data !== "string") return; let value; - let ndjson = false; + let ndjson; try { - value = JSON.parse(event.data); + ({ value, ndjson } = parseFrame(event.data)); } catch { - try { - value = event.data - .trim() - .split("\n") - .map((line) => JSON.parse(line)); - ndjson = true; - } catch { - frames.push({ kind: "unparsed", length: event.data.length }); - return; - } + frames.push({ kind: "unparsed", length: event.data.length }); + return; } globalThis.__sidebarWire.push(value); - if ( - value._tag === "Chunk" && - value.values?.some( - (x) => x?.kind === "synchronized" || (x?.kind === "snapshot" && x.snapshot?.threads), - ) - ) { - shellSocket = this; - shellRequestId = value.requestId; - } frames.push({ kind: "parsed", tag: value?._tag, @@ -167,90 +186,6 @@ count: Array.isArray(value) ? value.length : undefined, }); let changed = false; - const visit = (node) => { - if (!node || typeof node !== "object") return; - if (node.kind === "snapshot" && node.snapshot && Array.isArray(node.snapshot.threads)) { - const snapshot = node.snapshot; - frames.push({ - kind: "shell.snapshot", - count: snapshot.threads.length, - sequence: snapshot.snapshotSequence, - }); - snapshot.threads = snapshot.threads.filter( - (t) => - t.id.startsWith("triage-") && - (options.lineage || !/^triage-A-(9|10|11|12)$/.test(t.id)), - ); - changed = true; - const target = snapshot.threads.find((t) => t.id === "triage-A-1"); - if (options.delay && target) { - snapshot.threads = snapshot.threads.filter((t) => t !== target); - const original = JSON.parse(JSON.stringify(value)); - const replace = (n) => { - if (!n || typeof n !== "object") return; - if (n.kind === "snapshot" && n.snapshot && Array.isArray(n.snapshot.threads)) { - delete n.snapshot; - n.kind = "thread.updated"; - n.sequence = snapshot.snapshotSequence + 1; - n.location = "active"; - n.thread = target; - } else Object.values(n).forEach(replace); - }; - replace(original); - held.push(() => deliver(original)); - changed = true; - } - for (const thread of snapshot.threads) { - if (thread.id === "triage-A-1" && options.shelf) { - Object.assign(thread, { - pinnedAt: null, - snoozedUntil: null, - settledOverride: null, - settledAt: null, - }); - if (options.shelf === "pinned") - Object.assign(thread, { - pinnedAt: "2026-10-22T00:00:00.000Z", - pinOrderKey: "a0", - }); - if (options.shelf === "snoozed") - Object.assign(thread, { - snoozedUntil: "2099-01-01T00:00:00.000Z", - snoozedAt: "2026-10-22T00:00:00.000Z", - }); - if (options.shelf === "working") - Object.assign(thread, { - status: "running", - activeRunId: "triage-run-working", - latestRunId: "triage-run-working", - activityRunStatus: "running", - latestRunRequestedAt: "2026-10-21T23:00:00.000Z", - latestRunStartedAt: "2026-10-21T23:00:00.000Z", - }); - changed = true; - } - if (options.shelf === "settled" && thread.projectId === "triage-project-A") { - Object.assign(thread, { settledOverride: "settled", settledAt: thread.createdAt }); - changed = true; - } - if (options.status && ["triage-A-1", "triage-A-2"].includes(thread.id)) { - Object.assign(thread, { - status: thread.id === "triage-A-1" ? "failed" : "completed", - latestRunId: "triage-run-" + thread.id, - latestRunRequestedAt: "2026-10-21T23:00:00.000Z", - latestRunCompletedAt: "2026-10-21T23:01:00.000Z", - lastVisitedAt: null, - }); - changed = true; - } - } - } - Object.values(node).forEach((v) => { - if (!v || typeof v !== "object") return; - if (Array.isArray(v)) v.forEach(visit); - else visit(v); - }); - }; const deliver = (data) => { replaying = true; try { @@ -265,7 +200,35 @@ replaying = false; } }; - visit(value); + if (options.enabled) { + for (const chunk of Array.isArray(value) ? value : [value]) { + if (chunk?._tag !== "Chunk" || !Array.isArray(chunk.values)) continue; + for (const node of chunk.values) { + if (node?.kind !== "snapshot" || !Array.isArray(node.snapshot?.threads)) continue; + const snapshot = node.snapshot; + frames.push({ + kind: "shell.snapshot", + count: snapshot.threads.length, + sequence: snapshot.snapshotSequence, + }); + transformSnapshot(snapshot, (thread) => { + const update = { + ...chunk, + values: [ + { + kind: "thread.updated", + sequence: snapshot.snapshotSequence + 1, + location: "active", + thread, + }, + ], + }; + held.push(() => deliver(Array.isArray(value) ? [update] : update)); + }); + changed = true; + } + } + } if (changed) { event.stopImmediatePropagation(); deliver(value); From 6de1d633168f5ca1d10b6c97534101a42140a0a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:59:38 +0000 Subject: [PATCH 13/17] fix(fixtures): preserve callable Date in sidebar capture clock --- scripts/fixtures/issue-16534/bootstrap.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/scripts/fixtures/issue-16534/bootstrap.js b/scripts/fixtures/issue-16534/bootstrap.js index e5d8e61f666b6..f95bfbb016b66 100644 --- a/scripts/fixtures/issue-16534/bootstrap.js +++ b/scripts/fixtures/issue-16534/bootstrap.js @@ -1,15 +1,17 @@ (() => { const NativeDate = Date, epoch = NativeDate.parse("2026-10-22T00:00:00.000Z"); - class FixtureDate extends NativeDate { - constructor(...args) { - super(...(args.length ? args : [epoch])); - } - static now() { - return epoch; - } - } - globalThis.Date = FixtureDate; + globalThis.Date = new Proxy(NativeDate, { + apply() { + return new NativeDate(epoch).toString(); + }, + construct(target, args, newTarget) { + return Reflect.construct(target, args.length ? args : [epoch], newTarget); + }, + get(target, property, receiver) { + return property === "now" ? () => epoch : Reflect.get(target, property, receiver); + }, + }); globalThis.__sidebarCaptureClock = { epoch, readyState: document.readyState, From 18bb1287491054c4bf3089c9b27671b0e9112caa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 09:59:42 +0000 Subject: [PATCH 14/17] fix(tooling): analyze injected sidebar fixtures as Knip entries --- knip.jsonc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/knip.jsonc b/knip.jsonc index dbd148d7abcdc..babc22932ca0d 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -22,6 +22,8 @@ // Windows verify script with esbuild through a path knip does not trace, and // electron-builder loads the macOS signer by path. "entry": [ + // These fixture scripts are injected into a browser page by path. + "fixtures/issue-16534/*.js", "knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts", @@ -30,8 +32,7 @@ ], "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. - // Fixture scripts are injected into a browser page, never imported. - "ignore": ["npm-launcher/**", "fixtures/**/*.js"], + "ignore": ["npm-launcher/**"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. From cc2ac609ee308e128c9e3065b4ac95500d157d8c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:06:12 +0000 Subject: [PATCH 15/17] fix(tooling): preserve replay shell sequence continuity --- scripts/fixtures/issue-16534/replay.js | 104 +++++++++++++++++-------- 1 file changed, 70 insertions(+), 34 deletions(-) diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index 2439fb30e8ad5..b63e54da62a56 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -12,33 +12,30 @@ frames, held, release() { - if (delayedThread && shellSocket && shellRequestId !== null) { - shellSocket.dispatchEvent( - new MessageEvent("message", { - data: JSON.stringify({ - _tag: "Chunk", - requestId: shellRequestId, - values: [ - { - kind: "thread.updated", - sequence: shellSequence + 1, - location: "active", - thread: delayedThread, - }, - ], - }), - }), - ); + if (delayedThread && delayedStream) { + releaseThread(delayedStream, delayedThread); delayedThread = null; + delayedStream = null; } for (const run of held.splice(0)) run(); }, }; let delayedThread = null; - let shellSocket = null; - let shellRequestId = null; + let delayedStream = null; + let latestShellStream = null; let shellSequence = 0; + const releaseThread = (stream, thread, emit = stream.emit) => { + // Insert one fixture event into this subscription's sequence space; later + // server events must carry the same offset or the reducer drops the next one. + stream.offset++; + emit({ + kind: "thread.updated", + sequence: ++stream.sequence, + location: "active", + thread, + }); + }; const parseFrame = (data) => { try { const value = JSON.parse(data); @@ -76,7 +73,6 @@ }); } snapshot.projects = snapshot.projects?.filter((p) => p.id?.startsWith("triage-")) || []; - shellSequence = snapshot.snapshotSequence; for (const thread of snapshot.threads) { if (thread.id === "triage-A-1" && options.shelf) { Object.assign(thread, { @@ -135,7 +131,12 @@ count: value.threads.length, sequence: value.snapshotSequence, }); + shellSequence = value.snapshotSequence; transformSnapshot(value); + if (delayedThread && latestShellStream) { + delayedStream = latestShellStream; + delayedStream.sequence = Math.max(delayedStream.sequence, shellSequence); + } return new Response(JSON.stringify(value), { status: response.status, headers: { "content-type": "application/json" }, @@ -150,17 +151,44 @@ const { value } = parseFrame(data); for (const request of Array.isArray(value) ? value : [value]) { if (request?._tag === "Request" && request.tag === "orchestration.subscribeShell") { - shellSocket = this; - shellRequestId = request.id; + const stream = { + sequence: shellSequence, + offset: 0, + emit: (node) => + this.replayDeliver({ + _tag: "Chunk", + requestId: request.id, + values: [node], + }), + }; + this.shellStreams.set(request.id, stream); + latestShellStream = stream; + if (delayedThread && !delayedStream) delayedStream = stream; + } else if ( + request?._tag === "Request" && + request.tag === "orchestration.subscribeThread" + ) { + this.threadRequests.add(request.id); } } } catch { // Non-JSON socket traffic belongs to the native protocol handler. } } + shellStreams = new Map(); + threadRequests = new Set(); + replayDeliver; constructor(...args) { super(...args); let replaying = false; + this.replayDeliver = (data) => { + replaying = true; + try { + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(data) })); + } finally { + replaying = false; + } + }; this.addEventListener("message", (event) => { if (replaying) return; frames.push({ @@ -203,8 +231,26 @@ if (options.enabled) { for (const chunk of Array.isArray(value) ? value : [value]) { if (chunk?._tag !== "Chunk" || !Array.isArray(chunk.values)) continue; + if (this.threadRequests.has(chunk.requestId)) continue; + let stream = this.shellStreams.get(chunk.requestId); + if (!stream && chunk.values.some((node) => Array.isArray(node?.snapshot?.threads))) { + stream = { sequence: 0, offset: 0 }; + this.shellStreams.set(chunk.requestId, stream); + } + if (!stream) continue; + stream.emit = (node) => { + const update = { ...chunk, values: [node] }; + deliver(Array.isArray(value) ? [update] : update); + }; for (const node of chunk.values) { + if (typeof node?.sequence === "number") { + node.sequence += stream.offset; + stream.sequence = Math.max(stream.sequence, node.sequence); + changed ||= stream.offset !== 0; + } if (node?.kind !== "snapshot" || !Array.isArray(node.snapshot?.threads)) continue; + node.snapshot.snapshotSequence += stream.offset; + stream.sequence = Math.max(stream.sequence, node.snapshot.snapshotSequence); const snapshot = node.snapshot; frames.push({ kind: "shell.snapshot", @@ -212,18 +258,8 @@ sequence: snapshot.snapshotSequence, }); transformSnapshot(snapshot, (thread) => { - const update = { - ...chunk, - values: [ - { - kind: "thread.updated", - sequence: snapshot.snapshotSequence + 1, - location: "active", - thread, - }, - ], - }; - held.push(() => deliver(Array.isArray(value) ? [update] : update)); + const emit = stream.emit; + held.push(() => releaseThread(stream, thread, emit)); }); changed = true; } From 44215dadd198b563f2e2722afb955774ed5dbb9b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:13:07 +0000 Subject: [PATCH 16/17] fix(fixtures): guard settled replay expansion without source --- scripts/fixtures/issue-16534/replay.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index b63e54da62a56..2a4f3891cbb78 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -63,14 +63,15 @@ ); if (options.shelf === "settled") { const source = snapshot.threads.find((t) => t.id === "triage-A-8"); - for (let i = 13; i <= 26; i++) - snapshot.threads.push({ - ...source, - id: "triage-A-" + i, - title: "Triage A thread " + i, - latestMessageAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", - updatedAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", - }); + if (source) + for (let i = 13; i <= 26; i++) + snapshot.threads.push({ + ...source, + id: "triage-A-" + i, + title: "Triage A thread " + i, + latestMessageAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", + updatedAt: "2026-10-21T23:" + String(i).padStart(2, "0") + ":00.000Z", + }); } snapshot.projects = snapshot.projects?.filter((p) => p.id?.startsWith("triage-")) || []; for (const thread of snapshot.threads) { From a9dcdf37fd17842bb87a068fa4c94f535cbfc140 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 10:20:55 +0000 Subject: [PATCH 17/17] fix(fixtures): translate shell resume cursors after replay --- scripts/fixtures/issue-16534/replay.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/fixtures/issue-16534/replay.js b/scripts/fixtures/issue-16534/replay.js index 2a4f3891cbb78..8e1594482ec38 100644 --- a/scripts/fixtures/issue-16534/replay.js +++ b/scripts/fixtures/issue-16534/replay.js @@ -146,15 +146,24 @@ globalThis.WebSocket = class extends NativeWebSocket { send(data) { - super.send(data); - if (!options.enabled || typeof data !== "string") return; + if (!options.enabled || typeof data !== "string") return super.send(data); try { - const { value } = parseFrame(data); + const { value, ndjson } = parseFrame(data); + let changed = false; for (const request of Array.isArray(value) ? value : [value]) { if (request?._tag === "Request" && request.tag === "orchestration.subscribeShell") { + const afterSequence = request.payload?.afterSequence; + // Foreground resumes reuse this socket's client cursor. A new RPC + // session owns a new socket and reloads the authoritative HTTP shell. + const offset = + typeof afterSequence === "number" ? (this.latestShellStream?.offset ?? 0) : 0; + if (offset !== 0) { + request.payload.afterSequence -= offset; + changed = true; + } const stream = { - sequence: shellSequence, - offset: 0, + sequence: afterSequence ?? shellSequence, + offset, emit: (node) => this.replayDeliver({ _tag: "Chunk", @@ -163,6 +172,7 @@ }), }; this.shellStreams.set(request.id, stream); + this.latestShellStream = stream; latestShellStream = stream; if (delayedThread && !delayedStream) delayedStream = stream; } else if ( @@ -172,10 +182,16 @@ this.threadRequests.add(request.id); } } + if (changed) + data = ndjson + ? value.map((item) => JSON.stringify(item)).join("\n") + "\n" + : JSON.stringify(value); } catch { // Non-JSON socket traffic belongs to the native protocol handler. } + super.send(data); } + latestShellStream = null; shellStreams = new Map(); threadRequests = new Set(); replayDeliver;