import { FormEvent, memo, 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 { AgentSettingsDialog } from "./agent-settings"; import { BotIcon, ChevronDown, ChevronsRight, Computer, Info, Plus, Settings, Square, Users, X } from "./animated-icons"; import { Avatar, AvatarLookProvider, AvatarStack, readAvatarLooks } from "./avatar"; import { readChannels, writeChannels, type Channel } from "./channels"; import { useI18n, type Locale, type MessageKey } from "./i18n"; import { MarkdownBody } from "./markdown"; import { AboutDialog, SettingsDialog } from "./settings"; import { formatClock, formatDay, formatInboxTime, sameChatCluster, sameLocalDay } from "./time"; import type { AvatarShape, RoomMember } from "./types"; import UseAnimations from "./use-animations"; function TrashIcon() { return ( ); } function isPhone() { return typeof window !== "undefined" && window.matchMedia("(max-width: 700px)").matches; } function avatarProps(agent: Pick) { return { lookId: agent.id, name: agent.name, color: agent.avatar_color || undefined, shape: (agent.avatar_shape as AvatarShape | undefined) || "organic", src: agent.avatar_url || undefined, }; } function roomMember(agent: AgentRow): RoomMember { return { id: agent.id, name: agent.name, avatarColor: agent.avatar_color || "", avatarShape: (agent.avatar_shape as AvatarShape) || "organic", avatarUrl: agent.avatar_url || undefined, }; } function ChannelDialog({ agents, onClose, onCreate, }: { agents: AgentRow[]; onClose: () => void; onCreate: (name: string, memberIds: string[]) => void; }) { const { t, format } = useI18n(); const [name, setName] = useState(""); const [selected, setSelected] = useState([]); const host = agents.find((agent) => agent.id === selected[0]); return (
event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); const groupName = name.trim(); if (!groupName || selected.length < 2) return; onCreate(groupName, selected); }} >

{t.addChannel}

{t.channelDescription}

{t.chooseAgents} {agents.map((agent) => ( ))}
{host ?

{format("channelHostHint", { name: host.name })}

: null}
); } 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 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 tagsKey(tags?: string[]) { return (tags || []).join("\0"); } function sameAgentSurface(a: AgentRow, b: AgentRow) { return ( a.id === b.id && a.name === b.name && (a.preview || "") === (b.preview || "") && Boolean(a.running) === Boolean(b.running) && (a.title || "") === (b.title || "") && (a.description || "") === (b.description || "") && (a.avatar_color || "") === (b.avatar_color || "") && (a.avatar_shape || "") === (b.avatar_shape || "") && (a.avatar_url || "") === (b.avatar_url || "") && Boolean(a.has_avatar) === Boolean(b.has_avatar) && (a.last_at || "") === (b.last_at || "") && tagsKey(a.tags) === tagsKey(b.tags) ); } function adoptAgents(prev: AgentRow[], next: AgentRow[]): AgentRow[] { if (prev.length === next.length && prev.every((row, i) => sameAgentSurface(row, next[i]))) { return prev; } const prevById = new Map(prev.map((row) => [row.id, row])); return next.map((row) => { const old = prevById.get(row.id); return old && sameAgentSurface(old, row) ? old : row; }); } function sameSpoken(a: TranscriptItem[], b: TranscriptItem[]) { if (a.length !== b.length) return false; return a.every((item, i) => item.role === b[i].role && item.content === b[i].content); } function patchAgentRow(row: AgentRow, patch: Partial): AgentRow { const next = { ...row, ...patch }; return sameAgentSurface(row, next) ? row : next; } 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; } const SendArrow = memo(function SendArrow() { return ; }); const MessageLog = memo(function MessageLog({ transcript, questionPrompt, locale, agent, }: { transcript: TranscriptItem[]; questionPrompt: string | null; locale: Locale; agent: Pick | null; }) { // A fixed page keeps Markdown trees, images and avatars bounded for long chats. const [page, setPage] = useState(0); const pageCount = Math.max(1, Math.ceil(transcript.length / 40)); const currentPage = Math.min(page, pageCount - 1); const end = Math.max(0, transcript.length - currentPage * 40); const items = transcript.slice(Math.max(0, end - 40), end).filter((item) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt)); return ( <> {pageCount > 1 ? : null} {items.map((item, i) => { const prev = items[i - 1]; const next = items[i + 1]; const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at)); const clusterStart = newDay || !sameChatCluster(prev, item); const clusterEnd = !sameChatCluster(item, next); const showTime = Boolean(item.at) && clusterEnd; return (
{newDay && item.at ? (
) : null}
{item.role === "assistant" ? ( {clusterStart && agent ? ( ) : null} ) : null} {item.role === "user" && showTime && item.at ? ( ) : null} {item.role === "assistant" ? (
) : ( )} {item.role === "assistant" && showTime && item.at ? ( ) : null}
); })} ); }, (prev, next) => ( prev.transcript === next.transcript && prev.questionPrompt === next.questionPrompt && prev.locale === next.locale && prev.agent?.id === next.agent?.id && prev.agent?.name === next.agent?.name && (prev.agent?.avatar_color || "") === (next.agent?.avatar_color || "") && (prev.agent?.avatar_shape || "") === (next.agent?.avatar_shape || "") && (prev.agent?.avatar_url || "") === (next.agent?.avatar_url || "") )); export function App() { const { locale, t, format } = useI18n(); const [pageVisible, setPageVisible] = useState(() => !document.hidden); useEffect(() => { const update = () => setPageVisible(!document.hidden); document.addEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update); }, []); 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 transcriptGeneration = useRef(0); const handleEventRef = useRef<(event: TeamEvent) => void>(() => undefined); const [createMenuOpen, setCreateMenuOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); const [showChannel, setShowChannel] = useState(false); const [channels, setChannels] = useState(() => readChannels()); const [activeChannelId, setActiveChannelId] = useState(null); const [channelMenu, setChannelMenu] = useState<{ x: number; y: number; channel: Channel } | null>(null); 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 [settingsAgent, setSettingsAgent] = useState(null); const [query, setQuery] = useState(""); const accountRef = useRef(null); const createMenuRef = 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) => { const haystack = [ agent.name, agent.title, agent.description, agent.preview, ...(agent.tags || []), ] .filter(Boolean) .join(" ") .toLowerCase(); return haystack.includes(needle); }); }, [agents, query]); const visibleChannels = useMemo(() => { const needle = query.trim().toLowerCase(); const existing = new Set(agents.map((agent) => agent.id)); const live = channels .map((channel) => ({ ...channel, memberIds: channel.memberIds.filter((id) => existing.has(id)) })) .filter((channel) => channel.memberIds.length >= 2); if (!needle) return live; return live.filter((channel) => { if (channel.name.toLowerCase().includes(needle)) return true; return channel.memberIds.some((id) => agents.find((agent) => agent.id === id)?.name.toLowerCase().includes(needle)); }); }, [agents, channels, query]); const activeChannel = visibleChannels.find((channel) => channel.id === activeChannelId) || null; const channelMembers = useMemo(() => { if (!activeChannel) return []; return activeChannel.memberIds.flatMap((id) => { const agent = agents.find((row) => row.id === id); if (!agent) return []; return [roomMember(agent)]; }); }, [activeChannel, agents]); const rosterRequest = useRef | null>(null); const loadAgents = useCallback(() => { if (rosterRequest.current) return rosterRequest.current; const pending = api.agents().then((data) => { const next = data.agents || []; setAgents((cur) => adoptAgents(cur, next)); return next; }).finally(() => { rosterRequest.current = null; }); rosterRequest.current = pending; return pending; }, []); const applyActivity = useCallback((data: AgentActivity) => { setActivityState((cur) => (cur === data.state ? cur : data.state)); setTyping(data.state === "running"); if (data.state !== "running") setWorkingStep((cur) => (cur == null ? cur : null)); if (data.state === "idle") setUserProgress((cur) => (cur ? "" : cur)); 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 applyTranscript = useCallback((items: TranscriptItem[]) => { const next = visibleTranscript(items); setTranscript((cur) => (sameSpoken(cur, next) ? cur : next)); }, []); const transcriptRequest = useRef<{ id: string; controller: AbortController; dirty: boolean } | null>(null); const syncTranscript = useCallback(async function sync(id: string) { if (!id) return; if (transcriptRequest.current?.id === id && !transcriptRequest.current.controller.signal.aborted) { transcriptRequest.current.dirty = true; return; } transcriptRequest.current?.controller.abort(); const pending = { id, controller: new AbortController(), dirty: false }; transcriptRequest.current = pending; const generation = ++transcriptGeneration.current; try { const data = await api.agent(id, pending.controller.signal); if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return; applyTranscript(data.transcript || []); setAgents((cur) => cur.map((row) => { if (row.id !== id && row.name !== id) return row; return patchAgentRow(row, { name: data.name || row.name, preview: data.preview || row.preview, title: data.title ?? row.title, description: data.description ?? row.description, tags: data.tags ?? row.tags, avatar_color: data.avatar_color ?? row.avatar_color, avatar_shape: data.avatar_shape ?? row.avatar_shape, avatar_url: data.avatar_url ?? row.avatar_url, has_avatar: data.has_avatar ?? row.has_avatar, last_at: data.last_at ?? row.last_at, running: data.running ?? row.running, }); }) ); } catch { /* keep the last server snapshot */ } finally { if (transcriptRequest.current === pending) { transcriptRequest.current = null; if (pending.dirty && activeIdRef.current === id && !pending.controller.signal.aborted) void sync(id); } } }, [applyTranscript]); const runComputer = useCallback(async (action: ComputerAction, id?: string) => { const copy = tRef.current; const target = id || activeIdRef.current; if (!target) return; const op = `${target}:${action}`; if (computerOpRef.current === op) return; if (action === "restart" && !window.confirm(copy.restartConfirm)) return; if (action === "update" && !window.confirm(copy.updateConfirm)) return; computerOpRef.current = op; setComputerBusy(action); setComputerReady(false); if (action !== "start") setComputerUrl(""); setComputerStatus(copy[computerBusyKey(action)]); try { const data = await api.computer(target, action); if (activeIdRef.current !== target) return; 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(data.crowded ? copy.computerCrowded : ""); } else { setComputerUrl(""); setImageFresh(false); setComputerStatus(data.error || copy.computerNotReady); } } catch (err) { if (activeIdRef.current !== target) return; setComputerReady(false); setComputerUrl(""); setImageFresh(false); setComputerStatus(err instanceof Error ? err.message : String(err)); } finally { if (computerOpRef.current === op) computerOpRef.current = null; if (activeIdRef.current === target) setComputerBusy(null); } }, []); const ensureComputer = useCallback(async () => { if (!activeIdRef.current) return; await runComputer("start", activeIdRef.current); }, [runComputer]); const openRequest = useRef(null); useEffect(() => () => openRequest.current?.abort(), []); const openAgent = useCallback(async (id: string, keepChannel = false) => { openRequest.current?.abort(); const controller = new AbortController(); openRequest.current = controller; activeIdRef.current = id; activityGeneration.current += 1; transcriptGeneration.current += 1; transcriptRequest.current?.controller.abort(); setTranscript([]); const generation = transcriptGeneration.current; setActivityState("idle"); stickToBottom.current = true; setActiveId(id); if (!keepChannel) setActiveChannelId(null); localStorage.setItem("lazyboy.agent", id); setSidebarOpen(false); setUserProgress(""); setExecutionDetails([]); setError(""); setTyping(false); setComputerReady(false); setComputerUrl(""); setImageFresh(false); setComputerBusy("start"); setComputerStatus(tRef.current.computerStarting); void runComputer("start", id); try { const data = await api.agent(id, controller.signal); if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return; applyTranscript(data.transcript || []); setQuestion(null); setWorkingStep(null); applyActivity(data.activity); } catch (err) { if (controller.signal.aborted || activeIdRef.current !== id) return; setActivityState("unknown"); setTyping(false); setTranscript([]); setError(err instanceof Error ? err.message : String(err)); } }, [applyActivity, applyTranscript, runComputer]); useEffect(() => { if (isPhone()) setRightOpen(false); }, []); useEffect(() => { if (!accountOpen && !accountOverlay && !createMenuOpen && !showChannel) return; const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setAccountOpen(false); setAccountOverlay(null); setCreateMenuOpen(false); setShowChannel(false); }; const onPointer = (event: PointerEvent) => { const target = event.target as Node; if (accountOpen && !accountRef.current?.contains(target)) setAccountOpen(false); if (createMenuOpen && !createMenuRef.current?.contains(target)) setCreateMenuOpen(false); }; window.addEventListener("keydown", onKey); window.addEventListener("pointerdown", onPointer); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("pointerdown", onPointer); }; }, [accountOpen, accountOverlay, createMenuOpen, showChannel]); 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(() => { function onMessage(event: MessageEvent) { if (event.origin !== window.location.origin) return; const data = event.data as { type?: string; text?: string } | null; if (!data || typeof data !== "object") return; const source = event.source as Window | null; if (data.type === "lazyboy-clipboard-write" && typeof data.text === "string") { void navigator.clipboard.writeText(data.text).catch(() => undefined); } if (data.type === "lazyboy-clipboard-read" && source) { void navigator.clipboard .readText() .then((text) => source.postMessage({ type: "lazyboy-clipboard-text", text }, event.origin)) .catch(() => source.postMessage({ type: "lazyboy-clipboard-text", text: "" }, event.origin)); } } window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); useEffect(() => { if (!activeId || !pageVisible) 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 || []) handleEventRef.current(event); if ((payload.events || []).some((event) => event.kind !== "timing")) { void refreshActivity(sourceAgent); } }; source.onopen = () => { void syncTranscript(sourceAgent); void refreshActivity(sourceAgent); }; source.onerror = () => { /* reconnect is normal; activity poll covers gaps */ }; void refreshActivity(sourceAgent); const poll = window.setInterval(() => { void refreshActivity(sourceAgent); }, 2000); const rosterPoll = window.setInterval(() => { loadAgents().catch(() => undefined); }, 4000); return () => { source.close(); transcriptRequest.current?.controller.abort(); window.clearInterval(poll); window.clearInterval(rosterPoll); }; }, [activeId, pageVisible, loadAgents, refreshActivity, syncTranscript]); 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() }]; }); } handleEventRef.current = (event: TeamEvent) => { const p = event.payload || {}; switch (event.kind) { case "reply": setWorkingStep((cur) => (cur == null ? cur : null)); void syncTranscript(activeIdRef.current); 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(); const nextChannels = channels .map((channel) => ({ ...channel, memberIds: channel.memberIds.filter((id) => id !== agent.id) })) .filter((channel) => channel.memberIds.length >= 2); setChannels(nextChannels); writeChannels(nextChannels); if (activeChannelId && !nextChannels.some((channel) => channel.id === activeChannelId)) setActiveChannelId(null); 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 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) { sendingRef.current = false; setCreateOpen(true); return; } 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 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)] : computerReady ? imageFresh ? t.computerUpToDate : t.computerRunning : computerStatus || t.computerOff; const frame = !pageVisible ? null : computerUrl ? (