import { bindExternalStoreMessage, type ExportedMessageRepository, type ThreadMessage, } from "@assistant-ui/core"; 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, type KeyboardEvent, useEffect, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { clearActiveSessionId, markFreshLocalId, readActiveSessionId, writeActiveSessionId, writePendingWorkspaceSessionId, } from "@/lib/claw-active-session"; import { fetchSessionReplaySnapshot, invalidateCachedSessionReplay, readCachedSessionReplay, type SessionReplaySnapshot, } from "@/lib/claw-session-cache"; import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { pushHomeUrl, pushSessionUrl } from "@/lib/claw-session-url"; type ClawSession = { session_id: string; turns: number; tool_calls: number; preview: string; modified_at: number; }; type ClawStoredMessage = { role?: string; content?: string; name?: string; tool_call_id?: string; tool_calls?: ClawStoredToolCall[]; state?: string; stop_reason?: string; metadata?: { elapsed_ms?: unknown; kind?: unknown; placeholder?: unknown; phase?: unknown; status?: unknown; }; }; type ClawStoredSession = { session_id: string; messages?: ClawStoredMessage[]; }; export type ClawRunStatus = { run_id?: string; session_id?: string; status?: | "idle" | "queued" | "running" | "completed" | "failed" | "cancelled" | "interrupted"; current_stage?: string; started_at?: number; updated_at?: number; finished_at?: number; elapsed_ms?: number; cancellable?: boolean; pending_prompt?: string; events?: ClawRunEvent[]; }; type ClawActiveRunStatus = ClawRunStatus & { status: "queued" | "running"; }; type ClawRunEvent = { type?: string; started_at?: unknown; recorded_at?: unknown; elapsed_ms?: unknown; tool_name?: string; tool_call_id?: string; arguments?: unknown; assistant_content?: string; delta?: string; ok?: boolean; metadata?: { output_preview?: unknown; }; }; type ClawStoredToolCall = { id?: string; name?: string; arguments?: unknown; function?: { name?: string; arguments?: unknown; }; }; type ReplaySessionFn = ( sessionId: string, repository: ExportedMessageRepository, ) => void; type ClawReplaySnapshot = SessionReplaySnapshot< ClawStoredSession, ClawRunStatus >; const SESSION_BADGE_POLL_INTERVAL_MS = 2000; const MAX_SESSION_BADGE_POLL_COUNT = 80; function replaySessionSnapshot( replaySession: ReplaySessionFn, sessionId: string, snapshot: ClawReplaySnapshot, ) { replaySession( sessionId, toReplayRepository(snapshot.session, sessionId, snapshot.runStatus), ); } export const ThreadList: FC = () => { return ( ); }; const ThreadListNew: FC = () => { const { clearSession } = useClawSessionReplay(); return ( ); }; 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)}`; markFreshLocalId(generated); writeActiveSessionId(generated); return { sessionId: generated, created: true }; } function workspaceDisplayLabel(entry: JupyterWorkspaceEntry): string { if (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([]); const [loaded, setLoaded] = useState(false); const [bindingId, setBindingId] = useState(null); const [error, setError] = useState(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 (
工作区
{entries.map((entry) => { const label = workspaceDisplayLabel(entry); const subtitle = entry.workspace_root ?? ""; const isBinding = bindingId === entry.id; return (
); })}
{error ? (
{error}
) : null}
); }; const ClawSessionList: FC = () => { const { replaySession } = useClawSessionReplay(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [loadingSessionId, setLoadingSessionId] = useState(null); const [deletingSessionId, setDeletingSessionId] = useState( null, ); const [renamingSessionId, setRenamingSessionId] = useState( null, ); const [draftTitle, setDraftTitle] = useState(""); const [openMenuSessionId, setOpenMenuSessionId] = useState( null, ); const renameInputRef = useRef(null); const openRequestRef = useRef(0); const openAbortRef = useRef(null); const sessionBadges = useSessionRunBadges(sessions, activeSessionId); useEffect(() => { setActiveSessionId(readActiveSessionId()); const refreshSessions = () => { fetch("/api/claw/sessions") .then((res) => (res.ok ? res.json() : [])) .then((payload) => { if (Array.isArray(payload)) setSessions(dedupeSessions(payload)); }) .catch(() => setSessions([])); }; const clearActiveSession = () => { setActiveSessionId(null); refreshSessions(); }; window.addEventListener("claw-active-session-cleared", clearActiveSession); window.addEventListener("claw-sessions-changed", refreshSessions); window.addEventListener("focus", refreshSessions); refreshSessions(); return () => { window.removeEventListener( "claw-active-session-cleared", clearActiveSession, ); window.removeEventListener("claw-sessions-changed", refreshSessions); window.removeEventListener("focus", refreshSessions); }; }, []); useEffect(() => { if (!renamingSessionId) return; window.requestAnimationFrame(() => { renameInputRef.current?.focus(); renameInputRef.current?.select(); }); }, [renamingSessionId]); if (!sessions.length) return null; const saveTitle = async (sessionId: string, fallbackTitle: string) => { const normalized = draftTitle.trim(); if (!normalized || normalized === fallbackTitle) { setRenamingSessionId(null); setDraftTitle(""); return; } const response = await fetch(`/api/claw/sessions/${sessionId}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: normalized }), }); if (!response.ok) return; const payload = (await response.json()) as { title?: string }; const nextTitle = payload.title || normalized; setSessions((current) => current.map((item) => item.session_id === sessionId ? { ...item, preview: nextTitle } : item, ), ); setRenamingSessionId(null); setDraftTitle(""); }; const openSession = async (sessionId: string) => { sessionBadges.markRead(sessionId); const requestId = openRequestRef.current + 1; openRequestRef.current = requestId; openAbortRef.current?.abort(); const abortController = new AbortController(); openAbortRef.current = abortController; const cached = readCachedSessionReplay( sessionId, ); writeActiveSessionId(sessionId); pushSessionUrl(sessionId); setActiveSessionId(sessionId); if (cached) { replaySessionSnapshot(replaySession, sessionId, cached); setLoadingSessionId(null); } else { setLoadingSessionId(sessionId); } try { const snapshot = await fetchSessionReplaySnapshot< ClawStoredSession, ClawRunStatus >(sessionId, { signal: abortController.signal }); if (requestId !== openRequestRef.current) return; if (readActiveSessionId() !== sessionId) return; if (!snapshot) return; if (!cached || snapshot.signature !== cached.signature) { replaySessionSnapshot(replaySession, sessionId, snapshot); } } catch (error) { if (!(error instanceof DOMException && error.name === "AbortError")) { console.warn("[session-list] failed to open session", error); } } finally { if (requestId === openRequestRef.current) { setLoadingSessionId(null); } if (openAbortRef.current === abortController) { openAbortRef.current = null; } } }; return (
{sessions.map((session) => { const active = activeSessionId === session.session_id; const loading = loadingSessionId === session.session_id; const badge = sessionBadges.bySessionId.get(session.session_id); return (
{renamingSessionId === session.session_id ? (
setDraftTitle(event.target.value)} onBlur={() => { saveTitle( session.session_id, session.preview || session.session_id, ); }} onKeyDown={(event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } if (event.key === "Escape") { event.preventDefault(); setRenamingSessionId(null); setDraftTitle(""); } }} />
) : ( )} {openMenuSessionId === session.session_id ? (
) : null}
); })}
); }; function useSessionRunBadges( sessions: ClawSession[], activeSessionId: string | null, ) { const [runningIds, setRunningIds] = useState>(() => new Set()); const [unreadIds, setUnreadIds] = useState>(() => new Set()); const previousRunningIdsRef = useRef>(new Set()); useEffect(() => { if (!activeSessionId) return; setUnreadIds((current) => { if (!current.has(activeSessionId)) return current; const next = new Set(current); next.delete(activeSessionId); return next; }); }, [activeSessionId]); useEffect(() => { const polledSessionIds = sessions .slice(0, MAX_SESSION_BADGE_POLL_COUNT) .map((session) => session.session_id); const knownSessionIds = new Set(polledSessionIds); if (polledSessionIds.length === 0) { setRunningIds(new Set()); setUnreadIds(new Set()); previousRunningIdsRef.current = new Set(); return; } let cancelled = false; async function refreshBadges() { const results = await Promise.allSettled( polledSessionIds.map(async (sessionId) => ({ sessionId, status: await fetchLatestRunStatus(sessionId), })), ); if (cancelled) return; const nextRunningIds = new Set(); const completedSessionIds: string[] = []; for (const result of results) { if (result.status !== "fulfilled") continue; const { sessionId, status } = result.value; if (isActiveRunStatus(status)) { nextRunningIds.add(sessionId); } else if (previousRunningIdsRef.current.has(sessionId)) { completedSessionIds.push(sessionId); } } setRunningIds(nextRunningIds); setUnreadIds((current) => { const next = new Set( [...current].filter((sessionId) => knownSessionIds.has(sessionId)), ); for (const sessionId of completedSessionIds) { if (sessionId !== activeSessionId) { next.add(sessionId); } } if (activeSessionId) next.delete(activeSessionId); return next; }); previousRunningIdsRef.current = nextRunningIds; } void refreshBadges(); const interval = window.setInterval( refreshBadges, SESSION_BADGE_POLL_INTERVAL_MS, ); window.addEventListener("claw-sessions-changed", refreshBadges); return () => { cancelled = true; window.clearInterval(interval); window.removeEventListener("claw-sessions-changed", refreshBadges); }; }, [sessions, activeSessionId]); const bySessionId = new Map(); for (const session of sessions) { bySessionId.set(session.session_id, { running: runningIds.has(session.session_id), unread: unreadIds.has(session.session_id), }); } const markRead = (sessionId: string) => { setUnreadIds((current) => { if (!current.has(sessionId)) return current; const next = new Set(current); next.delete(sessionId); return next; }); }; return { bySessionId, markRead }; } function SessionRunBadge({ running, unread, }: { running: boolean; unread: boolean; }) { if (running) { return ( ); } if (unread) { return ( ); } return