This commit is contained in:
hupenglong1
2026-05-20 15:04:19 +08:00
parent c4b935692c
commit 1a94cec822
27 changed files with 4831 additions and 1693 deletions
@@ -41,6 +41,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import {
ACTIVE_SESSION_CHANGED_EVENT,
readActiveSessionId,
@@ -140,7 +141,7 @@ type ActivityItem = {
type LiveRunEvent = NonNullable<ClawRunStatus["events"]>[number];
export function ActivityPanel() {
export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {}) {
const {
open,
mode,
@@ -221,20 +222,12 @@ export function ActivityPanel() {
prevLatestIdRef.current = latestId;
}, [items, mode, openActivity, selectedId]);
if (!open) return null;
if (!asDrawer && !open) return null;
if (mode === "files") {
return (
<SessionFilesPanel
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
/>
);
}
const isFilesMode = mode === "files";
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">
const activityBody = (
<>
<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>
@@ -244,16 +237,18 @@ export function ActivityPanel() {
: "暂无链路"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={close}
aria-label="关闭活动面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
{!asDrawer ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={close}
aria-label="关闭活动面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
) : null}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{visibleItems.length === 0 ? (
@@ -280,6 +275,52 @@ export function ActivityPanel() {
</div>
)}
</div>
</>
);
if (asDrawer) {
return (
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) close();
}}
>
<SheetContent
side="right"
className="flex w-[min(25rem,calc(100vw-1rem))] flex-col gap-0 p-0 sm:max-w-md"
>
<SheetTitle className="sr-only">
{isFilesMode ? "聊天中的文件" : "活动"}
</SheetTitle>
{isFilesMode ? (
<SessionFilesPanel
key={`drawer:${sessionId ?? "active"}:${filesRefreshToken}`}
sessionId={sessionId}
onClose={close}
embedded
/>
) : (
activityBody
)}
</SheetContent>
</Sheet>
);
}
if (isFilesMode) {
return (
<SessionFilesPanel
key={`${sessionId ?? "active"}:${filesRefreshToken}`}
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">
{activityBody}
</aside>
);
}
@@ -308,9 +349,11 @@ type SessionFilesPayload = {
function SessionFilesPanel({
sessionId,
onClose,
embedded = false,
}: {
sessionId: string | null;
onClose: () => void;
embedded?: boolean;
}) {
const [payload, setPayload] = useState<SessionFilesPayload | null>(null);
const [status, setStatus] = useState("正在读取聊天中的文件...");
@@ -377,8 +420,8 @@ function SessionFilesPanel({
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">
const body = (
<>
<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>
@@ -386,16 +429,18 @@ function SessionFilesPanel({
{status || `${total} 个文件`}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="关闭文件面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
{!embedded ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
onClick={onClose}
aria-label="关闭文件面板"
>
<PanelRightCloseIcon className="size-4" />
</Button>
) : null}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{!status && total === 0 ? (
@@ -406,6 +451,16 @@ function SessionFilesPanel({
<FileSection title="输入" files={inputFiles} />
<FileSection title="输出" files={outputFiles} />
</div>
</>
);
if (embedded) {
return <div className="flex min-h-0 flex-1 flex-col">{body}</div>;
}
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">
{body}
</aside>
);
}
@@ -425,17 +425,16 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
const ChatTopActions: FC = () => {
const { openFiles } = useActivityPanel();
const [workspaceOpen, setWorkspaceOpen] = useState(false);
const actionSessionId = useCurrentThreadSessionId({
includePending: true,
// 工作区切换必须绑定当前可见会话;空白新会话不能回退到上一个
// activeSessionId,否则会把 Jupyter 工作区切到旧会话上。
includeActive: false,
});
const displaySessionId = useCurrentThreadSessionId({
// "切换工作区" dialog 与 indicator 必须用同一个 sessionId,否则会出现
// "绑给 A、查的是 B" 的错位(agent 的 bash 路由也是用 active session id
// 错位时表面 indicator 显示已连接,实际 agent 跑在本地)。
// 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底,
// 此处不再需要刻意排除 activeSessionId。
const sessionId = useCurrentThreadSessionId({
includePending: true,
includeActive: true,
});
const workspaceStatus = useJupyterWorkspaceStatus(displaySessionId);
const workspaceStatus = useJupyterWorkspaceStatus(sessionId);
return (
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
@@ -462,7 +461,7 @@ const ChatTopActions: FC = () => {
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(displaySessionId);
openFiles(sessionId);
}}
>
<FileTextIcon className="size-4 text-muted-foreground" />
@@ -482,7 +481,7 @@ const ChatTopActions: FC = () => {
<WorkspaceSwitchDialog
open={workspaceOpen}
onOpenChange={setWorkspaceOpen}
sessionId={actionSessionId}
sessionId={sessionId}
/>
</div>
);
@@ -72,6 +72,7 @@ type ClawSessionSummary = {
preview?: string;
model?: string;
usage?: UsageSummary;
is_training?: boolean;
};
type UsageSummary = {
@@ -365,8 +366,15 @@ function SessionResultButton({
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 className="flex items-center gap-1.5">
<span className="truncate font-medium text-sm">
{loading ? "回放中..." : session.preview || session.session_id}
</span>
{session.is_training ? (
<span className="shrink-0 rounded bg-amber-100 px-1.5 text-[10px] text-amber-800">
</span>
) : null}
</span>
<span className="mt-0.5 text-muted-foreground text-xs">
{session.turns ?? 0} · {session.tool_calls ?? 0}
@@ -382,17 +390,23 @@ function useSidebarSessions(open: boolean) {
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));
})
.catch(() => {
if (!cancelled) setSessions([]);
});
const reload = () => {
fetch("/api/claw/sessions", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled || !Array.isArray(payload)) return;
setSessions(dedupeSidebarSessions(payload));
})
.catch(() => {
if (!cancelled) setSessions([]);
});
};
reload();
const handleUpdate = () => reload();
window.addEventListener("claw-session-updated", handleUpdate);
return () => {
cancelled = true;
window.removeEventListener("claw-session-updated", handleUpdate);
};
}, [open]);