From cb5a973ac7978101bc1dfcba97554ce35960eef2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 26 Jul 2026 17:44:45 -0700 Subject: [PATCH] Preserve provider changes before sending --- web/src/App.tsx | 43 ++++++++++++++-------- web/src/lib/chat-model-selection.ts | 24 ++++++++++++ web/tests/chat-model-selection.test.mjs | 49 +++++++++++++++++++++++++ web/tsconfig.tsbuildinfo | 2 +- 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 web/src/lib/chat-model-selection.ts create mode 100644 web/tests/chat-model-selection.test.mjs diff --git a/web/src/App.tsx b/web/src/App.tsx index f12b834..95e66e1 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -63,6 +63,10 @@ import { type WorkspaceItem, } from "@/lib/api"; import { useSessionAuth } from "@/hooks/use-session-auth"; +import { + getChatModelSelection, + getChatModelSelectionSyncKey, +} from "@/lib/chat-model-selection"; import { resolveSidebarSelectionAfterRefresh, type SidebarSelection, @@ -545,14 +549,6 @@ function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) { ); } -function getChatModelSelection(chat: Pick | Pick | null) { - if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; - return { - provider: chat.lastUsedProvider, - model: chat.lastUsedModel.trim(), - }; -} - type ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; @@ -1562,15 +1558,32 @@ export default function App() { return searches.find((search) => search.id === selectedItem.id) ?? null; }, [searches, selectedItem]); - useEffect(() => { - if (draftKind || selectedItem?.kind !== "chat") return; + const selectedChatModelSelection = useMemo(() => { + if (draftKind || selectedItem?.kind !== "chat") return null; const detailSelection = selectedChat?.id === selectedItem.id ? getChatModelSelection(selectedChat) : null; const summarySelection = getChatModelSelection(selectedChatSummary); - const nextSelection = detailSelection ?? summarySelection; - if (!nextSelection) return; - setProvider(nextSelection.provider); - setModel(nextSelection.model); - }, [draftKind, selectedChat, selectedChatSummary, selectedItem]); + return detailSelection ?? summarySelection; + }, [ + draftKind, + selectedChat?.id, + selectedChat?.lastUsedModel, + selectedChat?.lastUsedProvider, + selectedChatSummary?.id, + selectedChatSummary?.lastUsedModel, + selectedChatSummary?.lastUsedProvider, + selectedItem?.id, + selectedItem?.kind, + ]); + const selectedChatModelSelectionSyncKey = getChatModelSelectionSyncKey( + selectedItem?.kind === "chat" ? selectedItem.id : null, + selectedChatModelSelection + ); + + useEffect(() => { + if (!selectedChatModelSelection) return; + setProvider(selectedChatModelSelection.provider); + setModel(selectedChatModelSelection.model); + }, [selectedChatModelSelectionSyncKey]); useEffect(() => { if (draftKind === "chat") return; diff --git a/web/src/lib/chat-model-selection.ts b/web/src/lib/chat-model-selection.ts new file mode 100644 index 0000000..f1b6814 --- /dev/null +++ b/web/src/lib/chat-model-selection.ts @@ -0,0 +1,24 @@ +import type { Provider } from "./api"; + +type PersistedChatModel = { + lastUsedProvider: Provider | null; + lastUsedModel: string | null; +}; + +export type ChatModelSelection = { + provider: Provider; + model: string; +}; + +export function getChatModelSelection(chat: PersistedChatModel | null): ChatModelSelection | null { + if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null; + return { + provider: chat.lastUsedProvider, + model: chat.lastUsedModel.trim(), + }; +} + +export function getChatModelSelectionSyncKey(chatId: string | null, selection: ChatModelSelection | null) { + if (!chatId || !selection) return null; + return JSON.stringify([chatId, selection.provider, selection.model]); +} diff --git a/web/tests/chat-model-selection.test.mjs b/web/tests/chat-model-selection.test.mjs new file mode 100644 index 0000000..75698c2 --- /dev/null +++ b/web/tests/chat-model-selection.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + getChatModelSelection, + getChatModelSelectionSyncKey, +} from "../src/lib/chat-model-selection.ts"; + +test("chat model selections are normalized from persisted metadata", () => { + assert.deepEqual( + getChatModelSelection({ + lastUsedProvider: "anthropic", + lastUsedModel: " claude-sonnet-4-5 ", + }), + { + provider: "anthropic", + model: "claude-sonnet-4-5", + } + ); +}); + +test("unrelated chat updates do not change the model synchronization key", () => { + const beforeSettingsSave = getChatModelSelection({ + lastUsedProvider: "openai", + lastUsedModel: "gpt-4.1-mini", + }); + const afterSettingsSave = getChatModelSelection({ + lastUsedProvider: "openai", + lastUsedModel: "gpt-4.1-mini", + }); + + assert.equal( + getChatModelSelectionSyncKey("chat-1", beforeSettingsSave), + getChatModelSelectionSyncKey("chat-1", afterSettingsSave) + ); +}); + +test("switching chats or persisted models changes the synchronization key", () => { + const original = { provider: "openai", model: "gpt-4.1-mini" }; + const updated = { provider: "gemini", model: "gemini-3.5-flash" }; + + assert.notEqual( + getChatModelSelectionSyncKey("chat-1", original), + getChatModelSelectionSyncKey("chat-2", original) + ); + assert.notEqual( + getChatModelSelectionSyncKey("chat-1", original), + getChatModelSelectionSyncKey("chat-1", updated) + ); +}); diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo index e058a17..84f67cd 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/sidebar-selection.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/chat-model-selection.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