Improve workspace sidebar and file panel
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user