修改
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user