This commit is contained in:
hupenglong1
2026-05-22 19:48:47 +08:00
parent 20690cdef3
commit 5311e6d97c
31 changed files with 4620 additions and 472 deletions
@@ -212,6 +212,7 @@ export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {})
useEffect(() => {
const latestId = items.at(-1)?.id ?? null;
if (
!asDrawer &&
mode === "activity" &&
!selectedId &&
latestId &&
@@ -220,7 +221,7 @@ export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {})
openActivity();
}
prevLatestIdRef.current = latestId;
}, [items, mode, openActivity, selectedId]);
}, [asDrawer, items, mode, openActivity, selectedId]);
if (!asDrawer && !open) return null;
@@ -269,6 +270,7 @@ export function ActivityPanel({ asDrawer = false }: { asDrawer?: boolean } = {})
open={selectedId === item.id}
onOpenChange={(nextOpen) => {
if (nextOpen) openItem(item.id);
else if (selectedId === item.id) openActivity();
}}
/>
))}
@@ -6,10 +6,13 @@ import {
import { ThreadListPrimitive } from "@assistant-ui/react";
import type { UIMessage } from "ai";
import {
Loader2Icon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
ServerIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import {
type FC,
@@ -23,6 +26,7 @@ import {
clearActiveSessionId,
readActiveSessionId,
writeActiveSessionId,
writePendingWorkspaceSessionId,
} from "@/lib/claw-active-session";
import { useClawSessionReplay } from "@/lib/claw-session-replay";
import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url";
@@ -112,6 +116,7 @@ export const ThreadList: FC = () => {
return (
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex min-h-0 flex-1 flex-col gap-1">
<ThreadListNew />
<JupyterWorkspaceList />
<ClawSessionList />
</ThreadListPrimitive.Root>
);
@@ -139,6 +144,177 @@ const ThreadListNew: FC = () => {
);
};
type JupyterWorkspaceEntry = {
id: string;
label?: string;
base_url?: string;
workspace_root?: string;
created_at?: number;
last_used_at?: number;
};
function ensureSidebarWorkspaceSessionId(): {
sessionId: string;
created: boolean;
} {
const existing = readActiveSessionId();
if (existing) return { sessionId: existing, created: false };
const generated =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
writeActiveSessionId(generated);
return { sessionId: generated, created: true };
}
function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string {
if (entry.label && entry.label.trim()) return entry.label.trim();
if (entry.base_url) {
try {
return new URL(entry.base_url).host;
} catch {
return entry.base_url;
}
}
return entry.id;
}
const JupyterWorkspaceList: FC = () => {
const [entries, setEntries] = useState<JupyterWorkspaceEntry[]>([]);
const [loaded, setLoaded] = useState(false);
const [bindingId, setBindingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const refresh = () => {
fetch("/api/claw/jupyter/workspaces", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((payload) => {
if (cancelled) return;
if (Array.isArray(payload)) {
setEntries(payload as JupyterWorkspaceEntry[]);
} else {
setEntries([]);
}
setLoaded(true);
})
.catch(() => {
if (cancelled) return;
setEntries([]);
setLoaded(true);
});
};
refresh();
const onChanged = () => refresh();
window.addEventListener("claw-workspaces-changed", onChanged);
window.addEventListener("focus", refresh);
return () => {
cancelled = true;
window.removeEventListener("claw-workspaces-changed", onChanged);
window.removeEventListener("focus", refresh);
};
}, []);
if (!loaded || entries.length === 0) return null;
const bind = async (entry: JupyterWorkspaceEntry) => {
setError(null);
setBindingId(entry.id);
const { sessionId, created } = ensureSidebarWorkspaceSessionId();
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}/bind`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ session_id: sessionId }),
},
);
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as {
detail?: string;
error?: string;
};
setError(payload.detail ?? payload.error ?? "切换工作区失败");
return;
}
if (created) writePendingWorkspaceSessionId(sessionId);
window.dispatchEvent(new Event("claw-sessions-changed"));
window.dispatchEvent(new Event("claw-workspaces-changed"));
} catch (err) {
setError(
err instanceof Error ? err.message : "切换工作区时网络异常",
);
} finally {
setBindingId(null);
}
};
const remove = async (entry: JupyterWorkspaceEntry) => {
if (!window.confirm(`移除工作区「${workspaceDisplayLabel(entry)}」?`)) return;
try {
const response = await fetch(
`/api/claw/jupyter/workspaces/${encodeURIComponent(entry.id)}`,
{ method: "DELETE" },
);
if (!response.ok) return;
setEntries((current) => current.filter((e) => e.id !== entry.id));
} catch {
// best effort; next refresh will reconcile
}
};
return (
<div className="mt-1 flex flex-col gap-1 border-y border-dashed py-2">
<div className="flex items-center gap-1.5 px-2 text-muted-foreground text-xs uppercase tracking-wide">
<ServerIcon className="size-3" />
<span></span>
</div>
<div className="flex flex-col gap-0.5">
{entries.map((entry) => {
const label = workspaceDisplayLabel(entry);
const subtitle = entry.workspace_root ?? "";
const isBinding = bindingId === entry.id;
return (
<div
key={entry.id}
className="group relative flex h-9 items-center gap-1 rounded-lg transition-colors hover:bg-muted"
>
<button
type="button"
disabled={isBinding}
onClick={() => void bind(entry)}
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-lg px-3 text-left text-sm disabled:cursor-wait"
title={`${entry.base_url ?? ""}${subtitle ? ` · ${subtitle}` : ""}`}
>
{isBinding ? (
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
<button
type="button"
aria-label="移除工作区"
title="移除工作区"
onClick={() => void remove(entry)}
className="mr-1 hidden size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground group-hover:flex"
>
<XIcon className="size-3.5" />
</button>
</div>
);
})}
</div>
{error ? (
<div className="px-2 text-destructive text-xs">{error}</div>
) : null}
</div>
);
};
const ClawSessionList: FC = () => {
const { replaySession } = useClawSessionReplay();
const [sessions, setSessions] = useState<ClawSession[]>([]);
@@ -273,7 +273,25 @@ function useRefreshCurrentRun() {
const runStatus = await fetchLatestRunStatus(sessionId);
if (cancelled) return;
if (runStatus?.status === "queued" || runStatus?.status === "running") {
activeRunRef.current = runStatus.run_id ?? "active";
const runKey = runStatus.run_id ?? "active";
const previousRunKey = activeRunRef.current;
activeRunRef.current = runKey;
// Watcher auto-resume: backend started a new run via _maybe_auto_resume
// without a local SSE stream driving the UI. We need to replay once so
// the active-run placeholder (pending prompt + 思考中 bubble) appears.
// useRefreshReplayedRun will take over polling once replayedRun.active
// becomes true.
if (!runtimeRunning && previousRunKey !== runKey) {
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
cache: "no-store",
});
if (!response.ok || cancelled) return;
const payload = await response.json();
replaySession(
sessionId,
toReplayRepository(payload, sessionId, runStatus),
);
}
return;
}
if (!runtimeRunning && !activeRunRef.current) return;
@@ -632,6 +650,7 @@ function WorkspaceSwitchDialog({
if (needsFirstMessageSession) {
writePendingWorkspaceSessionId(sessionIdForRequest);
}
window.dispatchEvent(new Event("claw-workspaces-changed"));
setStatus(payload as JupyterWorkspaceStatus);
setProgress({ percent: 100, label: "切换完成" });
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
@@ -1186,6 +1205,7 @@ function ModelPickerButton({
};
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const normalizedQuery = query.trim().toLowerCase();
const filteredModels = useMemo(() => {
if (!normalizedQuery) return status.models;
@@ -1199,7 +1219,7 @@ function ModelPickerButton({
}, [normalizedQuery, status.models]);
return (
<PopoverPrimitive.Root>
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
@@ -1240,7 +1260,10 @@ function ModelPickerButton({
key={model.id}
type="button"
className="flex h-8 w-full items-center justify-between gap-2 rounded-lg px-2 text-left text-sm hover:bg-muted"
onClick={() => status.setModel(model.id)}
onClick={() => {
setOpen(false);
void status.setModel(model.id);
}}
>
<span className="min-w-0 flex-1 truncate">{model.id}</span>
<span className="shrink-0 text-muted-foreground text-xs">