Preserve thread selection during background updates
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "node --test --experimental-strip-types tests/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+20
-29
@@ -63,9 +63,12 @@ import {
|
|||||||
type WorkspaceItem,
|
type WorkspaceItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||||
|
import {
|
||||||
|
resolveSidebarSelectionAfterRefresh,
|
||||||
|
type SidebarSelection,
|
||||||
|
} from "@/lib/sidebar-selection";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type SidebarSelection = { kind: "chat" | "search"; id: string };
|
|
||||||
type DraftSelectionKind = "chat" | "search";
|
type DraftSelectionKind = "chat" | "search";
|
||||||
type SidebarItem = SidebarSelection & {
|
type SidebarItem = SidebarSelection & {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -94,7 +97,7 @@ type ActiveRunsState = {
|
|||||||
searches: Record<string, true>;
|
searches: Record<string, true>;
|
||||||
};
|
};
|
||||||
type RefreshCollectionsOptions = {
|
type RefreshCollectionsOptions = {
|
||||||
preferredSelection?: SidebarSelection;
|
initialSelection?: SidebarSelection;
|
||||||
reportTransientError?: boolean;
|
reportTransientError?: boolean;
|
||||||
selectFallback?: boolean;
|
selectFallback?: boolean;
|
||||||
};
|
};
|
||||||
@@ -1131,7 +1134,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const refreshCollections = async ({
|
const refreshCollections = async ({
|
||||||
preferredSelection,
|
initialSelection,
|
||||||
reportTransientError = true,
|
reportTransientError = true,
|
||||||
selectFallback = false,
|
selectFallback = false,
|
||||||
}: RefreshCollectionsOptions = {}) => {
|
}: RefreshCollectionsOptions = {}) => {
|
||||||
@@ -1143,24 +1146,12 @@ export default function App() {
|
|||||||
setChats(nextChats);
|
setChats(nextChats);
|
||||||
setSearches(nextSearches);
|
setSearches(nextSearches);
|
||||||
|
|
||||||
setSelectedItem((current) => {
|
setSelectedItem((current) =>
|
||||||
const hasItem = (candidate: SidebarSelection | null) => {
|
resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
||||||
if (!candidate) return false;
|
initialSelection,
|
||||||
return nextWorkspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
|
selectFallback,
|
||||||
};
|
})
|
||||||
|
);
|
||||||
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;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
if (message.includes("bearer token")) {
|
if (message.includes("bearer token")) {
|
||||||
@@ -1252,10 +1243,10 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
const preferredSelection = initialRouteSelectionRef.current;
|
const initialSelection = initialRouteSelectionRef.current;
|
||||||
initialRouteSelectionRef.current = null;
|
initialRouteSelectionRef.current = null;
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
refreshCollections({ preferredSelection: preferredSelection ?? undefined, selectFallback: true }),
|
refreshCollections({ initialSelection: initialSelection ?? undefined, selectFallback: true }),
|
||||||
refreshModels(),
|
refreshModels(),
|
||||||
refreshChatTools(),
|
refreshChatTools(),
|
||||||
refreshActiveRuns(),
|
refreshActiveRuns(),
|
||||||
@@ -2480,7 +2471,7 @@ export default function App() {
|
|||||||
|
|
||||||
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
backgroundSuspendedChatStreamsRef.current.delete(chatId);
|
||||||
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
const persistedChat = await retryAfterAppResume(() => getChat(chatId));
|
||||||
await refreshCollections({ preferredSelection: target, reportTransientError: false });
|
await refreshCollections({ reportTransientError: false });
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
setSelectedChat(persistedChat);
|
setSelectedChat(persistedChat);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
@@ -2694,7 +2685,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await refreshCollections({ preferredSelection: target });
|
await refreshCollections();
|
||||||
return target;
|
return target;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2813,7 +2804,7 @@ export default function App() {
|
|||||||
setSelectedChat(persistedChat);
|
setSelectedChat(persistedChat);
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
}
|
}
|
||||||
void refreshCollections({ preferredSelection: target });
|
void refreshCollections();
|
||||||
return;
|
return;
|
||||||
} catch (resumeError) {
|
} catch (resumeError) {
|
||||||
err = resumeError;
|
err = resumeError;
|
||||||
@@ -2919,7 +2910,7 @@ export default function App() {
|
|||||||
{ signal: abortController.signal }
|
{ signal: abortController.signal }
|
||||||
);
|
);
|
||||||
|
|
||||||
await refreshCollections({ preferredSelection: target });
|
await refreshCollections();
|
||||||
if (isCurrentSelection(target)) {
|
if (isCurrentSelection(target)) {
|
||||||
await refreshSearch(searchId);
|
await refreshSearch(searchId);
|
||||||
}
|
}
|
||||||
@@ -2987,7 +2978,7 @@ export default function App() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
await refreshCollections({ preferredSelection: { kind: "chat", id: chat.id } });
|
await refreshCollections();
|
||||||
await refreshChat(chat.id);
|
await refreshChat(chat.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
@@ -3149,7 +3140,7 @@ export default function App() {
|
|||||||
messages: [],
|
messages: [],
|
||||||
});
|
});
|
||||||
setSelectedSearch(null);
|
setSelectedSearch(null);
|
||||||
await refreshCollections({ preferredSelection: { kind: "chat", id: chat.id } });
|
await refreshCollections();
|
||||||
await refreshChat(chat.id);
|
await refreshChat(chat.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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/sidebar-selection.ts","./src/lib/utils.ts","./src/pages/search-route-page.tsx"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user