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
@@ -1,16 +1,35 @@
import type { ExportedMessageRepository } from "@assistant-ui/core";
import { ThreadListPrimitive } from "@assistant-ui/react";
import {
BarChart3Icon,
BotIcon,
ChevronDownIcon,
Clock3Icon,
LogOutIcon,
PanelLeftOpenIcon,
SearchIcon,
SquarePenIcon,
UserIcon,
} from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui";
import type * as React from "react";
import { useEffect, useState } from "react";
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Sidebar,
SidebarContent,
@@ -20,7 +39,10 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
type ThreadListSidebarProps = React.ComponentProps<typeof Sidebar> & {
account: ClawAccount;
@@ -38,6 +60,7 @@ type ClawSessionSummary = {
turns: number;
tool_calls: number;
modified_at: number;
preview?: string;
model?: string;
usage?: UsageSummary;
};
@@ -50,57 +73,337 @@ type UsageSummary = {
type ClawStoredSession = {
session_id: string;
messages?: unknown[];
usage?: UsageSummary;
tool_calls?: number;
turns?: number;
model?: string;
};
type ReplaySessionPayload = Parameters<typeof toReplayRepository>[0];
type SidebarSession = ClawSessionSummary & {
preview: string;
};
export function ThreadListSidebar({
account,
onLogout,
...props
}: ThreadListSidebarProps) {
return (
<Sidebar {...props}>
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
<div className="aui-sidebar-header-content flex items-center justify-between">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<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">
<BotIcon className="aui-sidebar-header-icon size-4" />
</div>
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
<span className="aui-sidebar-header-title font-semibold">
ZK Data Agent
</span>
<span className="text-muted-foreground text-xs">
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</div>
</SidebarHeader>
<SidebarContent className="aui-sidebar-content px-2">
<ThreadList />
</SidebarContent>
<SidebarRail />
<SidebarFooter className="aui-sidebar-footer border-t">
<AccountMenu account={account} onLogout={onLogout} />
</SidebarFooter>
<Sidebar collapsible="icon" {...props}>
<div className="flex h-full flex-col group-data-[collapsible=icon]:hidden">
<SidebarHeader className="aui-sidebar-header mb-2 border-b">
<div className="aui-sidebar-header-content flex items-center justify-between gap-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg">
<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">
<BotIcon className="aui-sidebar-header-icon size-4" />
</div>
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
<span className="aui-sidebar-header-title font-semibold">
ZK Data Agent
</span>
<span className="text-muted-foreground text-xs">
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarTrigger className="size-8 shrink-0" />
</div>
</SidebarHeader>
<SidebarContent className="aui-sidebar-content px-2">
<ThreadList />
</SidebarContent>
<SidebarRail />
<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>
);
}
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({
account,
onLogout,
compact = false,
}: {
account: ClawAccount;
onLogout: () => void;
compact?: boolean;
}) {
const [open, setOpen] = useState(false);
const [state, setState] = useState<ClawState | null>(null);
@@ -165,20 +468,33 @@ function AccountMenu({
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<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 className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{account.username}
{compact ? (
<button
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"
aria-label="账号与统计"
title={account.username}
>
<span className="font-medium text-xs">
{account.username.slice(0, 2).toUpperCase()}
</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 className="text-muted-foreground text-xs"></div>
</div>
</button>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">
{account.username}
</div>
<div className="text-muted-foreground text-xs"></div>
</div>
</button>
)}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
@@ -191,12 +507,21 @@ function AccountMenu({
<div className="flex size-9 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<UserIcon className="size-4" />
</div>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{account.username}</div>
<div className="truncate text-muted-foreground text-xs">
{state?.model ?? "模型配置读取中"}
</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 className="grid grid-cols-3 gap-2">
<AccountStat label="会话" value={sessions.length} />
@@ -270,14 +595,15 @@ function AccountMenu({
<div className="text-muted-foreground"></div>
) : null}
</div>
<Button
variant="ghost"
className="mt-3 w-full justify-start gap-2 text-muted-foreground"
onClick={logout}
>
<LogOutIcon className="size-4" />
退
</Button>
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<span className="text-muted-foreground text-xs">
</span>
<div className="flex items-center gap-1">
<ClawThemeSwitcher />
<ClawLlmSettings />
</div>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>