From ccf9673a413e7ba784b0601e9a08675ee22e4dca Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 29 Sep 2026 22:40:36 +0800 Subject: [PATCH] feat(web): switch companion chats from the sidebar header The header lists recent chats and keeps the selected thread. Its title uses the first clause so the control stays compact. --- .../src/components/ai-sidebar/AiSidebar.tsx | 189 +++++++++++++++++- apps/web/src/lib/desktop-acp.ts | 1 + packages/shared/src/i18n/en-US.ts | 6 + packages/shared/src/i18n/ja.ts | 6 + packages/shared/src/i18n/zh-CN.ts | 6 + 5 files changed, 199 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 8596b6eb..1abdff9e 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -2,10 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, import { AnimatePresence } from "motion/react"; import * as m from "motion/react-m"; import { useTranslation } from "react-i18next"; -import { Loader2, PanelRightClose, Paperclip, Sparkles } from "lucide-react"; +import { Check, ChevronDown, Loader2, PanelRightClose, Paperclip, Plus, Search, Sparkles } from "lucide-react"; import type { CompanionAction, CompanionAnswer, CompanionEvent, CompanionTurn, CompanionTurnInput } from "@edgeever/shared"; import { buildRevisionDiffRows, createMemoLinkHref, parseMemoLinkHref } from "@edgeever/shared"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Attachment, @@ -41,6 +48,7 @@ import { AI_SIDEBAR_ADAPTER_PATH_KEY, AI_SIDEBAR_OPEN_KEY, AI_SIDEBAR_SOURCE_KEY, + AI_SIDEBAR_THREAD_KEY, AI_SIDEBAR_WIDTH_KEY, cancelDesktopAcp, desktopAcpAvailable, @@ -169,6 +177,141 @@ const readAiSidebarSource = (): "builtin" | "local" => ( readStorage(AI_SIDEBAR_SOURCE_KEY) === "local" && desktopAcpAvailable() ? "local" : "builtin" ); +const THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const THREAD_RECENCY_MS = 30 * 24 * 60 * 60 * 1000; + +const readAiSidebarThread = () => { + const value = readStorage(AI_SIDEBAR_THREAD_KEY); + return value && THREAD_ID_PATTERN.test(value) ? value : null; +}; + +const writeAiSidebarThread = (threadId: string) => { + writeStorage(AI_SIDEBAR_THREAD_KEY, threadId); +}; + +type SidebarThread = { id: string; title: string; updatedAt: string }; + +const sidebarThreadLabel = (title: string) => { + const clause = title.split(/[,。!?!?;;::\n]/)[0]?.trim() || title; + return clause.length >= 2 ? clause : title; +}; + +const sidebarThreadsFromTurns = (turns: CompanionTurn[]): SidebarThread[] => { + const byId = new Map(); + for (const turn of turns) { + const title = turn.message.replace(/\s+/g, " ").trim(); + const current = byId.get(turn.threadId); + if (!current) { + byId.set(turn.threadId, { id: turn.threadId, title, updatedAt: turn.createdAt, oldestAt: turn.createdAt }); + continue; + } + if (turn.createdAt > current.updatedAt) current.updatedAt = turn.createdAt; + if (turn.createdAt < current.oldestAt && title) { + current.oldestAt = turn.createdAt; + current.title = title; + } + } + return [...byId.values()] + .sort((left, right) => (left.updatedAt < right.updatedAt ? 1 : -1)) + .map(({ oldestAt: _oldestAt, ...thread }) => thread); +}; + +function AiSidebarThreadMenu({ + threads, + threadId, + title, + onSelect, + onCreate, +}: { + threads: SidebarThread[]; + threadId: string; + title: string; + onSelect: (threadId: string) => void; + onCreate: () => void; +}) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const normalized = query.trim().toLocaleLowerCase(); + const visible = threads.filter((thread) => !normalized || thread.title.toLocaleLowerCase().includes(normalized)); + const cutoff = Date.now() - THREAD_RECENCY_MS; + const recent = visible.filter((thread) => Date.parse(thread.updatedAt) >= cutoff); + const older = visible.filter((thread) => Date.parse(thread.updatedAt) < cutoff); + const searchOnRecent = recent.length > 0 || older.length === 0; + + const renderGroup = (label: string, items: SidebarThread[], withSearch: boolean) => { + if (!items.length) return null; + return ( +
+ {searching ? null : ( +
+ {label} + {withSearch ? ( + + ) : null} +
+ )} + {items.map((thread) => ( + onSelect(thread.id)} + > + {thread.title} + {thread.id === threadId ? + ))} +
+ ); + }; + + return ( + { if (!open) { setQuery(""); setSearching(false); } }}> + + + + + {searching ? ( + setQuery(event.target.value)} + onKeyDown={(event) => event.stopPropagation()} + /> + ) : null} + + + {threads.length ? : null} + {renderGroup(t("aiAssistant.sidebar.historyRecent"), recent, searchOnRecent && !searching)} + {renderGroup(t("aiAssistant.sidebar.historyOlder"), older, !searchOnRecent && !searching)} + {normalized && !visible.length ? ( +

{t("aiAssistant.sidebar.historyEmpty")}

+ ) : null} +
+
+ ); +} + const readLocalAdapter = (): { id: DesktopAcpAdapterId; path?: string } | null => { const id = readStorage(AI_SIDEBAR_ADAPTER_KEY); if (id !== "codex" && id !== "antigravity") return null; @@ -464,7 +607,7 @@ function AiSidebarSession({ const [turns, setTurns] = useState([]); const [actions, setActions] = useState([]); const [localTurns, setLocalTurns] = useState([]); - const [threadId, setThreadId] = useState(() => crypto.randomUUID()); + const [threadId, setThreadId] = useState(() => readAiSidebarThread() ?? crypto.randomUUID()); const [attachments, setAttachments] = useState([]); const [attachmentError, setAttachmentError] = useState(null); const [conflicts, setConflicts] = useState>({}); @@ -513,7 +656,13 @@ function AiSidebarSession({ if (!alive.current) return; setTurns(turnResult.turns); setActions(actionResult.actions); - setThreadId((current) => threadPinned.current ? current : (turnResult.turns[0]?.threadId ?? current)); + setThreadId((current) => { + if (threadPinned.current) return current; + const stored = readAiSidebarThread(); + const ids = new Set(turnResult.turns.map((turn) => turn.threadId)); + if (stored && ids.has(stored)) return stored; + return turnResult.turns[0]?.threadId ?? current; + }); }, []); useEffect(() => { @@ -785,6 +934,7 @@ function AiSidebarSession({ const uploaded: UploadedCompanionAttachment[] = []; threadPinned.current = true; const activeThreadId = threadId; + writeAiSidebarThread(activeThreadId); for (const item of snapshot) { if (controller.signal.aborted) throw new DOMException("aborted", "AbortError"); const result = await uploadCompanionAttachment({ @@ -987,6 +1137,14 @@ function AiSidebarSession({ }; const selectionCount = Array.from((selectionMarkdown ?? "").trim()).length; + const threads = useMemo(() => sidebarThreadsFromTurns(turns), [turns]); + const threadTitle = threads.find((thread) => thread.id === threadId)?.title || t("aiAssistant.sidebar.newThread"); + const rememberThread = (id: string) => { + threadPinned.current = true; + setThreadId(id); + setError(null); + writeAiSidebarThread(id); + }; const threadTurns = turns.filter((turn) => turn.threadId === threadId).slice().reverse(); const visibleCompanion = source === "builtin"; const visibleTurns = visibleCompanion ? threadTurns : localTurns; @@ -1016,12 +1174,25 @@ function AiSidebarSession({ return (
-