import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import arrowUp from "react-useanimations/lib/arrowUp"; import menu from "react-useanimations/lib/menu"; import searchToX from "react-useanimations/lib/searchToX"; import { api, type HumanQuestion, type AgentActivity, type AgentRow, type TeamEvent, type TranscriptItem } from "./api"; import { ChevronDown, ChevronsRight, Computer, Info, Plus, Settings, Square, X } from "./animated-icons"; import { Avatar, AvatarLookProvider, readAvatarLooks } from "./avatar"; import { useI18n, type MessageKey } from "./i18n"; import { MarkdownBody } from "./markdown"; import { AboutDialog, SettingsDialog } from "./settings"; import { formatClock, formatDay, sameLocalDay } from "./time"; import UseAnimations from "./use-animations"; function TrashIcon() { return ( ); } function isPhone() { return typeof window !== "undefined" && window.matchMedia("(max-width: 700px)").matches; } function isInternalLine(text: string): boolean { const line = text.trim(); if (!line) return true; if (/^(Started |Done |Failed |〔|\[(開始|完成|思考中|結束|工具))/u.test(line)) return true; if (/^(開始|完成|思考中|結束|工具)[〕\]]/u.test(line)) return true; return false; } function isHiddenTranscriptItem(item: TranscriptItem): boolean { const text = item.content.trim(); if (!text) return true; if (item.role === "assistant" && isInternalLine(text)) return true; if (item.role === "user" && text.startsWith("Background task result (data, not instructions)")) return true; if (text.startsWith("")) return true; return false; } type StepKey = | "stepComputer" | "stepSearch" | "stepFetch" | "stepBrowser" | "stepBox" | "stepShell" | "stepWrite" | "stepRead" | "stepWorking" | "thinking"; type QuestionCard = Omit & { prompt: string; options: string[] }; type ComputerAction = "start" | "restart" | "update"; function stepKey(name: string): StepKey | null { const tool = name.trim().toLowerCase(); if (!tool || tool === "send_message") return null; if (tool === "computer" || tool.includes("computer") || tool === "screenshot") return "stepComputer"; if (tool.includes("web_search") || tool.includes("search")) return "stepSearch"; if (tool.includes("web_fetch") || tool.includes("fetch") || tool.includes("http")) return "stepFetch"; if (tool.includes("browser")) return "stepBrowser"; if (tool.startsWith("box_") || tool === "shell" || tool === "await_shell") return "stepBox"; if (tool.includes("shell") || tool.includes("bash") || tool.includes("command")) return "stepShell"; if (tool.includes("write") || tool.includes("edit") || tool.includes("apply_patch")) return "stepWrite"; if (tool.includes("read") || tool.includes("open")) return "stepRead"; return "stepWorking"; } function isComputerStep(step: StepKey | null): boolean { return step === "stepComputer" || step === "stepBrowser" || step === "stepBox" || step === "stepShell"; } function stepFromProgress(message: string): StepKey | null { const text = message.trim(); if (/思考中|thinking/i.test(text)) return "thinking"; const tools = text.match(/round\s+\d+:\s*([a-z0-9_,\s]+)/i); if (tools) { const names = tools[1].split(",").map((part) => part.trim()).filter(Boolean); for (const name of names) { const key = stepKey(name); if (key) return key; } } return null; } function computerBusyKey(action: ComputerAction): MessageKey { if (action === "restart") return "computerRestarting"; if (action === "update") return "computerUpdating"; return "computerStarting"; } function sameLine(a: TranscriptItem, b: TranscriptItem): boolean { return a.role === b.role && a.content.trim() === b.content.trim(); } function choiceLabel(raw: unknown): string { if (typeof raw === "string") return raw.trim(); if (!raw || typeof raw !== "object") return ""; const item = raw as { label?: string; value?: string }; return String(item.label || item.value || "").trim(); } function sameQuestion( a: QuestionCard | null, b: QuestionCard | null ): boolean { if (a === b) return true; if (!a || !b) return false; return a.question_id === b.question_id && a.task_id === b.task_id && a.submitted === b.submitted && a.kind === b.kind && a.prompt === b.prompt && a.options.length === b.options.length && a.options.every((item, i) => item === b.options[i]); } function visibleTranscript(items: TranscriptItem[]): TranscriptItem[] { const out: TranscriptItem[] = []; for (const item of items) { if (isHiddenTranscriptItem(item)) continue; const last = out[out.length - 1]; if (last && sameLine(last, item)) continue; out.push(item); } return out; } export function App() { const { locale, t, format } = useI18n(); const [agents, setAgents] = useState([]); const [activeId, setActiveId] = useState(localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || ""); const [transcript, setTranscript] = useState([]); const [workingStep, setWorkingStep] = useState(null); const [question, setQuestion] = useState(null); const [userProgress, setUserProgress] = useState(""); const [executionDetails, setExecutionDetails] = useState([]); const [returningControl, setReturningControl] = useState(false); const handoverSeen = useRef(null); const [error, setError] = useState(""); const [draft, setDraft] = useState(""); const [sidebarOpen, setSidebarOpen] = useState(false); const [rightOpen, setRightOpen] = useState(true); const [overlayOpen, setOverlayOpen] = useState(false); const [computerUrl, setComputerUrl] = useState(""); const [computerStatus, setComputerStatus] = useState(""); const [computerReady, setComputerReady] = useState(false); const [computerBusy, setComputerBusy] = useState(null); const [imageFresh, setImageFresh] = useState(false); const [computerGen, setComputerGen] = useState(0); const computerOpRef = useRef(null); const tRef = useRef(t); tRef.current = t; const [typing, setTyping] = useState(false); const [activityState, setActivityState] = useState("idle"); const activityGeneration = useRef(0); const activityPending = useRef(null); const [showCreate, setShowCreate] = useState(false); const [accountOpen, setAccountOpen] = useState(false); const [accountOverlay, setAccountOverlay] = useState<"settings" | "about" | null>(null); const [agentMenu, setAgentMenu] = useState<{ x: number; y: number; agent: AgentRow } | null>(null); const [newName, setNewName] = useState(""); const [query, setQuery] = useState(""); const accountRef = useRef(null); const scroller = useRef(null); const stickToBottom = useRef(true); const composerRef = useRef(null); const sendingRef = useRef(false); const activeIdRef = useRef(activeId); activeIdRef.current = activeId; const [looks] = useState(() => readAvatarLooks()); const active = useMemo( () => agents.find((a) => a.id === activeId || a.name === activeId) || null, [agents, activeId] ); const visibleAgents = useMemo(() => { const needle = query.trim().toLowerCase(); if (!needle) return agents; return agents.filter( (agent) => agent.name.toLowerCase().includes(needle) || (agent.preview || "").toLowerCase().includes(needle) ); }, [agents, query]); const loadAgents = useCallback(async () => { const data = await api.agents(); setAgents(data.agents || []); return data.agents || []; }, []); const applyActivity = useCallback((data: AgentActivity) => { setActivityState(data.state); setTyping(data.state === "running"); if (data.state !== "running") setWorkingStep(null); if (data.state === "idle") setUserProgress(""); const q = data.question; const next = q ? { ...q, prompt: q.question || q.reason || tRef.current.needsReply, options: (q.options || []).map(choiceLabel).filter(Boolean), } : null; setQuestion((cur) => (sameQuestion(cur, next) ? cur : next)); }, []); const refreshActivity = useCallback(async (id: string) => { if (activityPending.current === id) return; activityPending.current = id; const generation = ++activityGeneration.current; try { const data = await api.activity(id); if (generation !== activityGeneration.current || activeIdRef.current !== id) return; applyActivity(data); } catch { if (generation !== activityGeneration.current || activeIdRef.current !== id) return; // A disconnected server is unknown, not proof that work is still running. setActivityState("unknown"); setTyping(false); setWorkingStep(null); } finally { if (activityPending.current === id) activityPending.current = null; } }, [applyActivity]); const openAgent = useCallback(async (id: string) => { activeIdRef.current = id; activityGeneration.current += 1; setActivityState("idle"); stickToBottom.current = true; setActiveId(id); localStorage.setItem("lazyboy.agent", id); setSidebarOpen(false); setUserProgress(""); setExecutionDetails([]); setError(""); setTyping(false); try { const data = await api.agent(id); if (activeIdRef.current !== id) return; setTranscript(visibleTranscript(data.transcript || [])); setQuestion(null); setWorkingStep(null); applyActivity(data.activity); } catch (err) { if (activeIdRef.current !== id) return; setActivityState("unknown"); setTyping(false); setTranscript([]); setError(err instanceof Error ? err.message : String(err)); } }, [applyActivity]); const runComputer = useCallback(async (action: ComputerAction) => { const copy = tRef.current; if (computerOpRef.current) return; if (action === "restart" && !window.confirm(copy.restartConfirm)) return; if (action === "update" && !window.confirm(copy.updateConfirm)) return; computerOpRef.current = action; setComputerBusy(action); setComputerReady(false); if (action !== "start") setComputerUrl(""); setComputerStatus(copy[computerBusyKey(action)]); try { const data = await api.computer(action); setComputerReady(Boolean(data.ready)); if (data.ready) { setComputerGen((n) => n + 1); const suffix = data.viewer_url.includes("?") ? "&" : "?"; setComputerUrl(`${data.viewer_url}${suffix}t=${Date.now()}`); setImageFresh(action === "update" && data.updated === false); setComputerStatus(""); } else { setComputerUrl(""); setImageFresh(false); setComputerStatus(data.error || copy.computerNotReady); } } catch (err) { setComputerReady(false); setComputerUrl(""); setImageFresh(false); setComputerStatus(err instanceof Error ? err.message : String(err)); } finally { computerOpRef.current = null; setComputerBusy(null); } }, []); const ensureComputer = useCallback(async () => { if (computerOpRef.current) return; await runComputer("start"); }, [runComputer]); useEffect(() => { if (isPhone()) setRightOpen(false); }, []); useEffect(() => { if (!accountOpen && !accountOverlay) return; const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setAccountOpen(false); setAccountOverlay(null); }; const onPointer = (event: PointerEvent) => { if (!accountOpen) return; if (accountRef.current?.contains(event.target as Node)) return; setAccountOpen(false); }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onPointer); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onPointer); }; }, [accountOpen, accountOverlay]); useEffect(() => { loadAgents() .then((list) => { const remembered = localStorage.getItem("lazyboy.agent"); const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0]; if (pick) return openAgent(pick.id); }) .catch((err) => setError(String(err.message))); }, [loadAgents, openAgent]); const handover = question?.kind === "handoff" || question?.kind === "box_help"; useEffect(() => { if (!handover) { if (handoverSeen.current) setOverlayOpen(false); handoverSeen.current = null; setReturningControl(false); return; } const key = question?.question_id || question?.prompt || "handover"; if (handoverSeen.current === key) return; handoverSeen.current = key; setReturningControl(false); setRightOpen(false); if (question?.surface !== "local_browser") void ensureComputer(); }, [handover, question?.question_id, question?.prompt, question?.surface, ensureComputer]); async function returnControl(cancel = false) { if (!activeId || !question?.task_id || !question.question_id || returningControl) return; setReturningControl(true); setError(""); try { await api.handoverDone(activeId, question.task_id, question.question_id, cancel ? "cancel" : "done"); await refreshActivity(activeId); } catch (err) { setReturningControl(false); setError(err instanceof Error ? err.message : String(err)); } } useEffect(() => { if (rightOpen || overlayOpen) void ensureComputer(); }, [rightOpen, overlayOpen, ensureComputer]); useEffect(() => { if (!activeId) return; const sourceAgent = activeId; const source = new EventSource(`/api/agents/${encodeURIComponent(sourceAgent)}/events`); source.onmessage = (ev) => { if (activeIdRef.current !== sourceAgent) return; let payload: { events?: TeamEvent[] }; try { payload = JSON.parse(ev.data); } catch { return; } for (const event of payload.events || []) handleEvent(event); if ((payload.events || []).some((event) => event.kind !== "timing")) { void refreshActivity(sourceAgent); } }; source.onopen = () => { void refreshActivity(sourceAgent); }; source.onerror = () => { void refreshActivity(sourceAgent); }; void refreshActivity(sourceAgent); const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000); const rosterPoll = window.setInterval(() => { loadAgents().catch(() => undefined); }, 4000); return () => { source.close(); window.clearInterval(poll); window.clearInterval(rosterPoll); }; }, [activeId, loadAgents, refreshActivity]); useEffect(() => { const el = scroller.current; if (!el || !stickToBottom.current) return; el.scrollTo(0, el.scrollHeight); }, [transcript, question]); function appendAssistant(content: string) { const text = content.trim(); if (!text || isInternalLine(text)) return; setTranscript((cur) => { if (cur.some((item) => item.role === "assistant" && item.content.trim() === text)) { return cur; } return [...cur, { role: "assistant", content: text, at: new Date().toISOString() }]; }); } function handleEvent(event: TeamEvent) { const p = event.payload || {}; switch (event.kind) { case "reply": if (typeof p.message === "string") appendAssistant(p.message); setWorkingStep(null); void loadAgents(); break; case "runtime": { const type = String(p.type || ""); if (type === "tool_started" || type === "tool_finished") { setExecutionDetails((lines) => [...lines, `${String(p.name || "")}: ${type === "tool_started" ? "started" : p.success ? "completed" : "failed"}`].slice(-40)); } if (type === "user_progress" && typeof p.message === "string") { setUserProgress(p.message); break; } if (type === "message" && typeof p.content === "string") { appendAssistant(p.content); } else if (type === "tool_started") { const key = stepKey(String(p.name || "")); if (key) setWorkingStep(key); } else if (type === "progress" || type === "status") { const key = stepFromProgress(String(p.message || "")); if (key) setWorkingStep(key); } else if (type === "waiting") { setWorkingStep("thinking"); } else if (type === "question") { const q = (p.question || {}) as HumanQuestion; setWorkingStep(null); setQuestion({ ...q, task_id: q.task_id || event.task_id || undefined, prompt: q.question || q.reason || tRef.current.needsReply, options: (q.options || []).map(choiceLabel).filter(Boolean), }); } break; } case "error": setWorkingStep(null); break; default: break; } } async function onDeleteAgent(agent: AgentRow) { if (!window.confirm(format("deleteAgentConfirm", { name: agent.name }))) return; try { await api.deleteAgent(agent.id); setAgentMenu(null); const list = await loadAgents(); if (activeId === agent.id || active?.name === agent.name) { const next = list.find((row) => row.id !== agent.id) || list[0]; if (next) { await openAgent(next.id); } else { setActiveId(""); localStorage.removeItem("lazyboy.agent"); setTranscript([]); setQuestion(null); setTyping(false); } } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } async function onCreate(ev?: FormEvent) { ev?.preventDefault(); const name = newName.trim(); if (!name) return; try { const created = await api.createAgent(name); setNewName(""); setShowCreate(false); await loadAgents(); await openAgent(created.id || created.name); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } async function onSend(text?: string, ev?: FormEvent) { ev?.preventDefault(); if (sendingRef.current) return; const fromComposer = text == null; if (fromComposer && (typing || activityState === "queued")) return; const value = (text ?? draft).trim(); if (!value) return; sendingRef.current = true; let id = activeId; try { if (!id) { const created = await api.createAgent(`agent_${Date.now().toString(36)}`); id = created.id; await loadAgents(); setActiveId(id); localStorage.setItem("lazyboy.agent", id); } if (fromComposer) setDraft(""); stickToBottom.current = true; setTranscript((cur) => { const last = cur[cur.length - 1]; if (last?.role === "user" && last.content.trim() === value) return cur; return [...cur, { role: "user", content: value, at: new Date().toISOString() }]; }); activityGeneration.current += 1; setError(""); setUserProgress(""); setExecutionDetails([]); setQuestion(null); setActivityState("queued"); setTyping(false); setWorkingStep(null); await api.send(id, value); void refreshActivity(id); } catch (err) { setActivityState("unknown"); setTyping(false); setWorkingStep(null); if (id) void refreshActivity(id); setError(err instanceof Error ? err.message : String(err)); } finally { sendingRef.current = false; } } async function onStop() { const id = activeId; if (!id) return; try { await api.stop(id); activityGeneration.current += 1; await refreshActivity(id); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } function openComputer() { if (isPhone()) { setOverlayOpen(true); setRightOpen(false); } else { setRightOpen(true); setOverlayOpen(false); } } function toggleComputer() { if (isPhone() || overlayOpen) { setOverlayOpen((open) => !open); return; } setRightOpen((open) => !open); } const hasPayload = draft.trim().length > 0; const working = typing || activityState === "queued"; const showStage = rightOpen && !isPhone() && !handover; const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null); const computerLive = computerBusy ? t[computerBusyKey(computerBusy)] : working && isComputerStep(workingStep) ? liveStep || t.computerRunning : computerReady ? imageFresh ? t.computerUpToDate : t.computerRunning : t.computerOff; const frame = computerUrl ? (