import { bindExternalStoreMessage, type ExportedMessageRepository, type ThreadMessage, } from "@assistant-ui/core"; import { ThreadListPrimitive } from "@assistant-ui/react"; import type { UIMessage } from "ai"; import { MoreHorizontalIcon, PencilIcon, PlusIcon, Trash2Icon, } from "lucide-react"; import { type FC, type KeyboardEvent, useEffect, useRef, useState, } from "react"; import { Button } from "@/components/ui/button"; import { useClawSessionReplay } from "@/lib/claw-session-replay"; 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[]; metadata?: { elapsed_ms?: unknown; }; }; type ClawStoredSession = { session_id: string; messages?: ClawStoredMessage[]; }; export type ClawRunStatus = { run_id?: string; session_id?: string; status?: "idle" | "queued" | "running" | "completed" | "failed" | "cancelled"; current_stage?: string; started_at?: number; }; type ClawStoredToolCall = { id?: string; name?: string; arguments?: unknown; function?: { name?: string; arguments?: unknown; }; }; export const ThreadList: FC = () => { return ( ); }; const ThreadListNew: FC = () => { return (
{ window.localStorage.removeItem("claw.activeSessionId"); window.dispatchEvent(new Event("claw-active-session-cleared")); }} >
); }; 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); useEffect(() => { setActiveSessionId(window.localStorage.getItem("claw.activeSessionId")); const refreshSessions = () => { fetch("/api/claw/sessions") .then((res) => (res.ok ? res.json() : [])) .then((payload) => { if (Array.isArray(payload)) setSessions(dedupeSessions(payload).slice(0, 8)); }) .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(""); }; return (
{sessions.map((session) => { const active = activeSessionId === session.session_id; const loading = loadingSessionId === 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 dedupeSessions(payload: unknown[]) { const byId = new Map(); for (const item of payload) { if (!isClawSession(item)) continue; const previous = byId.get(item.session_id); if (!previous || item.modified_at >= previous.modified_at) { byId.set(item.session_id, item); } } return [...byId.values()].sort((a, b) => b.modified_at - a.modified_at); } export async function fetchLatestRunStatus(sessionId: string) { try { const url = new URL("/api/claw/runs/latest", window.location.origin); url.searchParams.set("session_id", sessionId); const response = await fetch(url, { cache: "no-store" }); if (!response.ok) return null; return (await response.json()) as ClawRunStatus; } catch { return null; } } function isClawSession(value: unknown): value is ClawSession { if (!value || typeof value !== "object") return false; const item = value as Partial; return ( typeof item.session_id === "string" && typeof item.modified_at === "number" ); } export function toReplayRepository( session: ClawStoredSession, fallbackSessionId: string, runStatus?: ClawRunStatus | null, ): ExportedMessageRepository { const messages: ExportedMessageRepository["messages"] = []; const toolResults = collectToolResults(session.messages ?? []); const lastAssistantContentIndex = findLastAssistantContentIndex( session.messages ?? [], ); let previousId: string | null = null; for (const [index, message] of (session.messages ?? []).entries()) { if (message.role === "tool") continue; if (message.role !== "user" && message.role !== "assistant") continue; const content = cleanStoredContent(message.content ?? ""); if (!content) continue; if (content.trimStart().startsWith("")) continue; const id = `${fallbackSessionId}-${index}`; const toolParts = message.role === "assistant" ? toToolParts(message.tool_calls ?? [], toolResults, content) : []; const shouldShowAssistantText = message.role !== "assistant" || !toolParts.length || index === lastAssistantContentIndex; const parts = ( message.role === "assistant" && toolParts.length ? shouldShowAssistantText ? [...toolParts, { type: "text", text: content }] : toolParts : [{ type: "text", text: content }] ) as UIMessage["parts"]; const uiMessage: UIMessage = { id, role: message.role, parts, ...(message.role === "assistant" ? { metadata: { sessionId: session.session_id ?? fallbackSessionId } } : {}), } as UIMessage; const threadMessage = { id, role: message.role, createdAt: new Date(), content: toThreadMessageContent(parts), ...(message.role === "assistant" ? { status: { type: "complete", reason: "stop" }, metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: { sessionId: session.session_id ?? fallbackSessionId }, }, } : { metadata: { custom: {} } }), } as ThreadMessage; bindExternalStoreMessage(threadMessage, uiMessage); messages.push({ message: threadMessage, parentId: previousId }); previousId = id; } if (isActiveRunStatus(runStatus)) { const id = `${fallbackSessionId}-run-${runStatus.run_id ?? "active"}`; const text = runStatus.status === "queued" ? "当前会话已有任务在执行,本轮正在排队。" : runStatus.current_stage || "后端正在执行这个会话。"; const parts = [{ type: "reasoning", text }] as UIMessage["parts"]; const uiMessage: UIMessage = { id, role: "assistant", parts, metadata: { sessionId: session.session_id ?? fallbackSessionId, runId: runStatus.run_id, runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at), }, } as UIMessage; const threadMessage = { id, role: "assistant", createdAt: new Date(), content: toThreadMessageContent(parts), status: { type: "running" }, metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: { sessionId: session.session_id ?? fallbackSessionId, runId: runStatus.run_id, runStartedAtMs: normalizeRunTimestampMs(runStatus.started_at), }, }, } as ThreadMessage; bindExternalStoreMessage(threadMessage, uiMessage); messages.push({ message: threadMessage, parentId: previousId }); previousId = id; } return { headId: previousId, messages, }; } function isActiveRunStatus( runStatus?: ClawRunStatus | null, ): runStatus is ClawRunStatus { return runStatus?.status === "queued" || runStatus?.status === "running"; } function normalizeRunTimestampMs(value: unknown) { if (typeof value !== "number" || !Number.isFinite(value)) return undefined; return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value); } function findLastAssistantContentIndex(messages: readonly ClawStoredMessage[]) { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; if (message?.role !== "assistant") continue; const content = cleanStoredContent(message.content ?? ""); if (!content) continue; return index; } return -1; } function cleanStoredContent(content: string) { const markers = ["\n\n[当前会话目录]", "\n[当前会话目录]"]; for (const marker of markers) { if (content.includes(marker)) return content.split(marker, 1)[0].trim(); } return content.trim(); } function collectToolResults(messages: readonly ClawStoredMessage[]) { const results = new Map(); for (const message of messages) { if (message.role === "tool" && message.tool_call_id) { results.set(message.tool_call_id, message.content ?? ""); } } return results; } function toToolParts( toolCalls: readonly ClawStoredToolCall[], toolResults: Map, stageNote?: string, ) { return toolCalls.flatMap((call) => { const toolCallId = call.id; const toolName = call.function?.name ?? call.name; if (!toolCallId || !toolName) return []; const input = attachStageNote( parseToolInput(call.function?.arguments ?? call.arguments), stageNote, ); const output = toolResults.get(toolCallId); return [ { type: "dynamic-tool", toolName, toolCallId, state: output === undefined ? "input-available" : "output-available", input, ...(output === undefined ? {} : { output }), }, ]; }); } function attachStageNote(input: unknown, stageNote?: string) { const note = stageNote?.trim(); if (!note) return input; if (input && typeof input === "object" && !Array.isArray(input)) { return { ...input, __claw_stage_note: note }; } return { value: input, __claw_stage_note: note }; } function toThreadMessageContent(parts: UIMessage["parts"]) { return parts.map((part) => { if (part.type === "text" || part.type === "reasoning") return part; if (part.type === "dynamic-tool") { return { type: "tool-call", toolName: part.toolName, toolCallId: part.toolCallId, args: part.input ?? {}, argsText: JSON.stringify(part.input ?? {}), ...(part.state === "output-available" ? { result: part.output } : {}), }; } return part; }); } function parseToolInput(value: unknown) { if (typeof value !== "string") return value ?? {}; try { return JSON.parse(value); } catch { return value; } }