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.
This commit is contained in:
1 parent
871a9325d5
commit
ccf9673a41
5 files changed
+199
-9
No files matched your search
@@ -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<string, SidebarThread & { oldestAt: string }>();
|
||||
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 (
|
||||
<div>
|
||||
{searching ? null : (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs text-slate-500">
|
||||
<span className="min-w-0 flex-1">{label}</span>
|
||||
{withSearch ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
|
||||
aria-label={t("aiAssistant.sidebar.historySearch")}
|
||||
onClick={() => setSearching(true)}
|
||||
>
|
||||
<Search className="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{items.map((thread) => (
|
||||
<DropdownMenuItem
|
||||
key={thread.id}
|
||||
className="justify-between gap-3 py-2 text-sm"
|
||||
onSelect={() => onSelect(thread.id)}
|
||||
>
|
||||
<span className="truncate">{thread.title}</span>
|
||||
{thread.id === threadId ? <Check className="size-4 shrink-0 text-slate-700" aria-hidden="true" /> : <span className="size-4 shrink-0" aria-hidden="true" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) { setQuery(""); setSearching(false); } }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 max-w-52 items-center gap-1 rounded-full px-2 text-left text-[13px] font-medium text-slate-700 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
|
||||
aria-label={`${title} · ${t("aiAssistant.sidebar.history")}`}
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 text-slate-500" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{sidebarThreadLabel(title)}</span>
|
||||
<ChevronDown className="size-3.5 shrink-0 text-slate-400" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-[60] max-h-80 w-72 overflow-y-auto p-1.5">
|
||||
{searching ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
placeholder={t("aiAssistant.sidebar.historySearch")}
|
||||
className="mb-1 h-8 w-full rounded-md border border-slate-200 bg-card px-2 text-sm text-slate-950 outline-none placeholder:text-slate-400"
|
||||
aria-label={t("aiAssistant.sidebar.historySearch")}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
/>
|
||||
) : null}
|
||||
<DropdownMenuItem className="gap-2 py-2 text-sm" onSelect={onCreate}>
|
||||
<Plus className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("aiAssistant.sidebar.newThread")}
|
||||
</DropdownMenuItem>
|
||||
{threads.length ? <DropdownMenuSeparator /> : null}
|
||||
{renderGroup(t("aiAssistant.sidebar.historyRecent"), recent, searchOnRecent && !searching)}
|
||||
{renderGroup(t("aiAssistant.sidebar.historyOlder"), older, !searchOnRecent && !searching)}
|
||||
{normalized && !visible.length ? (
|
||||
<p className="px-2 py-3 text-sm text-slate-500">{t("aiAssistant.sidebar.historyEmpty")}</p>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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<CompanionTurn[]>([]);
|
||||
const [actions, setActions] = useState<CompanionAction[]>([]);
|
||||
const [localTurns, setLocalTurns] = useState<LocalTurn[]>([]);
|
||||
const [threadId, setThreadId] = useState<string>(() => crypto.randomUUID());
|
||||
const [threadId, setThreadId] = useState<string>(() => readAiSidebarThread() ?? crypto.randomUUID());
|
||||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [conflicts, setConflicts] = useState<Record<string, string>>({});
|
||||
@@ -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 (
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-col bg-card">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-slate-200 px-2">
|
||||
<Sparkles className="ml-1 h-4 w-4 shrink-0 text-slate-500" aria-hidden="true" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-950">{t("aiAssistant.sidebar.title")}</h2>
|
||||
{source === "local" ? (
|
||||
<span className="shrink-0 text-xs text-slate-500">{t("aiAssistant.sidebar.localStatus")}</span>
|
||||
) : null}
|
||||
{source === "builtin" ? (
|
||||
<AiSidebarThreadMenu
|
||||
threads={threads}
|
||||
threadId={threadId}
|
||||
title={threadTitle}
|
||||
onSelect={rememberThread}
|
||||
onCreate={() => {
|
||||
if (threads.some((thread) => thread.id === threadId)) rememberThread(crypto.randomUUID());
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="ml-1 h-4 w-4 shrink-0 text-slate-500" aria-hidden="true" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-950">{t("aiAssistant.sidebar.title")}</h2>
|
||||
<span className="shrink-0 text-xs text-slate-500">{t("aiAssistant.sidebar.localStatus")}</span>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@@ -1038,7 +1209,7 @@ function AiSidebarSession({
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p role="alert" className="shrink-0 px-3 pt-2 text-sm text-rose-700">{error}</p> : null}
|
||||
<Conversation className="min-h-0 flex-1">
|
||||
<Conversation key={source === "builtin" ? threadId : "local"} className="min-h-0 flex-1">
|
||||
<ConversationContent className={sidebarThreadClassName}>
|
||||
{loading && visibleCompanion ? <p role="status" className="text-sm text-slate-500">{t("common.loading")}</p> : null}
|
||||
{!loading && !visibleTurns.length ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ export type DesktopAcpEvent =
|
||||
|
||||
export const AI_SIDEBAR_WIDTH_KEY = "edgeever.aiSidebar.width";
|
||||
export const AI_SIDEBAR_OPEN_KEY = "edgeever.aiSidebar.open";
|
||||
export const AI_SIDEBAR_THREAD_KEY = "edgeever.aiSidebar.thread";
|
||||
export const AI_SIDEBAR_SOURCE_KEY = "edgeever.aiSidebar.source";
|
||||
export const AI_SIDEBAR_ADAPTER_KEY = "edgeever.aiSidebar.adapterId";
|
||||
export const AI_SIDEBAR_ADAPTER_PATH_KEY = "edgeever.aiSidebar.adapterPath";
|
||||
|
||||
@@ -1292,6 +1292,12 @@ export const enUS = {
|
||||
sidebar: {
|
||||
title: "AI assistant",
|
||||
close: "Collapse assistant",
|
||||
history: "Chat history",
|
||||
newThread: "New chat",
|
||||
historyRecent: "Past 30 days",
|
||||
historyOlder: "Earlier",
|
||||
historySearch: "Search chats",
|
||||
historyEmpty: "No matching chats",
|
||||
resize: "Resize assistant",
|
||||
stop: "Stop",
|
||||
placeholder: "Ask about this note, or type / for a skill",
|
||||
|
||||
@@ -1292,6 +1292,12 @@ export const ja = {
|
||||
sidebar: {
|
||||
title: "AI アシスタント",
|
||||
close: "アシスタントをしまう",
|
||||
history: "チャット履歴",
|
||||
newThread: "新しいチャット",
|
||||
historyRecent: "過去 30 日",
|
||||
historyOlder: "それ以前",
|
||||
historySearch: "チャットを検索",
|
||||
historyEmpty: "一致するチャットがありません",
|
||||
resize: "幅を調整",
|
||||
stop: "停止",
|
||||
placeholder: "このノートについて聞くか、/ でスキルを入力",
|
||||
|
||||
@@ -1292,6 +1292,12 @@ export const zhCN = {
|
||||
sidebar: {
|
||||
title: "AI 助手",
|
||||
close: "收起助手",
|
||||
history: "会话记录",
|
||||
newThread: "新对话",
|
||||
historyRecent: "过去 30 天",
|
||||
historyOlder: "更早",
|
||||
historySearch: "搜索会话",
|
||||
historyEmpty: "没有匹配的会话",
|
||||
resize: "调整助手宽度",
|
||||
stop: "停止",
|
||||
placeholder: "问问这篇笔记,或输入 / 使用技能",
|
||||
|
||||
Reference in new issue
Block a user