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
@@ -12,7 +12,9 @@ import {
CheckCircle2Icon,
ChevronDownIcon,
ClockIcon,
DownloadIcon,
FileTextIcon,
MessageSquareTextIcon,
PanelRightCloseIcon,
WrenchIcon,
XCircleIcon,
@@ -37,8 +39,11 @@ import { cn } from "@/lib/utils";
type ActivityContextValue = {
open: boolean;
mode: "activity" | "files";
selectedId: string | null;
sessionId: string | null;
openItem: (id: string) => void;
openFiles: (sessionId?: string | null) => void;
close: () => void;
};
@@ -46,20 +51,36 @@ const ActivityContext = createContext<ActivityContextValue | null>(null);
export function ActivityProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<"activity" | "files">("activity");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const openItem = useCallback((id: string) => {
setMode("activity");
setSelectedId(id);
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 value = useMemo(
() => ({
open,
mode,
selectedId,
sessionId,
openItem,
openFiles,
close,
}),
[open, selectedId, openItem, close],
[open, mode, selectedId, sessionId, openItem, openFiles, close],
);
return (
@@ -91,7 +112,8 @@ type ActivityItem = {
};
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 items = useMemo(() => collectActivityItems(messages), [messages]);
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
@@ -101,14 +123,18 @@ export function ActivityPanel() {
const prevCountRef = useRef(0);
useEffect(() => {
if (items.length > prevCountRef.current) {
if (mode === "activity" && items.length > prevCountRef.current) {
openItem(items.at(-1)?.id ?? items[0]?.id ?? "");
}
prevCountRef.current = items.length;
}, [items, openItem]);
}, [items, mode, openItem]);
if (!open) return null;
if (mode === "files") {
return <SessionFilesPanel sessionId={sessionId} onClose={close} />;
}
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">
<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) {
return activityId.split(":", 1)[0] ?? activityId;
}