Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
TestFlight / Build and upload (push) Successful in 1m55s
This commit is contained in:
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
|
||||
## UI
|
||||
|
||||
- Left panel: mixed list of chat conversations and Exa searches.
|
||||
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
|
||||
conversation messages, and web-search queries/answers. It waits 300 ms while
|
||||
typing and cancels superseded requests. Results include a single-line matching excerpt
|
||||
with matching words in bold and middle truncation
|
||||
(or “Title match” for title-only chat matches), with up to 100 newest matches.
|
||||
Clear the field or press Escape to return to recent items. Matching uses word
|
||||
prefixes, so `plan` also finds `planning`.
|
||||
- Right panel:
|
||||
- Chat mode: transcript + provider/model controls.
|
||||
- Search mode: top AI answer block + Google-style Exa results view.
|
||||
|
||||
+100
-32
@@ -26,6 +26,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
import {
|
||||
createChat,
|
||||
@@ -66,17 +67,18 @@ import {
|
||||
type WorkspaceItem,
|
||||
} from "@/lib/api";
|
||||
import { useSessionAuth } from "@/hooks/use-session-auth";
|
||||
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
|
||||
import {
|
||||
getChatModelSelection,
|
||||
getChatModelSelectionSyncKey,
|
||||
} from "@/lib/chat-model-selection";
|
||||
import {
|
||||
filterSidebarItemsWithForkGroups,
|
||||
getForkGroupPresentation,
|
||||
groupForkedChatItems,
|
||||
shouldRequestChatTitle,
|
||||
} from "@/lib/chat-forking";
|
||||
import {
|
||||
retainSelectedHistoryItem,
|
||||
resolveSidebarSelectionAfterRefresh,
|
||||
type SidebarSelection,
|
||||
} from "@/lib/sidebar-selection";
|
||||
@@ -96,6 +98,7 @@ import { cn } from "@/lib/utils";
|
||||
type DraftSelectionKind = "chat" | "search";
|
||||
type SidebarItem = SidebarSelection & {
|
||||
title: string;
|
||||
searchSnippet: string | null;
|
||||
parentChatId: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -843,6 +846,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
kind: "chat" as const,
|
||||
id: chat.id,
|
||||
title: getChatTitle(chat),
|
||||
searchSnippet: chat.searchSnippet ?? null,
|
||||
parentChatId: chat.parentChatId,
|
||||
updatedAt: chat.updatedAt,
|
||||
createdAt: chat.createdAt,
|
||||
@@ -860,6 +864,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
|
||||
kind: "search" as const,
|
||||
id: search.id,
|
||||
title: getSearchTitle(search),
|
||||
searchSnippet: search.searchSnippet ?? null,
|
||||
parentChatId: null,
|
||||
updatedAt: search.updatedAt,
|
||||
createdAt: search.createdAt,
|
||||
@@ -1052,6 +1057,10 @@ export default function App() {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [sidebarQuery, setSidebarQuery] = useState("");
|
||||
const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0);
|
||||
const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision);
|
||||
const isSidebarSearchActive = Boolean(sidebarQuery.trim());
|
||||
const selectedHistoryItemRef = useRef<WorkspaceItem | null>(null);
|
||||
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
|
||||
const hasSyncedSelectionHistoryRef = useRef(false);
|
||||
|
||||
@@ -1117,19 +1126,16 @@ export default function App() {
|
||||
}, [pendingAttachments]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
if (!query) return groupForkedChatItems(sidebarItems).flat();
|
||||
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => {
|
||||
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
|
||||
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
});
|
||||
}, [sidebarItems, sidebarQuery]);
|
||||
const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]);
|
||||
const knownSidebarItems = useMemo(() => {
|
||||
const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`));
|
||||
return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))];
|
||||
}, [sidebarItems, searchSidebarItems]);
|
||||
const sidebarSections = useMemo(
|
||||
() => buildSidebarSections(filteredSidebarItems, sidebarItems),
|
||||
[filteredSidebarItems, sidebarItems]
|
||||
() => isSidebarSearchActive
|
||||
? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : [])
|
||||
: buildSidebarSections(sidebarItems),
|
||||
[isSidebarSearchActive, searchSidebarItems, sidebarItems]
|
||||
);
|
||||
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
|
||||
|
||||
@@ -1148,6 +1154,8 @@ export default function App() {
|
||||
setChats([]);
|
||||
setSearches([]);
|
||||
setWorkspaceItems([]);
|
||||
setSidebarQuery("");
|
||||
selectedHistoryItemRef.current = null;
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
@@ -1199,6 +1207,10 @@ export default function App() {
|
||||
resetWorkspaceState();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error);
|
||||
}, [sidebarSearch.error]);
|
||||
|
||||
const refreshCollections = async ({
|
||||
initialSelection,
|
||||
reportTransientError = true,
|
||||
@@ -1211,12 +1223,14 @@ export default function App() {
|
||||
};
|
||||
setIsLoadingCollections(true);
|
||||
try {
|
||||
const nextWorkspaceItems = await listWorkspaceItems();
|
||||
let nextWorkspaceItems = await listWorkspaceItems();
|
||||
if (requestId !== collectionLoadRequestRef.current) return;
|
||||
nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current);
|
||||
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
|
||||
setWorkspaceItems(nextWorkspaceItems);
|
||||
setChats(nextChats);
|
||||
setSearches(nextSearches);
|
||||
setSidebarSearchRevision((current) => current + 1);
|
||||
|
||||
setSelectedItem((current) => {
|
||||
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
|
||||
@@ -1512,16 +1526,16 @@ export default function App() {
|
||||
const isItemRunning = (item: SidebarSelection) =>
|
||||
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
|
||||
const getChatFamilyItems = (chatId: string) => {
|
||||
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||
const chat = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||
if (!chat) return [];
|
||||
const rootId = chat.parentChatId ?? chat.id;
|
||||
return sidebarItems.filter(
|
||||
return knownSidebarItems.filter(
|
||||
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
|
||||
);
|
||||
};
|
||||
const isRootChat = (item: SidebarSelection) => {
|
||||
if (item.kind !== "chat") return false;
|
||||
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
|
||||
const chat = knownSidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
|
||||
return chat?.parentChatId === null;
|
||||
};
|
||||
const isDeleteBlocked = (item: SidebarSelection) => {
|
||||
@@ -1530,9 +1544,10 @@ export default function App() {
|
||||
};
|
||||
const isSidebarTargetStarred = (item: SidebarSelection) => {
|
||||
if (item.kind === "chat") {
|
||||
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
||||
const family = getChatFamilyItems(item.id);
|
||||
return (family.find((familyItem) => familyItem.parentChatId === null) ?? family[0])?.starred ?? false;
|
||||
}
|
||||
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
|
||||
return knownSidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
|
||||
};
|
||||
const isCurrentSelection = (item: SidebarSelection) => {
|
||||
const current = selectedItemRef.current;
|
||||
@@ -1928,10 +1943,21 @@ export default function App() {
|
||||
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
|
||||
const nextItem = orderedSidebarItems[nextIndex];
|
||||
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
|
||||
rememberHistoryResult(target);
|
||||
selectedItemRef.current = target;
|
||||
setSelectedItem(target);
|
||||
};
|
||||
|
||||
const rememberHistoryResult = (target: SidebarSelection) => {
|
||||
const item = sidebarSearch.items.find((item) => item.type === target.kind && item.id === target.id);
|
||||
if (!item) return;
|
||||
selectedHistoryItemRef.current = item;
|
||||
const { chats: matchedChats, searches: matchedSearches } = splitWorkspaceItems([item]);
|
||||
if (matchedChats.length) setChats((current) => [...matchedChats, ...current.filter((chat) => chat.id !== item.id)]);
|
||||
if (matchedSearches.length) setSearches((current) => [...matchedSearches, ...current.filter((search) => search.id !== item.id)]);
|
||||
setWorkspaceItems((current) => upsertWorkspaceItem(current, item, false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
@@ -1975,11 +2001,15 @@ export default function App() {
|
||||
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
|
||||
const summary = chats.find((chat) => chat.id === chatId);
|
||||
if (summary) return getChatTitle(summary);
|
||||
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||
const sidebarItem = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
|
||||
return sidebarItem?.title ?? "New chat";
|
||||
};
|
||||
|
||||
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
|
||||
setSidebarSearchRevision((current) => current + 1);
|
||||
if (selectedHistoryItemRef.current?.type === "chat" && selectedHistoryItemRef.current.id === updatedChat.id) {
|
||||
selectedHistoryItemRef.current = chatWorkspaceItem(updatedChat);
|
||||
}
|
||||
supersedeCollectionLoad();
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
|
||||
@@ -2012,6 +2042,10 @@ export default function App() {
|
||||
};
|
||||
|
||||
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
|
||||
setSidebarSearchRevision((current) => current + 1);
|
||||
if (selectedHistoryItemRef.current?.type === "search" && selectedHistoryItemRef.current.id === updatedSearch.id) {
|
||||
selectedHistoryItemRef.current = searchWorkspaceItem(updatedSearch);
|
||||
}
|
||||
supersedeCollectionLoad();
|
||||
setSearches((current) => {
|
||||
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
|
||||
@@ -2198,7 +2232,7 @@ export default function App() {
|
||||
const family = getChatFamilyItems(chatId);
|
||||
const root = family.find((item) => item.parentChatId === null) ?? family[0];
|
||||
if (!root) return;
|
||||
applyChatSummary(await updateChatStar(root.id, !root.starred), false);
|
||||
applyChatSummary(await updateChatStar(root.parentChatId ?? root.id, !root.starred), false);
|
||||
};
|
||||
|
||||
const handleToggleStar = async (target: SidebarSelection) => {
|
||||
@@ -2209,7 +2243,7 @@ export default function App() {
|
||||
if (target.kind === "chat") {
|
||||
await toggleChatFamilyStar(target.id);
|
||||
} else {
|
||||
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id);
|
||||
const current = knownSidebarItems.find((item) => item.kind === "search" && item.id === target.id);
|
||||
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
|
||||
applySearchSummary(updatedSearch, false);
|
||||
}
|
||||
@@ -2301,6 +2335,11 @@ export default function App() {
|
||||
} else {
|
||||
await deleteSearch(target.id);
|
||||
}
|
||||
const retained = selectedHistoryItemRef.current;
|
||||
if (retained && ((retained.type === target.kind && retained.id === target.id) ||
|
||||
(retained.type === "chat" && target.kind === "chat" && retained.parentChatId === target.id))) {
|
||||
selectedHistoryItemRef.current = null;
|
||||
}
|
||||
await refreshCollections({ selectFallback: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -3503,33 +3542,55 @@ export default function App() {
|
||||
<input
|
||||
value={sidebarQuery}
|
||||
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
|
||||
placeholder="Search chats"
|
||||
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
placeholder="Search chats and web searches"
|
||||
aria-label="Search conversation history and saved web searches"
|
||||
autoComplete="off"
|
||||
spellcheck={false}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setSidebarQuery("");
|
||||
}}
|
||||
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-9 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
|
||||
/>
|
||||
{sidebarQuery ? (
|
||||
<button type="button" onClick={() => setSidebarQuery("")} aria-label="Clear history search"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-violet-200/70 hover:text-violet-50">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="bg-violet-300/10" />
|
||||
<div className="flex-1 overflow-y-auto px-2 py-3">
|
||||
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||
{!isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
{!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
|
||||
{!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Start a chat or run your first search.
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
|
||||
{isSidebarSearchActive ? (
|
||||
<div aria-live="polite" role="status">
|
||||
{sidebarSearch.isLoading ? <p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground"><LoaderCircle className="h-4 w-4 animate-spin" />Searching history…</p> : null}
|
||||
{sidebarSearch.error ? (
|
||||
<div className="space-y-2 px-3 py-3 text-sm">
|
||||
<p className="text-red-300">{sidebarSearch.error}</p>
|
||||
<Button variant="secondary" onClick={() => setSidebarSearchRevision((current) => current + 1)}>Try again</Button>
|
||||
</div>
|
||||
) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? (
|
||||
<p className="px-3 py-3 text-sm text-muted-foreground">No matching chats or web searches. Try a title, query, or words from a conversation.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{sidebarSections.map((section) => (
|
||||
<div key={section.label} className="mb-4">
|
||||
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
|
||||
{section.items.map((item) => {
|
||||
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
|
||||
const isForkChild = item.kind === "chat" && item.parentChatId !== null;
|
||||
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
|
||||
const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null;
|
||||
const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
|
||||
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
|
||||
const itemIsStarred =
|
||||
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
|
||||
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred;
|
||||
const itemUpdatedAt = familyItems.reduce(
|
||||
(newest, familyItem) =>
|
||||
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
|
||||
@@ -3555,6 +3616,7 @@ export default function App() {
|
||||
draftKindRef.current = null;
|
||||
setDraftKind(null);
|
||||
const target = { kind: item.kind, id: item.id } as SidebarSelection;
|
||||
rememberHistoryResult(target);
|
||||
selectedItemRef.current = target;
|
||||
setSelectedItem(target);
|
||||
setIsMobileSidebarOpen(false);
|
||||
@@ -3573,7 +3635,7 @@ export default function App() {
|
||||
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
|
||||
)}
|
||||
>
|
||||
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
|
||||
{item.kind === "chat" ? (item.parentChatId ? <GitFork className="h-3.5 w-3.5" aria-label="Fork" /> : <MessageSquare className="h-3.5 w-3.5" />) : <Search className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span className="truncate text-sm font-semibold">{item.title}</span>
|
||||
@@ -3598,6 +3660,9 @@ export default function App() {
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSidebarSearchActive ? (
|
||||
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -3605,6 +3670,9 @@ export default function App() {
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{isSidebarSearchActive && sidebarSearch.hasMore ? (
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/// <reference lib="es2022.intl" />
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
|
||||
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
|
||||
const text = source.replace(/\s+/gu, " ").trim();
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const ellipsisRef = useRef<HTMLSpanElement>(null);
|
||||
const [kept, setKept] = useState<number | null>(null);
|
||||
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
|
||||
const runs = useMemo(() => {
|
||||
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
|
||||
const result: Array<{ start: number; end: number; match: boolean }> = [];
|
||||
let cursor = 0;
|
||||
for (const word of text.matchAll(words)) {
|
||||
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
|
||||
cursor = word.index + word[0].length;
|
||||
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
|
||||
}
|
||||
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
|
||||
return result;
|
||||
}, [text, query]);
|
||||
|
||||
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
|
||||
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
|
||||
if (run.end <= start || run.start >= end) return [];
|
||||
return [run.match
|
||||
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
|
||||
: <span key={`${start}:${run.start}`}>{value}</span>];
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
const ellipsis = ellipsisRef.current;
|
||||
if (!container || !measure || !ellipsis) return;
|
||||
let disposed = false;
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const width = container.getBoundingClientRect().width;
|
||||
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
|
||||
// Measure the styled DOM, including bold runs, instead of guessing a
|
||||
// character limit. Grapheme boundaries keep accents and emoji intact.
|
||||
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
|
||||
const range = document.createRange();
|
||||
const measureRange = (start: number, end: number) => {
|
||||
if (start >= end) return 0;
|
||||
let offset = 0;
|
||||
let started = false;
|
||||
for (const node of nodes) {
|
||||
const next = offset + node.length;
|
||||
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
|
||||
if (end <= next) { range.setEnd(node, end - offset); break; }
|
||||
offset = next;
|
||||
}
|
||||
return range.getBoundingClientRect().width;
|
||||
};
|
||||
const count = boundaries.length - 1;
|
||||
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
|
||||
let low = 0;
|
||||
let high = count - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
const prefix = boundaries[Math.ceil(middle / 2)];
|
||||
const suffix = boundaries[count - Math.floor(middle / 2)];
|
||||
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
setKept(low);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
void document.fonts.ready.then(update);
|
||||
document.fonts.addEventListener("loadingdone", update);
|
||||
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
|
||||
}, [text, runs, boundaries]);
|
||||
|
||||
const count = boundaries.length - 1;
|
||||
const truncated = kept !== null && kept < count;
|
||||
return (
|
||||
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
|
||||
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
|
||||
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
|
||||
|
||||
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
|
||||
const EMPTY_ITEMS: WorkspaceItem[] = [];
|
||||
|
||||
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
|
||||
const trimmed = query.trim();
|
||||
const [state, setState] = useState<SearchState | null>(null);
|
||||
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
|
||||
|
||||
useEffect(() => {
|
||||
setState(null);
|
||||
if (!enabled || !trimmed || validationError) return;
|
||||
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
|
||||
(result) => setState({ ...result, query: trimmed, revision, error: null }),
|
||||
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
|
||||
);
|
||||
}, [trimmed, enabled, revision, validationError]);
|
||||
|
||||
// Hide obsolete results immediately, even before effect cleanup runs.
|
||||
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
|
||||
return {
|
||||
items: current?.items ?? EMPTY_ITEMS,
|
||||
hasMore: current?.hasMore ?? false,
|
||||
error: validationError ?? current?.error ?? null,
|
||||
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
|
||||
};
|
||||
}
|
||||
+7
-1
@@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & {
|
||||
type: "search";
|
||||
};
|
||||
|
||||
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
|
||||
export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
|
||||
|
||||
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
@@ -272,6 +274,10 @@ export async function listWorkspaceItems() {
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
|
||||
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
|
||||
}
|
||||
|
||||
export async function verifySession() {
|
||||
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ export type SidebarSelection = { kind: "chat" | "search"; id: string };
|
||||
|
||||
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
|
||||
|
||||
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
|
||||
items: T[], selection: SidebarSelection | null, retained: T | null
|
||||
): T[] {
|
||||
if (retained && selection?.kind === retained.type && selection.id === retained.id &&
|
||||
!items.some((item) => item.type === retained.type && item.id === retained.id)) {
|
||||
return [...items, retained];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
type ResolveSidebarSelectionOptions = {
|
||||
initialSelection?: SidebarSelection;
|
||||
selectFallback?: boolean;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WorkspaceSearchResponse } from "./api";
|
||||
|
||||
// Cleanup cancels both the debounce and fetch, and also rejects late responses
|
||||
// from transports that finish after cancellation.
|
||||
export function scheduleWorkspaceSearch(
|
||||
query: string,
|
||||
request: (query: string, signal: AbortSignal) => Promise<WorkspaceSearchResponse>,
|
||||
onResult: (result: WorkspaceSearchResponse) => void,
|
||||
onError: (error: unknown) => void
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const result = await request(query, controller.signal);
|
||||
if (!controller.signal.aborted) onResult(result);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) onError(error);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { scheduleWorkspaceSearch } from "../src/lib/workspace-search.ts";
|
||||
import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
|
||||
|
||||
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||
|
||||
test("typing is debounced and superseded requests never start", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const queries = [];
|
||||
const results = [];
|
||||
const request = async (query) => { queries.push(query); return { items: [], hasMore: false }; };
|
||||
const reportError = (error) => assert.fail(String(error));
|
||||
const cancelFirst = scheduleWorkspaceSearch("ne", request, (result) => results.push(result), reportError);
|
||||
t.mock.timers.tick(299);
|
||||
assert.deepEqual(queries, []);
|
||||
cancelFirst();
|
||||
const cancelLast = scheduleWorkspaceSearch("nebula", request, (result) => results.push(result), reportError);
|
||||
t.mock.timers.tick(300);
|
||||
await flush();
|
||||
assert.deepEqual(queries, ["nebula"]);
|
||||
assert.deepEqual(results, [{ items: [], hasMore: false }]);
|
||||
cancelLast();
|
||||
});
|
||||
|
||||
test("cancellation aborts the transport and discards stale results and errors", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
let resolveFirst;
|
||||
let rejectSecond;
|
||||
let signal;
|
||||
const results = [];
|
||||
const errors = [];
|
||||
const first = scheduleWorkspaceSearch("first", (_query, value) => {
|
||||
signal = value;
|
||||
return new Promise((resolve) => { resolveFirst = resolve; });
|
||||
}, (result) => results.push(result), (error) => errors.push(error));
|
||||
t.mock.timers.tick(300);
|
||||
first();
|
||||
assert.equal(signal.aborted, true);
|
||||
const second = scheduleWorkspaceSearch("second", () => new Promise((_resolve, reject) => { rejectSecond = reject; }),
|
||||
(result) => results.push(result), (error) => errors.push(error));
|
||||
t.mock.timers.tick(300);
|
||||
second();
|
||||
resolveFirst({ items: [{ id: "stale" }], hasMore: true });
|
||||
rejectSecond(new Error("stale failure"));
|
||||
await flush();
|
||||
assert.deepEqual(results, []);
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test("search results retain excerpts and truncation info; failures reach the UI", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const expected = { items: [{ type: "chat", id: "old", searchSnippet: "A nebula in the sky" }], hasMore: true };
|
||||
let received;
|
||||
let error;
|
||||
const cancel = scheduleWorkspaceSearch("nebula", async () => expected, (result) => { received = result; }, assert.fail);
|
||||
t.mock.timers.tick(300);
|
||||
await flush();
|
||||
assert.deepEqual(received, expected);
|
||||
cancel();
|
||||
const cancelFailure = scheduleWorkspaceSearch("nebula", async () => { throw new Error("Offline"); }, assert.fail, (result) => { error = result; });
|
||||
t.mock.timers.tick(300);
|
||||
await flush();
|
||||
assert.equal(error.message, "Offline");
|
||||
cancelFailure();
|
||||
});
|
||||
|
||||
test("opening older chat/search results survives collection refresh without restoring deleted or unrelated items", () => {
|
||||
const recent = [{ type: "chat", id: "recent" }];
|
||||
for (const kind of ["chat", "search"]) {
|
||||
const selection = { kind, id: "old" };
|
||||
const retained = { type: kind, id: "old" };
|
||||
const refreshed = retainSelectedHistoryItem(recent, selection, retained);
|
||||
assert.deepEqual(resolveSidebarSelectionAfterRefresh(selection, refreshed), selection);
|
||||
assert.equal(retainSelectedHistoryItem(refreshed, selection, retained), refreshed);
|
||||
assert.equal(retainSelectedHistoryItem(recent, { kind, id: "different" }, retained), recent);
|
||||
assert.equal(retainSelectedHistoryItem(recent, selection, null), recent);
|
||||
assert.equal(retainSelectedHistoryItem(recent, null, retained), recent);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user