From e2443162b0d18aaec4d3ba0794a7c78252421b14 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 26 Jul 2026 17:37:39 -0700 Subject: [PATCH] Preserve thread selection during background updates --- web/package.json | 1 + web/src/App.tsx | 49 ++++++++++++---------------- web/src/lib/sidebar-selection.ts | 31 ++++++++++++++++++ web/tests/sidebar-selection.test.mjs | 45 +++++++++++++++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 5 files changed, 98 insertions(+), 30 deletions(-) create mode 100644 web/src/lib/sidebar-selection.ts create mode 100644 web/tests/sidebar-selection.test.mjs diff --git a/web/package.json b/web/package.json index a9b9aec..dafa25b 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "test": "node --test --experimental-strip-types tests/*.test.mjs", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/web/src/App.tsx b/web/src/App.tsx index 3de1cff..f12b834 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -63,9 +63,12 @@ import { type WorkspaceItem, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; +import { + resolveSidebarSelectionAfterRefresh, + type SidebarSelection, +} from "@/lib/sidebar-selection"; import { cn } from "@/lib/utils"; -type SidebarSelection = { kind: "chat" | "search"; id: string }; type DraftSelectionKind = "chat" | "search"; type SidebarItem = SidebarSelection & { title: string; @@ -94,7 +97,7 @@ type ActiveRunsState = { searches: Record; }; type RefreshCollectionsOptions = { - preferredSelection?: SidebarSelection; + initialSelection?: SidebarSelection; reportTransientError?: boolean; selectFallback?: boolean; }; @@ -1131,7 +1134,7 @@ export default function App() { }; const refreshCollections = async ({ - preferredSelection, + initialSelection, reportTransientError = true, selectFallback = false, }: RefreshCollectionsOptions = {}) => { @@ -1143,24 +1146,12 @@ export default function App() { setChats(nextChats); setSearches(nextSearches); - setSelectedItem((current) => { - const hasItem = (candidate: SidebarSelection | null) => { - if (!candidate) return false; - return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id); - }; - - if (preferredSelection && hasItem(preferredSelection)) { - return preferredSelection; - } - if (hasItem(current)) { - return current; - } - if (!selectFallback) { - return null; - } - const first = nextWorkspaceItems[0]; - return first ? { kind: first.type, id: first.id } : null; - }); + setSelectedItem((current) => + resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, { + initialSelection, + selectFallback, + }) + ); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { @@ -1252,10 +1243,10 @@ export default function App() { useEffect(() => { if (!isAuthenticated) return; - const preferredSelection = initialRouteSelectionRef.current; + const initialSelection = initialRouteSelectionRef.current; initialRouteSelectionRef.current = null; void Promise.all([ - refreshCollections({ preferredSelection: preferredSelection ?? undefined, selectFallback: true }), + refreshCollections({ initialSelection: initialSelection ?? undefined, selectFallback: true }), refreshModels(), refreshChatTools(), refreshActiveRuns(), @@ -2480,7 +2471,7 @@ export default function App() { backgroundSuspendedChatStreamsRef.current.delete(chatId); const persistedChat = await retryAfterAppResume(() => getChat(chatId)); - await refreshCollections({ preferredSelection: target, reportTransientError: false }); + await refreshCollections({ reportTransientError: false }); if (isCurrentSelection(target)) { setSelectedChat(persistedChat); setSelectedSearch(null); @@ -2694,7 +2685,7 @@ export default function App() { } } - await refreshCollections({ preferredSelection: target }); + await refreshCollections(); return target; }; @@ -2813,7 +2804,7 @@ export default function App() { setSelectedChat(persistedChat); setSelectedSearch(null); } - void refreshCollections({ preferredSelection: target }); + void refreshCollections(); return; } catch (resumeError) { err = resumeError; @@ -2919,7 +2910,7 @@ export default function App() { { signal: abortController.signal } ); - await refreshCollections({ preferredSelection: target }); + await refreshCollections(); if (isCurrentSelection(target)) { await refreshSearch(searchId); } @@ -2987,7 +2978,7 @@ export default function App() { messages: [], }); setSelectedSearch(null); - await refreshCollections({ preferredSelection: { kind: "chat", id: chat.id } }); + await refreshCollections(); await refreshChat(chat.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -3149,7 +3140,7 @@ export default function App() { messages: [], }); setSelectedSearch(null); - await refreshCollections({ preferredSelection: { kind: "chat", id: chat.id } }); + await refreshCollections(); await refreshChat(chat.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/web/src/lib/sidebar-selection.ts b/web/src/lib/sidebar-selection.ts new file mode 100644 index 0000000..a3aee2a --- /dev/null +++ b/web/src/lib/sidebar-selection.ts @@ -0,0 +1,31 @@ +export type SidebarSelection = { kind: "chat" | "search"; id: string }; + +type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string }; + +type ResolveSidebarSelectionOptions = { + initialSelection?: SidebarSelection; + selectFallback?: boolean; +}; + +export function resolveSidebarSelectionAfterRefresh( + current: SidebarSelection | null, + workspaceItems: WorkspaceSelectionItem[], + { initialSelection, selectFallback = false }: ResolveSidebarSelectionOptions = {} +): SidebarSelection | null { + const hasItem = (candidate: SidebarSelection | null | undefined) => { + if (!candidate) return false; + return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id); + }; + + if (hasItem(current)) { + return current; + } + if (hasItem(initialSelection)) { + return initialSelection ?? null; + } + if (!selectFallback) { + return null; + } + const first = workspaceItems[0]; + return first ? { kind: first.type, id: first.id } : null; +} diff --git a/web/tests/sidebar-selection.test.mjs b/web/tests/sidebar-selection.test.mjs new file mode 100644 index 0000000..e23b41a --- /dev/null +++ b/web/tests/sidebar-selection.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts"; + +const workspaceItems = [ + { type: "chat", id: "completed-chat" }, + { type: "chat", id: "selected-chat" }, + { type: "search", id: "selected-search" }, +]; + +test("a collection refresh preserves the current thread selection", () => { + assert.deepEqual( + resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems), + { kind: "chat", id: "selected-chat" } + ); +}); + +test("an initial route selection cannot override a current thread selection", () => { + assert.deepEqual( + resolveSidebarSelectionAfterRefresh( + { kind: "search", id: "selected-search" }, + workspaceItems, + { initialSelection: { kind: "chat", id: "completed-chat" }, selectFallback: true } + ), + { kind: "search", id: "selected-search" } + ); +}); + +test("a collection refresh preserves an intentionally empty selection", () => { + assert.equal(resolveSidebarSelectionAfterRefresh(null, workspaceItems), null); +}); + +test("initial load can select the URL thread or fall back to the first item", () => { + assert.deepEqual( + resolveSidebarSelectionAfterRefresh(null, workspaceItems, { + initialSelection: { kind: "search", id: "selected-search" }, + selectFallback: true, + }), + { kind: "search", id: "selected-search" } + ); + assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { selectFallback: true }), { + kind: "chat", + id: "completed-chat", + }); +}); diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index 2e8287a..e058a17 100644 --- a/web/tsconfig.tsbuildinfo +++ b/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/pwa.ts","./src/root-router.tsx","./src/vite-env.d.ts","./src/components/sybil-character.tsx","./src/components/auth/auth-screen.tsx","./src/components/chat/chat-attachment-list.tsx","./src/components/chat/chat-messages-panel.tsx","./src/components/markdown/markdown-content.tsx","./src/components/search/search-results-panel.tsx","./src/components/ui/button.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/components/ui/textarea.tsx","./src/hooks/use-session-auth.ts","./src/lib/api.ts","./src/lib/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"} \ No newline at end of file