Compare commits
2 Commits
048456b8a5
...
cb5a973ac7
| Author | SHA1 | Date | |
|---|---|---|---|
| cb5a973ac7 | |||
| e2443162b0 |
@@ -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": {
|
||||
|
||||
@@ -63,9 +63,16 @@ 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,
|
||||
} 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 +101,7 @@ type ActiveRunsState = {
|
||||
searches: Record<string, true>;
|
||||
};
|
||||
type RefreshCollectionsOptions = {
|
||||
preferredSelection?: SidebarSelection;
|
||||
initialSelection?: SidebarSelection;
|
||||
reportTransientError?: boolean;
|
||||
selectFallback?: boolean;
|
||||
};
|
||||
@@ -542,14 +549,6 @@ function normalizeEnabledTools(value: unknown, availableTools: ChatToolInfo[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function getChatModelSelection(chat: Pick<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | null) {
|
||||
if (!chat?.lastUsedProvider || !chat.lastUsedModel?.trim()) return null;
|
||||
return {
|
||||
provider: chat.lastUsedProvider,
|
||||
model: chat.lastUsedModel.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
type ToolLogMetadata = {
|
||||
kind: "tool_call";
|
||||
toolCallId?: string;
|
||||
@@ -1131,7 +1130,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const refreshCollections = async ({
|
||||
preferredSelection,
|
||||
initialSelection,
|
||||
reportTransientError = true,
|
||||
selectFallback = false,
|
||||
}: RefreshCollectionsOptions = {}) => {
|
||||
@@ -1143,24 +1142,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 +1239,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(),
|
||||
@@ -1571,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;
|
||||
@@ -2480,7 +2484,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 +2698,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
await refreshCollections({ preferredSelection: target });
|
||||
await refreshCollections();
|
||||
return target;
|
||||
};
|
||||
|
||||
@@ -2813,7 +2817,7 @@ export default function App() {
|
||||
setSelectedChat(persistedChat);
|
||||
setSelectedSearch(null);
|
||||
}
|
||||
void refreshCollections({ preferredSelection: target });
|
||||
void refreshCollections();
|
||||
return;
|
||||
} catch (resumeError) {
|
||||
err = resumeError;
|
||||
@@ -2919,7 +2923,7 @@ export default function App() {
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
await refreshCollections({ preferredSelection: target });
|
||||
await refreshCollections();
|
||||
if (isCurrentSelection(target)) {
|
||||
await refreshSearch(searchId);
|
||||
}
|
||||
@@ -2987,7 +2991,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 +3153,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);
|
||||
|
||||
24
web/src/lib/chat-model-selection.ts
Normal file
24
web/src/lib/chat-model-selection.ts
Normal file
@@ -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]);
|
||||
}
|
||||
31
web/src/lib/sidebar-selection.ts
Normal file
31
web/src/lib/sidebar-selection.ts
Normal file
@@ -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;
|
||||
}
|
||||
49
web/tests/chat-model-selection.test.mjs
Normal file
49
web/tests/chat-model-selection.test.mjs
Normal file
@@ -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)
|
||||
);
|
||||
});
|
||||
45
web/tests/sidebar-selection.test.mjs
Normal file
45
web/tests/sidebar-selection.test.mjs
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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"}
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user