import { ActionBarMorePrimitive, ActionBarPrimitive, AuiIf, BranchPickerPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, SuggestionPrimitive, ThreadPrimitive, useAui, useAuiState, } from "@assistant-ui/react"; import { ArrowDownIcon, ArrowUpIcon, BookOpenIcon, BrainIcon, CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, DownloadIcon, FileTextIcon, ListIcon, MoreHorizontalIcon, PencilIcon, RefreshCwIcon, SearchIcon, SquareIcon, WrenchIcon, } from "lucide-react"; import { Popover as PopoverPrimitive } from "radix-ui"; import type { ComponentProps, Dispatch, FC, KeyboardEvent, ReactNode, SetStateAction, } from "react"; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import TextareaAutosize from "react-textarea-autosize"; import { useActivityPanel } from "@/components/assistant-ui/activity-panel"; import { ComposerAddAttachment, ComposerAttachments, UserMessageAttachments, } from "@/components/assistant-ui/attachment"; import { MarkdownText } from "@/components/assistant-ui/markdown-text"; import { Reasoning } from "@/components/assistant-ui/reasoning"; import { type ClawRunStatus, fetchLatestRunStatus, toReplayRepository, } from "@/components/assistant-ui/thread-list"; import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { ACTIVE_SESSION_CHANGED_EVENT, clearActiveSessionId, clearPendingWorkspaceSessionId, markFreshLocalId, PENDING_WORKSPACE_SESSION_CHANGED_EVENT, readActiveSessionId, readPendingWorkspaceSessionId, writeActiveSessionId, writePendingWorkspaceSessionId, } from "@/lib/claw-active-session"; import { fetchSessionReplaySnapshot } from "@/lib/claw-session-cache"; import { useClawSessionReplay } from "@/lib/claw-session-replay"; import { readSessionIdFromUrl } from "@/lib/claw-session-url"; import { dispatchSkillToggled } from "@/lib/use-training-mode"; import { cn } from "@/lib/utils"; type ComposerInsertEvent = CustomEvent<{ text: string }>; const INPUT_QUEUE_CHANGED_EVENT = "claw-input-queue-changed"; type SkillSummary = { name: string; description?: string; when_to_use?: string; enabled?: boolean; configurable?: boolean; }; type SkillSyncResponse = { skills?: SkillSummary[]; updated?: boolean; before?: string; after?: string; changed_files?: string[]; message?: string; error?: string; detail?: string; }; type SlashCommandSummary = { names: string[]; primary: string; description?: string; webui_supported?: boolean; }; type ClawState = { model?: string; active_session_id?: string | null; }; type ClawStoredSession = Parameters[0] & { model?: string; }; type SessionInputQueueItem = { id: number; session_id: string; run_id?: string; kind: "next_turn" | "guidance"; status: "pending" | "consumed" | "cancelled"; content: string; created_at?: number; updated_at?: number; consumed_at?: number | null; metadata?: Record; }; type ModelOption = { id: string; provider?: string; model_type?: string; }; type ContextBudget = { model?: string; context_window_tokens?: number; projected_input_tokens?: number; message_tokens?: number; chat_overhead_tokens?: number; reserved_output_tokens?: number; reserved_compaction_buffer_tokens?: number; reserved_schema_tokens?: number; hard_input_limit_tokens?: number; soft_input_limit_tokens?: number; overflow_tokens?: number; soft_overflow_tokens?: number; exceeds_hard_limit?: boolean; exceeds_soft_limit?: boolean; token_counter_backend?: string; token_counter_source?: string; token_counter_accurate?: boolean; }; function scheduleSessionListRefresh() { if (typeof window === "undefined") return; window.dispatchEvent(new Event("claw-sessions-changed")); for (const delay of [300, 1000, 2500]) { window.setTimeout(() => { window.dispatchEvent(new Event("claw-sessions-changed")); }, delay); } } export const Thread: FC = () => { useRefreshReplayedRun(); useRefreshCurrentRun(); return (
s.thread.isEmpty}>
{() => }
); }; function useRefreshReplayedRun() { const { replaySession } = useClawSessionReplay(); const replayedRun = useReplayedRunState(); useEffect(() => { if (!replayedRun.active || !replayedRun.sessionId) return; const activeSessionId = replayedRun.sessionId; let cancelled = false; let lastSignature: string | null = null; async function refreshIfFinished() { const snapshot = await fetchSessionReplaySnapshot< ClawStoredSession, ClawRunStatus >(activeSessionId); if (cancelled || !snapshot) return; if (snapshot.signature === lastSignature) return; lastSignature = snapshot.signature; replaySession( activeSessionId, toReplayRepository( snapshot.session, activeSessionId, snapshot.runStatus, ), ); if (!isActiveRunStatus(snapshot.runStatus)) { window.dispatchEvent(new Event("claw-sessions-changed")); } } refreshIfFinished(); const interval = window.setInterval(refreshIfFinished, 3000); return () => { cancelled = true; window.clearInterval(interval); }; }, [replayedRun.active, replayedRun.sessionId, replaySession]); } function useRefreshCurrentRun() { const { replaySession } = useClawSessionReplay(); const runtimeRunning = useAuiState((s) => s.thread.isRunning); const sessionId = useCurrentThreadSessionId({ includePending: true, includeActive: true, }); // Per-session bookkeeping: switching sessions (sidebar history) and coming // back must NOT re-trigger replaySession on a session that has already been // applied — replaySession rebuilds the thread tree, which causes a layout // shift / scroll jitter in the chat UI. Single-value refs would get // overwritten when visiting another session and forget that this session // was already settled. const activeRunRefMap = useRef>(new Map()); const appliedTerminalRefMap = useRef>(new Map()); useEffect(() => { if (!sessionId) return; let cancelled = false; async function refreshIfSettled() { if (!sessionId) return; const snapshot = await fetchSessionReplaySnapshot< ClawStoredSession, ClawRunStatus >(sessionId); const runStatus = snapshot?.runStatus ?? null; if (cancelled) return; if (runStatus?.status === "queued" || runStatus?.status === "running") { const runKey = runStatus.run_id ?? "active"; const previousRunKey = activeRunRefMap.current.get(sessionId) ?? null; activeRunRefMap.current.set(sessionId, 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) { if (!snapshot || cancelled) return; replaySession( sessionId, toReplayRepository(snapshot.session, sessionId, runStatus), ); } return; } const sessionActiveRun = activeRunRefMap.current.get(sessionId) ?? null; if (!runtimeRunning && !sessionActiveRun) return; const terminalKey = [ runStatus?.run_id ?? sessionActiveRun ?? "unknown", runStatus?.status ?? "idle", runStatus?.finished_at ?? "", runStatus?.elapsed_ms ?? "", ].join(":"); if (appliedTerminalRefMap.current.get(sessionId) === terminalKey) return; if (!snapshot || cancelled) return; replaySession( sessionId, toReplayRepository(snapshot.session, sessionId, runStatus), ); activeRunRefMap.current.delete(sessionId); appliedTerminalRefMap.current.set(sessionId, terminalKey); scheduleSessionListRefresh(); } refreshIfSettled(); const interval = window.setInterval(refreshIfSettled, 1500); return () => { cancelled = true; window.clearInterval(interval); }; }, [runtimeRunning, sessionId, replaySession]); } function useReplayedRunState() { const active = useAuiState((s) => s.thread.messages.some((message) => isReplayRunMessageId(message.id)), ); const replaySessionId = useAuiState((s) => { const replayRunMessage = s.thread.messages.find((message) => isReplayRunMessageId(message.id), ); return normalizeSessionId( readMetadataValue(replayRunMessage?.metadata, "sessionId"), ); }); const latestSessionId = useAuiState((s) => normalizeSessionId( findLatestMessageMetadataValue(s.thread.messages, "sessionId"), ), ); const runId = useAuiState((s) => { const replayRunMessage = s.thread.messages.find((message) => isReplayRunMessageId(message.id), ); return normalizeSessionId( readMetadataValue(replayRunMessage?.metadata, "runId"), ); }); const sessionId = replaySessionId ?? latestSessionId; return useMemo( () => ({ active, sessionId, runId }), [active, sessionId, runId], ); } function usePendingWorkspaceSessionId() { const [sessionId, setSessionId] = useState(() => typeof window !== "undefined" ? normalizeSessionId(readPendingWorkspaceSessionId()) : null, ); useEffect(() => { const refresh = () => { setSessionId(normalizeSessionId(readPendingWorkspaceSessionId())); }; window.addEventListener(PENDING_WORKSPACE_SESSION_CHANGED_EVENT, refresh); window.addEventListener("storage", refresh); window.addEventListener("focus", refresh); return () => { window.removeEventListener( PENDING_WORKSPACE_SESSION_CHANGED_EVENT, refresh, ); window.removeEventListener("storage", refresh); window.removeEventListener("focus", refresh); }; }, []); return sessionId; } function useActiveSessionId() { const [sessionId, setSessionId] = useState(() => typeof window !== "undefined" ? normalizeSessionId(readSessionIdFromUrl() ?? readActiveSessionId()) : null, ); useEffect(() => { const refresh = () => { setSessionId( normalizeSessionId(readSessionIdFromUrl() ?? readActiveSessionId()), ); }; window.addEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh); window.addEventListener("storage", refresh); window.addEventListener("focus", refresh); window.addEventListener("popstate", refresh); return () => { window.removeEventListener(ACTIVE_SESSION_CHANGED_EVENT, refresh); window.removeEventListener("storage", refresh); window.removeEventListener("focus", refresh); window.removeEventListener("popstate", refresh); }; }, []); return sessionId; } function useCurrentThreadSessionId({ includePending = false, includeActive = true, } = {}) { const currentRun = useReplayedRunState(); const pendingSessionId = usePendingWorkspaceSessionId(); const activeSessionId = useActiveSessionId(); useEffect(() => { if (currentRun.sessionId && currentRun.sessionId === pendingSessionId) { clearPendingWorkspaceSessionId(); } }, [currentRun.sessionId, pendingSessionId]); const replayRunSessionId = currentRun.active && (!activeSessionId || currentRun.sessionId === activeSessionId) ? currentRun.sessionId : null; return ( (includeActive ? activeSessionId : null) ?? (includePending ? pendingSessionId : null) ?? replayRunSessionId ?? currentRun.sessionId ); } async function cancelLatestRun(sessionId: string, runId?: string | null) { await fetch("/api/claw/runs/cancel", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sessionId, ...(runId ? { run_id: runId } : {}), }), }); } const ChatTopActions: FC = () => { const { openFiles } = useActivityPanel(); const [workspaceOpen, setWorkspaceOpen] = useState(false); // "切换工作区" dialog 与 indicator 必须用同一个 sessionId,否则会出现 // "绑给 A、查的是 B" 的错位(agent 的 bash 路由也是用 active session id, // 错位时表面 indicator 显示已连接,实际 agent 跑在本地)。 // 新建空会话场景由 thread-list.tsx 的 clearActiveSessionId() 兜底, // 此处不再需要刻意排除 activeSessionId。 const sessionId = useCurrentThreadSessionId({ includePending: true, includeActive: true, }); const workspaceStatus = useJupyterWorkspaceStatus(sessionId); return (
); }; type JupyterWorkspaceStatus = { connected?: boolean; session_id?: string; workspace_cwd?: string; jupyter_tree_url?: string; detail?: string; error?: string; }; type WorkspaceSwitchProgress = { percent: number; label: string; }; const WORKSPACE_SWITCH_STEPS = [ "登录 Jupyter", "初始化目录", "创建 Python 环境", "配置 pip 源", "同步 Skill", "链接工作区", ]; function useJupyterWorkspaceStatus(sessionId: string | null) { const [status, setStatus] = useState(null); useEffect(() => { if (!sessionId) { setStatus(null); return; } const sessionIdForRequest = sessionId; let cancelled = false; async function refresh() { try { const response = await fetch( `/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`, { cache: "no-store" }, ); const payload = (await response.json().catch(() => ({}))) as | JupyterWorkspaceStatus | Record; if (cancelled) return; if (!response.ok) { setStatus(null); return; } setStatus(payload as JupyterWorkspaceStatus); } catch { if (!cancelled) setStatus(null); } } void refresh(); const onRefresh = () => void refresh(); window.addEventListener("focus", onRefresh); window.addEventListener("claw-sessions-changed", onRefresh); const interval = window.setInterval(refresh, 8000); return () => { cancelled = true; window.removeEventListener("focus", onRefresh); window.removeEventListener("claw-sessions-changed", onRefresh); window.clearInterval(interval); }; }, [sessionId]); return status; } function WorkspaceSwitchDialog({ open, onOpenChange, sessionId, }: { open: boolean; onOpenChange: (open: boolean) => void; sessionId: string | null; }) { const [jupyterUrl, setJupyterUrl] = useState(""); const [password, setPassword] = useState(""); const [workspaceRoot, setWorkspaceRoot] = useState( "/root/zk_agent_workspaces", ); const [status, setStatus] = useState(null); const [submitting, setSubmitting] = useState(false); const [message, setMessage] = useState(""); const [progress, setProgress] = useState( null, ); const fieldId = useId(); const effectiveSessionId = sessionId; useEffect(() => { if (!open || !effectiveSessionId) return; const sessionIdForRequest = effectiveSessionId; let cancelled = false; async function refreshStatus() { const response = await fetch( `/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`, { cache: "no-store" }, ); const payload = (await response.json().catch(() => ({}))) as | JupyterWorkspaceStatus | Record; if (!cancelled) setStatus(payload as JupyterWorkspaceStatus); } void refreshStatus().catch(() => undefined); return () => { cancelled = true; }; }, [open, effectiveSessionId]); async function submit() { setMessage(""); const sessionIdForRequest = ensureWorkspaceSessionId(effectiveSessionId); if (!jupyterUrl.trim() || !password) { setMessage("请填写 Jupyter 地址和密码。"); return; } setSubmitting(true); const progressTimer = startWorkspaceProgress(setProgress); try { const needsFirstMessageSession = !normalizeSessionId(sessionId); const response = await fetch("/api/claw/jupyter", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sessionIdForRequest, base_url: jupyterUrl.trim(), password, workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces", }), }); const payload = (await response.json().catch(() => ({}))) as | JupyterWorkspaceStatus | { detail?: string; error?: string }; if (!response.ok) { setMessage(payload.detail ?? payload.error ?? "切换工作区失败"); return; } writeActiveSessionId(sessionIdForRequest); if (needsFirstMessageSession) { writePendingWorkspaceSessionId(sessionIdForRequest); } window.dispatchEvent(new Event("claw-workspaces-changed")); setStatus(payload as JupyterWorkspaceStatus); setProgress({ percent: 100, label: "切换完成" }); setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。"); setPassword(""); } finally { window.clearInterval(progressTimer); setSubmitting(false); window.setTimeout(() => setProgress(null), 1200); } } return ( {status?.connected ? "Jupyter 工作区" : "切换工作区"} {status?.connected ? "当前 session 已固定到远端 Jupyter 工作区。" : "把当前 session 的工具执行切换到远端 Jupyter 开发机。"}
{status?.connected ? (
当前已连接
工作目录:{status.workspace_cwd}
{status.jupyter_tree_url ? ( 打开 Jupyter 工作区 ) : null}
) : null} {!status?.connected ? ( <> ) : null} {progress ? (
{progress.label} {Math.round(progress.percent)}%
) : null} {message ? (
{message}
) : null}
{!status?.connected ? ( ) : null}
); } function ensureWorkspaceSessionId(current: string | null) { if (current) return current; 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); scheduleSessionListRefresh(); return generated; } function startWorkspaceProgress( setProgress: Dispatch>, ) { setProgress({ percent: 6, label: WORKSPACE_SWITCH_STEPS[0] }); return window.setInterval(() => { setProgress((current) => { const previous = current ?? { percent: 6, label: WORKSPACE_SWITCH_STEPS[0], }; const nextPercent = Math.min( 92, previous.percent + (previous.percent < 55 ? 9 : 4), ); const stepIndex = Math.min( WORKSPACE_SWITCH_STEPS.length - 1, Math.floor((nextPercent / 100) * WORKSPACE_SWITCH_STEPS.length), ); return { percent: nextPercent, label: WORKSPACE_SWITCH_STEPS[stepIndex], }; }); }, 1200); } const ThreadMessage: FC = () => { const role = useAuiState((s) => s.message.role); const isEditing = useAuiState((s) => s.message.composer.isEditing); if (isEditing) return ; if (role === "user") return ; return ; }; const ThreadScrollToBottom: FC = () => { return ( ); }; const ThreadWelcome: FC = () => { return (

ZK Data Agent

中控数据开发平台

); }; const ThreadSuggestions: FC = () => { return (
{() => }
); }; const ThreadSuggestionItem: FC = () => { return (
); }; const Composer: FC = () => { const workspaceSessionId = useCurrentThreadSessionId({ includePending: true, includeActive: true, }); return (
); }; const ComposerPendingInputQueue: FC<{ sessionId: string | null }> = ({ sessionId, }) => { const backendRunStatus = useBackendActiveRunStatus(sessionId); const items = useSessionInputQueue(sessionId); if (!sessionId || items.length === 0) return null; return (
{items.map((item) => { const isGuidance = item.kind === "guidance"; const canGuide = Boolean(backendRunStatus?.run_id); return (
{isGuidance ? "待引导" : "待发送"} {item.content}
); })}
); }; const ComposerWorkspaceStatus: FC<{ sessionId: string | null }> = ({ sessionId, }) => { const status = useJupyterWorkspaceStatus(sessionId); if (!status?.connected) return null; return (
Jupyter 工作区 {status.workspace_cwd ? ( {status.workspace_cwd} ) : null} {status.jupyter_tree_url ? ( 打开 ) : null}
); }; const ComposerAction: FC = () => { const aui = useAui(); const runtimeRunning = useAuiState((s) => s.thread.isRunning); const replayedRun = useReplayedRunState(); const currentSessionId = useCurrentThreadSessionId({ includePending: true, includeActive: true, }); const [runtimeCancelling, setRuntimeCancelling] = useState(false); const replayedRunIsCurrent = replayedRun.active && Boolean(currentSessionId) && replayedRun.sessionId === currentSessionId; const cancelSessionId = replayedRunIsCurrent ? replayedRun.sessionId : currentSessionId; const backendRunStatus = useBackendActiveRunStatus(cancelSessionId); const cancelRunId = (replayedRunIsCurrent ? replayedRun.runId : null) ?? backendRunStatus?.run_id ?? null; useEffect(() => { if (!runtimeRunning) setRuntimeCancelling(false); }, [runtimeRunning]); return (
{!runtimeRunning && !replayedRunIsCurrent && !backendRunStatus ? ( ) : null} {runtimeRunning ? ( ) : null} {!runtimeRunning && (replayedRunIsCurrent || backendRunStatus) ? ( ) : null}
); }; function useBackendActiveRunStatus(sessionId: string | null) { const [status, setStatus] = useState(null); useEffect(() => { if (!sessionId) { setStatus(null); return; } const safeSessionId = sessionId; let cancelled = false; async function refresh() { const nextStatus = await fetchLatestRunStatus(safeSessionId); if (cancelled) return; setStatus(isActiveRunStatus(nextStatus) ? nextStatus : null); } void refresh(); const interval = window.setInterval(refresh, 1000); return () => { cancelled = true; window.clearInterval(interval); }; }, [sessionId]); return status; } function isActiveRunStatus(status?: ClawRunStatus | null) { return status?.status === "queued" || status?.status === "running"; } function dispatchInputQueueChanged() { window.dispatchEvent(new Event(INPUT_QUEUE_CHANGED_EVENT)); } function useSessionInputQueue(sessionId: string | null) { const [items, setItems] = useState([]); useEffect(() => { if (!sessionId) { setItems([]); return; } let cancelled = false; const refresh = async () => { const nextItems = await fetchSessionInputQueue(sessionId); if (!cancelled) setItems(nextItems); }; const handleQueueChanged = () => { void refresh(); }; void refresh(); const interval = window.setInterval(refresh, 1000); window.addEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged); return () => { cancelled = true; window.clearInterval(interval); window.removeEventListener(INPUT_QUEUE_CHANGED_EVENT, handleQueueChanged); }; }, [sessionId]); return items; } async function fetchSessionInputQueue(sessionId: string) { try { const response = await fetch( `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`, { cache: "no-store" }, ); if (!response.ok) return []; const payload = (await response.json()) as { items?: SessionInputQueueItem[]; }; return Array.isArray(payload.items) ? payload.items : []; } catch { return []; } } async function createSessionInputQueueItem( sessionId: string, payload: { content: string; kind?: "next_turn" | "guidance"; run_id?: string; }, ) { const response = await fetch( `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue`, { method: "POST", cache: "no-store", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }, ); if (!response.ok) { throw new Error(`Failed to queue input: ${response.status}`); } return (await response.json()) as { item?: SessionInputQueueItem }; } async function updateSessionInputQueueItem( sessionId: string, itemId: number, payload: Partial< Pick >, ) { const response = await fetch( `/api/claw/sessions/${encodeURIComponent(sessionId)}/input-queue/${itemId}`, { method: "PATCH", cache: "no-store", headers: { "content-type": "application/json" }, body: JSON.stringify(payload), }, ); if (!response.ok) { throw new Error(`Failed to update queued input: ${response.status}`); } return (await response.json()) as { item?: SessionInputQueueItem }; } const ReplayedRunCancelButton: FC<{ sessionId: string | null; runId: string | null; }> = ({ sessionId, runId }) => { const { replaySession } = useClawSessionReplay(); const [cancelling, setCancelling] = useState(false); return ( ); }; const ComposerContextStatus: FC = () => { const status = useComposerContextStatus(); const totalTokens = status.contextBudget?.projected_input_tokens ?? 0; const limit = status.contextBudget?.context_window_tokens ?? 0; const percent = limit ? Math.min(100, Math.round((totalTokens / limit) * 100)) : 0; return (
); }; function ContextWindowButton({ percent, totalTokens, limit, contextBudget, }: { percent: number; totalTokens: number; limit: number; contextBudget?: ContextBudget; }) { const ringStyle = { background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`, }; return (
背景信息窗口:
{percent}% 已用(剩余 {Math.max(0, 100 - percent)}%)
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "} {formatCompactTokenCount(limit)}
背景信息窗口:
{percent}% 已用(剩余 {Math.max(0, 100 - percent)}%)
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "} {formatCompactTokenCount(limit)}
软阈值: {formatCompactTokenCount( contextBudget?.soft_input_limit_tokens ?? 0, )}
硬阈值: {formatCompactTokenCount( contextBudget?.hard_input_limit_tokens ?? 0, )}
保留输出: {formatCompactTokenCount( contextBudget?.reserved_output_tokens ?? 0, )} ;压缩缓冲: {formatCompactTokenCount( contextBudget?.reserved_compaction_buffer_tokens ?? 0, )}
计数器:{contextBudget?.token_counter_backend ?? "unknown"} {contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"}
); } function ModelPickerButton({ status, }: { status: { model?: string; models: ModelOption[]; setModel: (model: string) => Promise; }; }) { const [query, setQuery] = useState(""); const [open, setOpen] = useState(false); const normalizedQuery = query.trim().toLowerCase(); const filteredModels = useMemo(() => { if (!normalizedQuery) return status.models; return status.models.filter((model) => { const provider = model.provider ?? modelProvider(model.id); return ( model.id.toLowerCase().includes(normalizedQuery) || provider.toLowerCase().includes(normalizedQuery) ); }); }, [normalizedQuery, status.models]); return (
切换模型
setQuery(event.target.value)} placeholder="搜索模型或来源" className="h-8 w-full rounded-lg border bg-background pr-2.5 pl-8 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" />
{status.models.length ? (
{filteredModels.length ? ( filteredModels.map((model) => ( )) ) : (
没有匹配的模型
)}
) : (
模型列表读取中
)}
); } function useComposerContextStatus() { const isRunning = useAuiState((s) => s.thread.isRunning); const latestSessionId = useAuiState((s) => findLatestMessageMetadataValue(s.thread.messages, "sessionId"), ); const wasRunningRef = useRef(false); const [status, setStatus] = useState<{ model?: string; contextBudget?: ContextBudget; sessionId?: string | null; models: ModelOption[]; setModel: (model: string) => Promise; }>({ models: [], setModel: async () => {}, }); useEffect(() => { if (wasRunningRef.current && !isRunning) { window.dispatchEvent(new Event("claw-sessions-changed")); window.setTimeout(() => { window.dispatchEvent(new Event("claw-sessions-changed")); }, 1500); } wasRunningRef.current = isRunning; }, [isRunning]); useEffect(() => { let cancelled = false; async function refresh() { try { const stateResponse = await fetch("/api/claw/state", { cache: "no-store", }); const statePayload = stateResponse.ok ? ((await stateResponse.json()) as ClawState) : {}; const activeSessionId = normalizeSessionId( readSessionIdFromUrl() ?? readActiveSessionId(), ); const latestMessageSessionId = normalizeSessionId(latestSessionId); let sessionId = normalizeSessionId( activeSessionId || latestMessageSessionId, ); let sessionPayload: ClawStoredSession | null = null; let contextBudget: ContextBudget | undefined; if (sessionId) { const sessionResponse = await fetch( `/api/claw/sessions/${sessionId}`, { cache: "no-store", }, ); if (sessionResponse.ok) { sessionPayload = (await sessionResponse.json()) as ClawStoredSession; } else if (sessionResponse.status === 404) { if ( sessionId === normalizeSessionId(readPendingWorkspaceSessionId()) ) { sessionPayload = null; } else { clearActiveSessionId(); sessionId = null; } } } const budgetUrl = new URL( "/api/claw/context-budget", window.location.origin, ); if (sessionId) budgetUrl.searchParams.set("session_id", sessionId); const budgetResponse = await fetch(budgetUrl, { cache: "no-store" }); contextBudget = budgetResponse.ok ? ((await budgetResponse.json()) as ContextBudget) : undefined; if (cancelled) return; setStatus((current) => ({ model: statePayload.model ?? contextBudget?.model ?? sessionPayload?.model, contextBudget, sessionId, models: current.models, setModel: current.setModel, })); } catch { if (!cancelled) { setStatus((current) => ({ ...current, })); } } } refresh(); const onRefresh = () => refresh(); window.addEventListener("focus", onRefresh); window.addEventListener("claw-active-session-cleared", onRefresh); const interval = window.setInterval(refresh, isRunning ? 2000 : 8000); return () => { cancelled = true; window.removeEventListener("focus", onRefresh); window.removeEventListener("claw-active-session-cleared", onRefresh); window.clearInterval(interval); }; }, [isRunning, latestSessionId]); useEffect(() => { let cancelled = false; async function refreshModels() { try { const response = await fetch("/api/claw/models", { cache: "no-store", }); const payload = (await response.json()) as { models?: ModelOption[] }; if (!cancelled && response.ok) { setStatus((current) => ({ ...current, models: Array.isArray(payload.models) ? payload.models : [], })); } } catch { if (!cancelled) { setStatus((current) => ({ ...current, models: [] })); } } } refreshModels(); return () => { cancelled = true; }; }, []); const setModel = useCallback(async (model: string) => { const response = await fetch("/api/claw/state", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ model }), }); const payload = (await response.json()) as ClawState; if (!response.ok) return; setStatus((current) => ({ ...current, model: payload.model ?? model, contextBudget: undefined, })); }, []); useEffect(() => { setStatus((current) => ({ ...current, setModel })); }, [setModel]); return status; } function findLatestMessageMetadataValue( messages: readonly unknown[], key: string, ) { for (const message of [...messages].reverse()) { if (!message || typeof message !== "object") continue; const metadata = (message as { metadata?: unknown }).metadata; const value = readMetadataValue(metadata, key); if (value !== undefined) return value; } return undefined; } function readMetadataValue(metadata: unknown, key: string): unknown { if (!metadata || typeof metadata !== "object") return undefined; const record = metadata as Record; if (record[key] !== undefined) return record[key]; const custom = record.custom; if (custom && typeof custom === "object") { return (custom as Record)[key]; } return undefined; } function normalizeSessionId(value: unknown) { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed || null; } function formatCompactTokenCount(value: number) { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; return `${value}`; } function compactModelLabel(model?: string) { if (!model) return "模型"; const value = model.includes("/") ? model.split("/").at(-1) || model : model; if (value.toLowerCase().startsWith("mimo-v2.5")) return "MiMo 2.5"; if (value.toLowerCase().startsWith("mimo-v2")) return "MiMo 2"; if (value.toLowerCase().startsWith("deepseek")) return "DeepSeek"; if (value.toLowerCase().startsWith("qwen")) return "Qwen"; if (value.toLowerCase().startsWith("minimax")) return "MiniMax"; return value; } function modelProvider(model: string) { if (model.includes("/")) return model.split("/", 1)[0] || "unknown"; return "default"; } const ComposerAssistButtons: FC = () => { return (
} triggerLabel="" items={TOOL_PROMPTS} />
); }; const TOOL_PROMPTS = [ { title: "data_agent_profile_router_sessions", description: "查看 parquet 线上 session 数据字段、规模和样例。", prompt: "请优先使用 data_agent_profile_router_sessions 了解我指定的 router_session_parquet 数据目录,再给出可用字段和初步挖掘建议。", }, { title: "data_agent_search_router_sessions", description: "用关键词、正则、domain、device 等条件筛选候选。", prompt: "请优先使用 data_agent_search_router_sessions 按我的条件筛选线上候选,并说明筛选策略、命中数量和样例质量。", }, { title: "data_agent_normalize_dataset_draft", description: "把自然语言 draft 转换为 canonical records。", prompt: "请在我确认数据内容后,使用 data_agent_normalize_dataset_draft 把 draft 转换成 canonical records。", }, { title: "data_agent_validate_dataset_records", description: "校验 canonical records 是否符合结构约束。", prompt: "请使用 data_agent_validate_dataset_records 校验当前 canonical records,并解释每类错误应该如何修复。", }, ]; function PromptInsertDialog({ title, triggerIcon, triggerLabel, items, }: { title: string; triggerIcon: ReactNode; triggerLabel: string; items: { title: string; description: string; prompt: string }[]; }) { const [open, setOpen] = useState(false); return ( {title}
{items.map((item) => ( ))}
); } function SlashCommandDialog() { const [open, setOpen] = useState(false); const [commands, setCommands] = useState([]); const [status, setStatus] = useState( "点击后读取当前后端 slash command 列表。", ); async function loadCommands() { setStatus("正在读取 slash command 列表..."); try { const response = await fetch("/api/claw/slash-commands", { cache: "no-store", }); const payload = (await response.json()) as | SlashCommandSummary[] | { error?: string }; if (!response.ok || !Array.isArray(payload)) { throw new Error( Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"), ); } setCommands(payload); setStatus(payload.length ? "" : "当前没有可用 slash command。"); } catch (err) { setStatus(err instanceof Error ? err.message : "读取 slash command 失败"); } } return ( { setOpen(open); if (open) loadCommands(); }} > 快捷入口 {status ? (

{status}

) : null}
{commands.map((command) => { const commandText = `/${command.primary}`; const aliases = command.names .filter((name) => name !== command.primary) .map((name) => `/${name}`) .join(" "); return ( ); })}
); } function SkillInsertDialog() { const [open, setOpen] = useState(false); const [skills, setSkills] = useState([]); const [status, setStatus] = useState("点击后读取当前后端 Skill 列表。"); const [updatingSkill, setUpdatingSkill] = useState(null); const [syncingSkills, setSyncingSkills] = useState(false); const [bulkUpdatingSkills, setBulkUpdatingSkills] = useState(false); const configurableSkills = skills.filter( (skill) => skill.configurable !== false, ); const allSkillsEnabled = configurableSkills.length > 0 && configurableSkills.every((skill) => skill.enabled !== false); async function loadSkills() { setStatus("正在读取 Skill 列表..."); try { const response = await fetch("/api/claw/skills", { cache: "no-store" }); const payload = (await response.json()) as | SkillSummary[] | { error?: string }; if (!response.ok || !Array.isArray(payload)) { throw new Error( Array.isArray(payload) ? "读取失败" : (payload.error ?? "读取失败"), ); } setSkills(payload); setStatus(payload.length ? "" : "当前没有可用 Skill。"); } catch (err) { setStatus(err instanceof Error ? err.message : "读取 Skill 失败"); } } async function syncSkills() { setSyncingSkills(true); setStatus("正在从 git 同步 Skill..."); try { const response = await fetch("/api/claw/skills", { method: "POST", cache: "no-store", }); const payload = (await response.json()) as SkillSyncResponse; if (!response.ok || !Array.isArray(payload.skills)) { throw new Error(payload.detail ?? payload.error ?? "同步 Skill 失败"); } setSkills(payload.skills); const changedCount = payload.changed_files?.length ?? 0; if (payload.updated) { const shortHash = payload.after ? payload.after.slice(0, 7) : "最新提交"; setStatus(`Skill 已更新到 ${shortHash},变更 ${changedCount} 个文件。`); } else { setStatus("Skill 已是最新。"); } } catch (err) { setStatus(err instanceof Error ? err.message : "同步 Skill 失败"); } finally { setSyncingSkills(false); } } async function setSkillEnabled(skill: SkillSummary, enabled: boolean) { if (skill.configurable === false) return; const previous = skills; setUpdatingSkill(skill.name); setSkills((items) => items.map((item) => item.name === skill.name ? { ...item, enabled } : item, ), ); try { const response = await fetch("/api/claw/skills", { method: "PATCH", headers: { "content-type": "application/json", }, body: JSON.stringify({ skill: skill.name, enabled }), }); const payload = (await response.json()) as | SkillSummary[] | { error?: string; detail?: string }; if (!response.ok || !Array.isArray(payload)) { throw new Error( Array.isArray(payload) ? "更新失败" : (payload.detail ?? payload.error ?? "更新失败"), ); } setSkills(payload); dispatchSkillToggled({ skill: skill.name, enabled }); setStatus(enabled ? `已启用 ${skill.name}` : `已停用 ${skill.name}`); } catch (err) { setSkills(previous); setStatus(err instanceof Error ? err.message : "更新 Skill 失败"); } finally { setUpdatingSkill(null); } } async function setAllSkillsEnabled(enabled: boolean) { if (!configurableSkills.length) return; const previous = skills; setBulkUpdatingSkills(true); setSkills((items) => items.map((item) => item.configurable === false ? item : { ...item, enabled }, ), ); try { const response = await fetch("/api/claw/skills", { method: "PATCH", headers: { "content-type": "application/json", }, body: JSON.stringify({ apply_all: true, enabled }), }); const payload = (await response.json()) as | SkillSummary[] | { error?: string; detail?: string }; if (!response.ok || !Array.isArray(payload)) { throw new Error( Array.isArray(payload) ? "更新失败" : (payload.detail ?? payload.error ?? "更新失败"), ); } setSkills(payload); setStatus(enabled ? "已启用全部 Skill。" : "已停用全部可选 Skill。"); } catch (err) { setSkills(previous); setStatus(err instanceof Error ? err.message : "批量更新 Skill 失败"); } finally { setBulkUpdatingSkills(false); } } return ( { setOpen(open); if (open) loadSkills(); }} > Skills
{status ? (

{status}

) : null}
{skills.map((skill) => (
setSkillEnabled(skill, event.currentTarget.checked) } />
))}
); } function insertComposerText(text: string) { window.dispatchEvent( new CustomEvent("claw-composer-insert", { detail: { text }, }), ); } const MessageError: FC = () => { return ( ); }; const AssistantMessage: FC = () => { const ACTION_BAR_PT = "pt-1.5"; const ACTION_BAR_HEIGHT = `-mb-7.5 min-h-7.5 ${ACTION_BAR_PT}`; const messageId = useAuiState((s) => s.message.id); return (
{ if (part.type === "reasoning" || part.type === "tool-call") return ["group-chain"]; return null; }} > {({ part }) => { switch (part.type) { case "group-chain": return ( ); case "text": return ( ); case "reasoning": return ; case "tool-call": return part.toolUI ?? ; default: return null; } }}
); }; const ActivityChainSummary: FC<{ messageId: string; partIndex: number | undefined; }> = ({ messageId, partIndex }) => { const { openItem } = useActivityPanel(); const metadataRunStartedAtMs = useAuiState((s) => { const value = readMetadataValue(s.message.metadata, "runStartedAtMs"); return typeof value === "number" && Number.isFinite(value) ? value : null; }); const contentRunStartedAtMs = useAuiState((s) => findNumberInActivityParts(s.message.content, "runStartedAtMs"), ); const messageCreatedAtMs = useAuiState((s) => normalizeMessageCreatedAtMs(s.message.createdAt), ); const storedElapsedMs = useAuiState((s) => { const value = readMetadataValue(s.message.metadata, "elapsedMs"); const contentValue = findElapsedMsInActivityParts(s.message.content); if (typeof value !== "number" || !Number.isFinite(value)) { return contentValue; } return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.round(value)) : null; }); const initialStartedAtMs = metadataRunStartedAtMs ?? contentRunStartedAtMs ?? messageCreatedAtMs; const liveStartedAtRef = useRef(initialStartedAtMs); const [elapsedMs, setElapsedMs] = useState(() => { if (initialStartedAtMs !== null) { return Math.max(0, Date.now() - initialStartedAtMs); } if (storedElapsedMs !== null) return storedElapsedMs; return null; }); const label = useAuiState((s) => { const message = s.message; const status = message.status?.type === "running" || isReplayRunMessageId(messageId) ? "running" : message.status?.type; return summarizeActivityChainLabel(message.content, status, elapsedMs); }); const active = useAuiState((s) => { const message = s.message; return ( message.status?.type === "running" || isReplayRunMessageId(messageId) ); }); useEffect(() => { if (!active) { setElapsedMs(storedElapsedMs); return; } const authoritativeStartedAt = metadataRunStartedAtMs ?? contentRunStartedAtMs; if (authoritativeStartedAt !== null) { liveStartedAtRef.current = authoritativeStartedAt; } else if (liveStartedAtRef.current === null && storedElapsedMs !== null) { liveStartedAtRef.current = Date.now() - storedElapsedMs; } else if (liveStartedAtRef.current !== null && storedElapsedMs !== null) { liveStartedAtRef.current = Math.min( liveStartedAtRef.current, Date.now() - storedElapsedMs, ); } else if ( liveStartedAtRef.current === null && messageCreatedAtMs !== null ) { liveStartedAtRef.current = messageCreatedAtMs; } const startedAt = liveStartedAtRef.current; if (startedAt === null) { setElapsedMs(storedElapsedMs); return; } setElapsedMs(Math.max(0, Date.now() - startedAt)); const timer = window.setInterval(() => { setElapsedMs(Math.max(0, Date.now() - startedAt)); }, 1000); return () => window.clearInterval(timer); }, [ active, metadataRunStartedAtMs, contentRunStartedAtMs, messageCreatedAtMs, storedElapsedMs, ]); return ( ); }; function isReplayRunMessageId(messageId: string) { return messageId.includes("-run-"); } type ActivitySummaryPart = { type: string; text?: string; result?: unknown; args?: unknown; toolName?: string; runStartedAtMs?: unknown; elapsedMs?: unknown; }; function findNumberInActivityParts( content: readonly ActivitySummaryPart[], key: keyof ActivitySummaryPart, ) { for (const part of content) { const value = part[key]; if (typeof value === "number" && Number.isFinite(value)) return value; } return null; } function normalizeMessageCreatedAtMs(value: unknown) { if (value instanceof Date) { const time = value.getTime(); return Number.isFinite(time) ? time : null; } if (typeof value === "number" && Number.isFinite(value)) { return value < 10_000_000_000 ? Math.round(value * 1000) : Math.round(value); } if (typeof value === "string") { const time = Date.parse(value); return Number.isFinite(time) ? time : null; } return null; } function findElapsedMsInActivityParts(content: readonly ActivitySummaryPart[]) { for (const part of content) { if (typeof part.elapsedMs === "number" && Number.isFinite(part.elapsedMs)) { return Math.max(0, Math.round(part.elapsedMs)); } const args = part.args; if (!args || typeof args !== "object" || Array.isArray(args)) continue; const value = (args as Record).__claw_elapsed_ms; if (typeof value === "number" && Number.isFinite(value)) { return Math.max(0, Math.round(value)); } } return null; } function summarizeActivityChainLabel( content: readonly ActivitySummaryPart[], status: string | undefined, elapsedMs: number | null = null, ) { const toolParts = content.filter((part) => part.type === "tool-call"); const runningTool = toolParts.findLast((part) => part.result === undefined); const activeTool = runningTool ?? toolParts.at(-1); const reasoningText = content.find((part) => part.type === "reasoning")?.text ?? ""; const lastReasoningLine = reasoningText .trim() .split("\n") .map((line) => line.trim()) .filter(Boolean) .at(-1); const stageNote = activeTool ? getToolStageNote(activeTool.args) : ""; const activeDetail = stageNote || (activeTool?.toolName ? `调用 ${activeTool.toolName}` : "") || (isGenericActivityLine(lastReasoningLine) ? "" : lastReasoningLine); if (status === "running") { const elapsed = elapsedMs === null ? "" : `,用时 ${formatActivityDuration(elapsedMs)}`; if (activeDetail) { return `${compactActivityLabel(activeDetail, 15)}${elapsed}`; } return elapsed ? `思考中${elapsed}` : "思考中"; } const label = compactActivityLabel( activeDetail || lastReasoningLine || "思考完成", 15, ); const finalDuration = extractDurationLabel(lastReasoningLine); const elapsed = finalDuration || (elapsedMs === null ? "" : formatActivityDuration(elapsedMs)); return elapsed ? `${label},用时 ${elapsed}` : label; } function getToolStageNote(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) return ""; const note = (value as Record).__claw_stage_note; return typeof note === "string" ? note.trim() : ""; } function isGenericActivityLine(value?: string) { if (!value) return true; return ( value.startsWith("思考中") || value.startsWith("思考并执行完成") || value.endsWith("输出中...") ); } function extractDurationLabel(value?: string) { if (!value) return ""; const match = value.match(/用时\s*([^。,.,\s]+)/); return match?.[1] ?? ""; } function formatActivityDuration(ms: number) { const safeMs = Math.max(0, Math.round(ms)); if (safeMs < 1000) return `${safeMs}ms`; const totalSeconds = Math.round(safeMs / 1000); if (totalSeconds < 60) return `${totalSeconds}s`; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return seconds > 0 ? `${minutes}min${seconds}s` : `${minutes}min`; } function compactActivityLabel(label: string, maxLength = 15) { const normalized = label.replace(/\s+/g, " ").trim(); const progress = normalized.match( /^进度[::]\s*批\s*(\d+)\s*\/\s*(\d+)(?:[,,]\s*已生成\s*(\d+)\s*\/\s*(\d+))?/, ); if (progress) { const batch = `批${progress[1]}/${progress[2]}`; const count = progress[3] && progress[4] ? ` 已${progress[3]}/${progress[4]}` : ""; const compactProgress = `${batch}${count}`; if (compactProgress.length <= maxLength) return compactProgress; return batch; } if (normalized.length <= maxLength) return normalized; return `${normalized.slice(0, Math.max(1, maxLength - 1))}…`; } const AssistantActionBar: FC = () => { return ( s.message.isCopied}> !s.message.isCopied}> Export as Markdown ); }; const UserMessage: FC = () => { return (
); }; const UserActionBar: FC = () => { return ( ); }; const EditComposer: FC = () => { return (
); }; const ImeComposerInput: FC> = ({ autoFocus, onChange, onCompositionEnd, onCompositionStart, onKeyDown, disabled, ...props }) => { const aui = useAui(); const storeText = useAuiState((s) => s.composer.isEditing ? s.composer.text : "", ); const isEditingComposer = useAuiState((s) => s.composer.isEditing); const runtimeRunning = useAuiState((s) => s.thread.isRunning); const runtimeDisabled = useAuiState( (s) => s.thread.isDisabled || s.composer.dictation?.inputDisabled, ); const activeSessionId = useCurrentThreadSessionId({ includePending: true, includeActive: true, }); const backendRunStatus = useBackendActiveRunStatus(activeSessionId); const [localText, setLocalText] = useState(storeText); const textareaRef = useRef(null); const isComposingRef = useRef(false); const justSubmittedRef = useRef(false); const canQueueWhileRunning = !isEditingComposer && Boolean(activeSessionId) && (runtimeRunning || isActiveRunStatus(backendRunStatus)); const isDisabled = (runtimeDisabled && !canQueueWhileRunning) || disabled; // Pull store→local on external store changes (session switch, paste-from-attachment, // submit-clear, etc). The reverse direction (local→store) is pushed eagerly by // onChange/handleInsert/handleKeyDown/onCompositionEnd via syncComposerText, so we // don't need a symmetric effect — and adding one creates a feedback loop that // re-renders the composer at ~1ms intervals (visible as shake). useEffect(() => { if (!isComposingRef.current) { if (justSubmittedRef.current && storeText) return; if (!storeText) justSubmittedRef.current = false; setLocalText(storeText); } }, [storeText]); useEffect(() => { if (!autoFocus || isDisabled) return; const textarea = textareaRef.current; if (!textarea) return; textarea.focus({ preventScroll: true }); textarea.setSelectionRange(textarea.value.length, textarea.value.length); }, [autoFocus, isDisabled]); useEffect(() => { const clearAfterSubmit = () => { justSubmittedRef.current = true; setLocalText(""); scheduleSessionListRefresh(); }; const unsubscribeComposer = aui.on("composer.send", clearAfterSubmit); const unsubscribeRun = aui.on("thread.runStart", clearAfterSubmit); return () => { unsubscribeComposer(); unsubscribeRun(); }; }, [aui]); // AssistantWorkspace 切 session 时通过事件通知我们恢复 draft——直接 // 控制 localText,绕过 store→local 路径上的 justSubmittedRef 屏蔽和 // switchToNewThread 引起的异步覆盖。 useEffect(() => { const handler = (event: Event) => { const detail = (event as CustomEvent<{ text?: string }>).detail; const text = detail?.text ?? ""; justSubmittedRef.current = false; setLocalText(text); requestAnimationFrame(() => { const textarea = textareaRef.current; if (!textarea) return; textarea.focus({ preventScroll: true }); textarea.setSelectionRange(text.length, text.length); }); }; window.addEventListener("claw-composer-restore-draft", handler); return () => window.removeEventListener("claw-composer-restore-draft", handler); }, []); const syncComposerText = useCallback( (value: string) => { if (!aui.composer().getState().isEditing) return; aui.composer().setText(value); }, [aui], ); useEffect(() => { const textarea = textareaRef.current; if (!textarea) return; const handleInsert = (event: Event) => { const insertText = (event as ComposerInsertEvent).detail?.text; if (!insertText) return; const nextText = [localText.trimEnd(), insertText.trim()] .filter(Boolean) .join("\n\n"); setLocalText(nextText); syncComposerText(nextText); requestAnimationFrame(() => { textarea.focus({ preventScroll: true }); textarea.setSelectionRange(nextText.length, nextText.length); }); }; window.addEventListener("claw-composer-insert", handleInsert); return () => window.removeEventListener("claw-composer-insert", handleInsert); }, [localText, syncComposerText]); const handleKeyDown = (event: KeyboardEvent) => { onKeyDown?.(event); if (event.defaultPrevented || isDisabled) return; if ( event.nativeEvent.isComposing || isComposingRef.current || event.key !== "Enter" || event.shiftKey ) { return; } const threadState = aui.thread().getState(); const content = localText.trim(); if ( (threadState.isRunning || isActiveRunStatus(backendRunStatus)) && activeSessionId ) { event.preventDefault(); if (!content) return; justSubmittedRef.current = true; setLocalText(""); syncComposerText(""); void createSessionInputQueueItem(activeSessionId, { content, kind: "next_turn", }) .catch(() => { justSubmittedRef.current = false; setLocalText(content); }) .finally(dispatchInputQueueChanged); return; } if (threadState.isRunning && !threadState.capabilities.queue) return; event.preventDefault(); syncComposerText(localText); aui.composer().send(); justSubmittedRef.current = true; setLocalText(""); }; return ( { onChange?.(event); const nextText = event.currentTarget.value; justSubmittedRef.current = false; setLocalText(nextText); const isNativeComposing = (event.nativeEvent as { isComposing?: boolean }).isComposing === true; if (!isNativeComposing && !isComposingRef.current) { syncComposerText(nextText); } }} onCompositionStart={(event) => { onCompositionStart?.(event); isComposingRef.current = true; }} onCompositionEnd={(event) => { onCompositionEnd?.(event); isComposingRef.current = false; const target = event.currentTarget; queueMicrotask(() => { const nextText = target.value; setLocalText(nextText); syncComposerText(nextText); }); }} onKeyDown={handleKeyDown} /> ); }; const BranchPicker: FC = ({ className, ...rest }) => { return ( / ); };