Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s

This commit is contained in:
2026-10-07 22:05:49 -07:00
parent 3f9c1409fe
commit dd6cd1ea7b
23 changed files with 1174 additions and 53 deletions
+100 -32
View File
@@ -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>