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
@@ -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[]>([]);