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:
+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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user