Improve workspace sidebar and file panel

This commit is contained in:
武阳
2026-05-06 17:46:49 +08:00
parent d9a8110b7c
commit 93a0eb74d7
6 changed files with 702 additions and 83 deletions
+52 -3
View File
@@ -1,6 +1,11 @@
import { readFile, stat } from "node:fs/promises"; import { readdir, readFile, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { accountBaseRoot, getCurrentAccount } from "@/lib/claw-auth"; import {
accountBaseRoot,
accountSessionInputRoot,
accountSessionOutputRoot,
getCurrentAccount,
} from "@/lib/claw-auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
@@ -11,8 +16,15 @@ export async function GET(req: Request) {
const url = new URL(req.url); const url = new URL(req.url);
const requestedPath = url.searchParams.get("path"); const requestedPath = url.searchParams.get("path");
const sessionId = url.searchParams.get("session_id");
if (!requestedPath && sessionId) {
return Response.json({
input: await listSessionFiles(account.id, sessionId, "input"),
output: await listSessionFiles(account.id, sessionId, "output"),
});
}
if (!requestedPath) { if (!requestedPath) {
return Response.json({ error: "缺少文件路径" }, { status: 400 }); return Response.json({ error: "缺少文件路径或会话 ID" }, { status: 400 });
} }
const accountRoot = path.resolve(accountBaseRoot(account.id)); const accountRoot = path.resolve(accountBaseRoot(account.id));
@@ -38,6 +50,43 @@ export async function GET(req: Request) {
} }
} }
async function listSessionFiles(
accountId: string,
sessionId: string,
kind: "input" | "output",
) {
const accountRoot = path.resolve(accountBaseRoot(accountId));
const dir =
kind === "input"
? accountSessionInputRoot(accountId, sessionId)
: accountSessionOutputRoot(accountId, sessionId);
const resolvedDir = path.resolve(dir);
if (!resolvedDir.startsWith(`${accountRoot}${path.sep}`)) return [];
try {
const entries = await readdir(resolvedDir, { withFileTypes: true });
const files = await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const filePath = path.join(resolvedDir, entry.name);
const fileStat = await stat(filePath);
return {
name: entry.name,
path: filePath,
kind,
size: fileStat.size,
modified_at: fileStat.mtime.toISOString(),
download_url: `/api/claw/files?path=${encodeURIComponent(filePath)}`,
};
}),
);
return files.sort((a, b) => b.modified_at.localeCompare(a.modified_at));
} catch {
return [];
}
}
function contentTypeFor(filePath: string) { function contentTypeFor(filePath: string) {
const ext = path.extname(filePath).toLowerCase(); const ext = path.extname(filePath).toLowerCase();
if (ext === ".json") return "application/json; charset=utf-8"; if (ext === ".json") return "application/json; charset=utf-8";
+2 -21
View File
@@ -14,16 +14,9 @@ import {
} from "@/components/assistant-ui/activity-panel"; } from "@/components/assistant-ui/activity-panel";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar"; import { ThreadListSidebar } from "@/components/assistant-ui/threadlist-sidebar";
import { Separator } from "@/components/ui/separator"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
import { ClawAccountGate, useClawAccount } from "./claw-account-gate"; import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
import { ClawLlmSettings } from "./claw-llm-settings";
import { ClawThemeSwitcher } from "./claw-theme-switcher";
export const Assistant = () => { export const Assistant = () => {
const { account, isLoading, setAccount } = useClawAccount(); const { account, isLoading, setAccount } = useClawAccount();
@@ -83,24 +76,12 @@ export const Assistant = () => {
<ClawSessionReplayProvider value={{ replaySession }}> <ClawSessionReplayProvider value={{ replaySession }}>
<ActivityProvider> <ActivityProvider>
<SidebarProvider> <SidebarProvider>
<div className="flex h-dvh w-full pr-0.5"> <div className="flex h-dvh w-full">
<ThreadListSidebar <ThreadListSidebar
account={account} account={account}
onLogout={() => setAccount(null)} onLogout={() => setAccount(null)}
/> />
<SidebarInset> <SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger />
<Separator orientation="vertical" className="mr-2 h-4" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm"></div>
<div className="text-muted-foreground text-xs">
ZK Data Agent
</div>
</div>
<ClawThemeSwitcher />
<ClawLlmSettings />
</header>
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<Thread /> <Thread />
@@ -12,7 +12,9 @@ import {
CheckCircle2Icon, CheckCircle2Icon,
ChevronDownIcon, ChevronDownIcon,
ClockIcon, ClockIcon,
DownloadIcon,
FileTextIcon, FileTextIcon,
MessageSquareTextIcon,
PanelRightCloseIcon, PanelRightCloseIcon,
WrenchIcon, WrenchIcon,
XCircleIcon, XCircleIcon,
@@ -37,8 +39,11 @@ import { cn } from "@/lib/utils";
type ActivityContextValue = { type ActivityContextValue = {
open: boolean; open: boolean;
mode: "activity" | "files";
selectedId: string | null; selectedId: string | null;
sessionId: string | null;
openItem: (id: string) => void; openItem: (id: string) => void;
openFiles: (sessionId?: string | null) => void;
close: () => void; close: () => void;
}; };
@@ -46,20 +51,36 @@ const ActivityContext = createContext<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) { export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"activity" | "files">("activity");
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const openItem = useCallback((id: string) => { const openItem = useCallback((id: string) => {
setMode("activity");
setSelectedId(id); setSelectedId(id);
setOpen(true); setOpen(true);
}, []); }, []);
const openFiles = useCallback((nextSessionId?: string | null) => {
setMode("files");
setSessionId(
nextSessionId ??
(typeof window !== "undefined"
? window.localStorage.getItem("claw.activeSessionId")
: null),
);
setOpen(true);
}, []);
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
const value = useMemo( const value = useMemo(
() => ({ () => ({
open, open,
mode,
selectedId, selectedId,
sessionId,
openItem, openItem,
openFiles,
close, close,
}), }),
[open, selectedId, openItem, close], [open, mode, selectedId, sessionId, openItem, openFiles, close],
); );
return ( return (
@@ -91,7 +112,8 @@ type ActivityItem = {
}; };
export function ActivityPanel() { export function ActivityPanel() {
const { open, selectedId, openItem, close } = useActivityPanel(); const { open, mode, selectedId, sessionId, openItem, close } =
useActivityPanel();
const messages = useAuiState((s) => s.thread.messages); const messages = useAuiState((s) => s.thread.messages);
const items = useMemo(() => collectActivityItems(messages), [messages]); const items = useMemo(() => collectActivityItems(messages), [messages]);
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null; const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
@@ -101,14 +123,18 @@ export function ActivityPanel() {
const prevCountRef = useRef(0); const prevCountRef = useRef(0);
useEffect(() => { useEffect(() => {
if (items.length > prevCountRef.current) { if (mode === "activity" && items.length > prevCountRef.current) {
openItem(items.at(-1)?.id ?? items[0]?.id ?? ""); openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
} }
prevCountRef.current = items.length; prevCountRef.current = items.length;
}, [items, openItem]); }, [items, mode, openItem]);
if (!open) return null; if (!open) return null;
if (mode === "files") {
return <SessionFilesPanel sessionId={sessionId} onClose={close} />;
}
return ( return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none"> <aside className="fixed inset-y-0 right-0 z-30 flex w-[min(23rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-92 lg:shrink-0 lg:shadow-none">
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4"> <header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
@@ -155,6 +181,195 @@ export function ActivityPanel() {
); );
} }
type SessionFile = {
name: string;
path: string;
kind: "input" | "output";
size: number;
modified_at: string;
download_url: string;
};
type SessionFilesPayload = {
input?: SessionFile[];
output?: SessionFile[];
error?: string;
};
function SessionFilesPanel({
sessionId,
onClose,
}: {
sessionId: string | null;
onClose: () => void;
}) {
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
const [status, setStatus] = useState("正在读取聊天中的文件...");
useEffect(() => {
let cancelled = false;
async function loadFiles() {
const activeSessionId =
sessionId ?? window.localStorage.getItem("claw.activeSessionId");
if (!activeSessionId) {
setPayload({ input: [], output: [] });
setStatus("当前还没有可用会话。");
return;
}
setStatus("正在读取聊天中的文件...");
try {
const url = new URL("/api/claw/files", window.location.origin);
url.searchParams.set("session_id", activeSessionId);
const response = await fetch(url, { cache: "no-store" });
const nextPayload = (await response.json()) as SessionFilesPayload;
if (cancelled) return;
if (!response.ok) {
setPayload({ input: [], output: [], error: nextPayload.error });
setStatus(nextPayload.error ?? "读取文件失败");
return;
}
setPayload(nextPayload);
setStatus("");
} catch (err) {
if (!cancelled) {
setPayload({ input: [], output: [] });
setStatus(err instanceof Error ? err.message : "读取文件失败");
}
}
}
loadFiles();
return () => {
cancelled = true;
};
}, [sessionId]);
const inputFiles = payload?.input ?? [];
const outputFiles = payload?.output ?? [];
const total = inputFiles.length + outputFiles.length;
return (
<aside className="fixed inset-y-0 right-0 z-30 flex w-[min(25rem,calc(100vw-1rem))] flex-col border-l bg-background shadow-xl lg:static lg:z-auto lg:h-full lg:w-96 lg:shrink-0 lg:shadow-none">
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4">
<div className="min-w-0">
<h2 className="font-semibold text-base"></h2>
<p className="text-muted-foreground text-xs">
{status || `${total} 个文件`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="关闭文件面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!status && total === 0 ? (
<p className="text-muted-foreground text-sm">
</p>
) : null}
<FileSection title="输入" files={inputFiles} />
<FileSection title="输出" files={outputFiles} />
</div>
</aside>
);
}
function FileSection({
title,
files,
}: {
title: string;
files: SessionFile[];
}) {
return (
<section className="mb-5">
<div className="mb-2 flex items-center justify-between">
<h3 className="font-medium text-sm">{title}</h3>
<span className="text-muted-foreground text-xs">{files.length}</span>
</div>
{files.length ? (
<div className="flex flex-col gap-2">
{files.map((file) => (
<SessionFileRow key={`${file.kind}:${file.path}`} file={file} />
))}
</div>
) : (
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-muted-foreground text-xs">
{title}
</p>
)}
</section>
);
}
function SessionFileRow({ file }: { file: SessionFile }) {
return (
<div className="flex items-center gap-3 rounded-lg border px-3 py-2">
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<FileTextIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm" title={file.name}>
{file.name}
</div>
<div className="text-muted-foreground text-xs">
{fileExtension(file.name).toUpperCase() || "FILE"} ·{" "}
{formatBytes(file.size)}
</div>
</div>
<a
href={file.download_url}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`下载 ${file.name}`}
title="下载"
>
<DownloadIcon className="size-4" />
</a>
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={`针对 ${file.name} 提问`}
title="针对文件提问"
onClick={() => askAboutFile(file)}
>
<MessageSquareTextIcon className="size-4" />
</button>
</div>
);
}
function askAboutFile(file: SessionFile) {
window.dispatchEvent(
new CustomEvent("claw-composer-insert", {
detail: {
text: [
`请针对这个${file.kind === "input" ? "输入" : "输出"}文件回答我的问题:`,
`- 文件名: ${file.name}`,
`- 本地路径: ${file.path}`,
"请先使用 read_file 工具读取文件内容。",
].join("\n"),
},
}),
);
}
function fileExtension(fileName: string) {
const ext = fileName.split(".").at(-1);
return ext && ext !== fileName ? ext : "";
}
function formatBytes(bytes: number) {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
function activityMessageId(activityId: string) { function activityMessageId(activityId: string) {
return activityId.split(":", 1)[0] ?? activityId; return activityId.split(":", 1)[0] ?? activityId;
} }
@@ -331,7 +331,7 @@ function isClawSession(value: unknown): value is ClawSession {
); );
} }
function toReplayRepository( export function toReplayRepository(
session: ClawStoredSession, session: ClawStoredSession,
fallbackSessionId: string, fallbackSessionId: string,
): ExportedMessageRepository { ): ExportedMessageRepository {
@@ -22,6 +22,7 @@ import {
ChevronRightIcon, ChevronRightIcon,
CopyIcon, CopyIcon,
DownloadIcon, DownloadIcon,
FileTextIcon,
ListIcon, ListIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
PencilIcon, PencilIcon,
@@ -115,8 +116,9 @@ export const Thread: FC = () => {
<ThreadPrimitive.Viewport <ThreadPrimitive.Viewport
turnAnchor="top" turnAnchor="top"
data-slot="aui_thread-viewport" data-slot="aui_thread-viewport"
className="relative flex flex-1 flex-col overflow-x-auto overflow-y-scroll scroll-smooth" className="relative flex flex-1 flex-col overflow-x-auto overflow-y-auto scroll-smooth"
> >
<ChatTopActions />
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4"> <div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col px-4 pt-4">
<AuiIf condition={(s) => s.thread.isEmpty}> <AuiIf condition={(s) => s.thread.isEmpty}>
<ThreadWelcome /> <ThreadWelcome />
@@ -141,6 +143,52 @@ export const Thread: FC = () => {
); );
}; };
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
const latestSessionId = useAuiState((s) =>
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
);
return (
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
<PopoverPrimitive.Root>
<PopoverPrimitive.Trigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="pointer-events-auto size-8 rounded-full bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-muted hover:text-foreground"
aria-label="聊天选项"
>
<MoreHorizontalIcon className="size-4" />
</Button>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="bottom"
align="end"
sideOffset={8}
className="z-50 min-w-40 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={() => {
openFiles(
typeof latestSessionId === "string" ? latestSessionId : null,
);
}}
>
<FileTextIcon className="size-4 text-muted-foreground" />
</button>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
</div>
);
};
const ThreadMessage: FC = () => { const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role); const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing); const isEditing = useAuiState((s) => s.message.composer.isEditing);
@@ -168,7 +216,7 @@ const ThreadWelcome: FC = () => {
return ( return (
<div className="aui-thread-welcome-root my-auto flex grow flex-col"> <div className="aui-thread-welcome-root my-auto flex grow flex-col">
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center"> <div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4"> <div className="aui-thread-welcome-message flex size-full flex-col items-center justify-center px-4 text-center">
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200"> <h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
ZK Data Agent ZK Data Agent
</h1> </h1>
@@ -1,16 +1,35 @@
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { ThreadListPrimitive } from "@assistant-ui/react";
import { import {
BarChart3Icon, BarChart3Icon,
BotIcon, BotIcon,
ChevronDownIcon, ChevronDownIcon,
Clock3Icon,
LogOutIcon, LogOutIcon,
PanelLeftOpenIcon,
SearchIcon,
SquarePenIcon,
UserIcon, UserIcon,
} from "lucide-react"; } from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { ClawAccount } from "@/app/claw-account-gate"; import type { ClawAccount } from "@/app/claw-account-gate";
import { ThreadList } from "@/components/assistant-ui/thread-list"; import { ClawLlmSettings } from "@/app/claw-llm-settings";
import { ClawThemeSwitcher } from "@/app/claw-theme-switcher";
import {
ThreadList,
toReplayRepository,
} from "@/components/assistant-ui/thread-list";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -20,7 +39,10 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & { type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
account: ClawAccount; account: ClawAccount;
@@ -38,6 +60,7 @@ type ClawSessionSummary = {
turns: number; turns: number;
tool_calls: number; tool_calls: number;
modified_at: number; modified_at: number;
preview?: string;
model?: string; model?: string;
usage?: UsageSummary; usage?: UsageSummary;
}; };
@@ -50,57 +73,337 @@ type UsageSummary = {
type ClawStoredSession = { type ClawStoredSession = {
session_id: string; session_id: string;
messages?: unknown[];
usage?: UsageSummary; usage?: UsageSummary;
tool_calls?: number; tool_calls?: number;
turns?: number; turns?: number;
model?: string; model?: string;
}; };
type ReplaySessionPayload = Parameters<typeof toReplayRepository>[0];
type SidebarSession = ClawSessionSummary & {
preview: string;
};
export function ThreadListSidebar({ export function ThreadListSidebar({
account, account,
onLogout, onLogout,
...props ...props
}: ThreadListSidebarProps) { }: ThreadListSidebarProps) {
return ( return (
<Sidebar {...props}> <Sidebar collapsible="icon" {...props}>
<SidebarHeader className="aui-sidebar-header mb-2 border-b"> <div className="flex h-full flex-col group-data-[collapsible=icon]:hidden">
<div className="aui-sidebar-header-content flex items-center justify-between"> <SidebarHeader className="aui-sidebar-header mb-2 border-b">
<SidebarMenu> <div className="aui-sidebar-header-content flex items-center justify-between gap-2">
<SidebarMenuItem> <SidebarMenu>
<SidebarMenuButton size="lg"> <SidebarMenuItem>
<div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> <SidebarMenuButton size="lg">
<BotIcon className="aui-sidebar-header-icon size-4" /> <div className="aui-sidebar-header-icon-wrapper flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
</div> <BotIcon className="aui-sidebar-header-icon size-4" />
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none"> </div>
<span className="aui-sidebar-header-title font-semibold"> <div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
ZK Data Agent <span className="aui-sidebar-header-title font-semibold">
</span> ZK Data Agent
<span className="text-muted-foreground text-xs"> </span>
<span className="text-muted-foreground text-xs">
</span>
</div> </span>
</SidebarMenuButton> </div>
</SidebarMenuItem> </SidebarMenuButton>
</SidebarMenu> </SidebarMenuItem>
</div> </SidebarMenu>
</SidebarHeader> <SidebarTrigger className="size-8 shrink-0" />
<SidebarContent className="aui-sidebar-content px-2"> </div>
<ThreadList /> </SidebarHeader>
</SidebarContent> <SidebarContent className="aui-sidebar-content px-2">
<SidebarRail /> <ThreadList />
<SidebarFooter className="aui-sidebar-footer border-t"> </SidebarContent>
<AccountMenu account={account} onLogout={onLogout} /> <SidebarRail />
</SidebarFooter> <SidebarFooter className="aui-sidebar-footer border-t">
<AccountMenu account={account} onLogout={onLogout} />
</SidebarFooter>
</div>
<div className="hidden h-full group-data-[collapsible=icon]:flex">
<CollapsedSidebarRail account={account} onLogout={onLogout} />
</div>
</Sidebar> </Sidebar>
); );
} }
function CollapsedSidebarRail({
account,
onLogout,
}: {
account: ClawAccount;
onLogout: () => void;
}) {
const { setOpen } = useSidebar();
return (
<div className="flex h-full w-full flex-col items-center border-r bg-sidebar py-3 text-sidebar-foreground">
<button
type="button"
className="group mb-5 flex size-9 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
aria-label="展开侧栏"
title="展开侧栏"
onClick={() => setOpen(true)}
>
<BotIcon className="size-5 group-hover:hidden" />
<PanelLeftOpenIcon className="hidden size-5 group-hover:block" />
</button>
<div className="flex flex-col items-center gap-3">
<div
onClickCapture={() => {
window.localStorage.removeItem("claw.activeSessionId");
window.dispatchEvent(new Event("claw-active-session-cleared"));
}}
>
<ThreadListPrimitive.New asChild>
<CollapsedIconButton label="新任务">
<SquarePenIcon className="size-4" />
</CollapsedIconButton>
</ThreadListPrimitive.New>
</div>
<CollapsedSessionSearch />
<CollapsedRecentSessions />
</div>
<div className="mt-auto">
<AccountMenu account={account} onLogout={onLogout} compact />
</div>
</div>
);
}
function CollapsedIconButton({
label,
children,
onClick,
}: {
label: string;
children: React.ReactNode;
onClick?: () => void;
}) {
return (
<button
type="button"
className="flex size-8 items-center justify-center rounded-lg text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
aria-label={label}
title={label}
onClick={onClick}
>
{children}
</button>
);
}
function CollapsedSessionSearch() {
const { replaySession } = useClawSessionReplay();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
const filteredSessions = sessions.filter((session) => {
const keyword = query.trim().toLowerCase();
if (!keyword) return true;
return (
session.preview.toLowerCase().includes(keyword) ||
session.session_id.toLowerCase().includes(keyword)
);
});
return (
<>
<CollapsedIconButton label="搜索任务" onClick={() => setOpen(true)}>
<SearchIcon className="size-4" />
</CollapsedIconButton>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="top-[35%] max-w-lg translate-y-[-35%] gap-3 p-4">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
ID
</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={query}
placeholder="输入关键词..."
onChange={(event) => setQuery(event.target.value)}
/>
<div className="max-h-80 overflow-y-auto">
{filteredSessions.length ? (
<div className="flex flex-col gap-1">
{filteredSessions.map((session) => (
<SessionResultButton
key={session.session_id}
session={session}
loading={loadingSessionId === session.session_id}
onClick={async () => {
await openSession(session.session_id, replaySession);
setOpen(false);
}}
/>
))}
</div>
) : (
<p className="py-8 text-center text-muted-foreground text-sm">
</p>
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
function CollapsedRecentSessions() {
const { replaySession } = useClawSessionReplay();
const [open, setOpen] = useState(false);
const { sessions, loadingSessionId, openSession } = useSidebarSessions(open);
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<CollapsedIconButton label="最近任务">
<Clock3Icon className="size-4" />
</CollapsedIconButton>
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
side="right"
align="start"
sideOffset={10}
className="z-50 w-72 rounded-xl border bg-popover p-2 text-popover-foreground shadow-lg outline-none"
>
<div className="px-2 py-1.5 font-medium text-sm"></div>
{sessions.length ? (
<div className="flex max-h-96 flex-col gap-1 overflow-y-auto">
{sessions.map((session) => (
<SessionResultButton
key={session.session_id}
session={session}
loading={loadingSessionId === session.session_id}
onClick={async () => {
await openSession(session.session_id, replaySession);
setOpen(false);
}}
/>
))}
</div>
) : (
<p className="px-2 py-6 text-center text-muted-foreground text-sm">
</p>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}
function SessionResultButton({
session,
loading,
onClick,
}: {
session: SidebarSession;
loading: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
className="flex w-full flex-col rounded-lg px-2 py-2 text-left transition-colors hover:bg-muted"
onClick={onClick}
>
<span className="truncate font-medium text-sm">
{loading ? "回放中..." : session.preview || session.session_id}
</span>
<span className="mt-0.5 text-muted-foreground text-xs">
{session.turns ?? 0} · {session.tool_calls ?? 0}
</span>
</button>
);
}
function useSidebarSessions(open: boolean) {
const [sessions, setSessions] = useState<SidebarSession[]>([]);
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
fetch("/api/claw/sessions", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled || !Array.isArray(payload)) return;
setSessions(dedupeSidebarSessions(payload).slice(0, 12));
})
.catch(() => {
if (!cancelled) setSessions([]);
});
return () => {
cancelled = true;
};
}, [open]);
async function openSession(
sessionId: string,
replaySession: (
sessionId: string,
repository: ExportedMessageRepository,
) => void,
) {
window.localStorage.setItem("claw.activeSessionId", sessionId);
setLoadingSessionId(sessionId);
try {
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (!response.ok) return;
const payload = (await response.json()) as ReplaySessionPayload;
replaySession(sessionId, toReplayRepository(payload, sessionId));
} finally {
setLoadingSessionId(null);
}
}
return { sessions, loadingSessionId, openSession };
}
function dedupeSidebarSessions(payload: unknown[]) {
const byId = new Map<string, SidebarSession>();
for (const item of payload) {
if (!isSidebarSession(item)) continue;
const previous = byId.get(item.session_id);
if (!previous || item.modified_at >= previous.modified_at) {
byId.set(item.session_id, item);
}
}
return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at);
}
function isSidebarSession(value: unknown): value is SidebarSession {
if (!value || typeof value !== "object") return false;
const item = value as Partial<SidebarSession>;
return (
typeof item.session_id === "string" &&
typeof item.modified_at === "number" &&
typeof item.preview === "string"
);
}
function AccountMenu({ function AccountMenu({
account, account,
onLogout, onLogout,
compact = false,
}: { }: {
account: ClawAccount; account: ClawAccount;
onLogout: () => void; onLogout: () => void;
compact?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [state, setState] = useState<ClawState | null>(null); const [state, setState] = useState<ClawState | null>(null);
@@ -165,20 +468,33 @@ function AccountMenu({
return ( return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}> <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild> <PopoverPrimitive.Trigger asChild>
<button {compact ? (
type="button" <button
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent" type="button"
> className="flex size-8 items-center justify-center rounded-full bg-sidebar-primary text-sidebar-primary-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> aria-label="账号与统计"
<UserIcon className="size-4" /> title={account.username}
</div> >
<div className="min-w-0 flex-1"> <span className="font-medium text-xs">
<div className="truncate font-medium text-sm"> {account.username.slice(0, 2).toUpperCase()}
{account.username} </span>
</button>
) : (
<button
type="button"
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left transition-colors hover:bg-sidebar-accent"
>
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<UserIcon className="size-4" />
</div> </div>
<div className="text-muted-foreground text-xs"></div> <div className="min-w-0 flex-1">
</div> <div className="truncate font-medium text-sm">
</button> {account.username}
</div>
<div className="text-muted-foreground text-xs"></div>
</div>
</button>
)}
</PopoverPrimitive.Trigger> </PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
@@ -191,12 +507,21 @@ function AccountMenu({
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<UserIcon className="size-4" /> <UserIcon className="size-4" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{account.username}</div> <div className="truncate font-medium">{account.username}</div>
<div className="truncate text-muted-foreground text-xs"> <div className="truncate text-muted-foreground text-xs">
{state?.model ?? "模型配置读取中"} {state?.model ?? "模型配置读取中"}
</div> </div>
</div> </div>
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0 gap-1.5 px-2 text-muted-foreground"
onClick={logout}
>
<LogOutIcon className="size-3.5" />
退
</Button>
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<AccountStat label="会话" value={sessions.length} /> <AccountStat label="会话" value={sessions.length} />
@@ -270,14 +595,15 @@ function AccountMenu({
<div className="text-muted-foreground"></div> <div className="text-muted-foreground"></div>
) : null} ) : null}
</div> </div>
<Button <div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
variant="ghost" <span className="text-muted-foreground text-xs">
className="mt-3 w-full justify-start gap-2 text-muted-foreground"
onClick={logout} </span>
> <div className="flex items-center gap-1">
<LogOutIcon className="size-4" /> <ClawThemeSwitcher />
退 <ClawLlmSettings />
</Button> </div>
</div>
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
</PopoverPrimitive.Root> </PopoverPrimitive.Root>