LazyBoy2/web/src/App.tsx

959 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
);
}
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("<system_reminder>")) return true;
return false;
}
type StepKey =
| "stepComputer"
| "stepSearch"
| "stepFetch"
| "stepBrowser"
| "stepBox"
| "stepShell"
| "stepWrite"
| "stepRead"
| "stepWorking"
| "thinking";
type QuestionCard = Omit<HumanQuestion, "options"> & { 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<AgentRow[]>([]);
const [activeId, setActiveId] = useState(localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || "");
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
const [workingStep, setWorkingStep] = useState<StepKey | null>(null);
const [question, setQuestion] = useState<QuestionCard | null>(null);
const [userProgress, setUserProgress] = useState("");
const [executionDetails, setExecutionDetails] = useState<string[]>([]);
const [returningControl, setReturningControl] = useState(false);
const handoverSeen = useRef<string | null>(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<ComputerAction | null>(null);
const [imageFresh, setImageFresh] = useState(false);
const [computerGen, setComputerGen] = useState(0);
const computerOpRef = useRef<ComputerAction | null>(null);
const tRef = useRef(t);
tRef.current = t;
const [typing, setTyping] = useState(false);
const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle");
const activityGeneration = useRef(0);
const activityPending = useRef<string | null>(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<HTMLDivElement>(null);
const scroller = useRef<HTMLDivElement>(null);
const stickToBottom = useRef(true);
const composerRef = useRef<HTMLTextAreaElement>(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 ? (
<iframe
key={computerGen}
className="desktop-frame"
title={active ? format("computerOf", { name: active.name }) : t.computer}
src={computerUrl}
allow="clipboard-read; clipboard-write; fullscreen"
/>
) : (
<div className={`empty-computer ${computerBusy ? "is-waiting" : ""}`}>
<Computer />
<span>{computerStatus || t.computerNotStarted}</span>
</div>
);
const hud = computerBusy ? (
<div className="computer-hud">
<span className="computer-signal" aria-hidden="true">
<span className="computer-signal-face"><i /><i /></span>
</span>
<span className="computer-hud-label">{t[computerBusyKey(computerBusy)]}</span>
</div>
) : null;
return (
<AvatarLookProvider value={looks}>
<div className={`app-shell ${showStage ? "right-open stage-open" : "right-collapsed"}`}>
{sidebarOpen ? <button type="button" className="mobile-nav-backdrop" aria-label={t.close} onClick={() => setSidebarOpen(false)} /> : null}
<aside id="conversation-sidebar" className={`sidebar ${sidebarOpen ? "open" : ""}`}>
<div className="brand">
<img className="brand-icon" src="/lazyboy-icon.png" alt="" />
<span>LazyBoy</span>
<button type="button" className="icon-button mobile-nav-close" aria-label={t.close} onClick={() => setSidebarOpen(false)}>
<X />
</button>
<button type="button" className="icon-button" aria-label={t.addAgent} title={t.addAgent} onClick={() => setShowCreate((open) => !open)}>
<Plus />
</button>
</div>
<label className="search">
<UseAnimations animation={searchToX} size={16} strokeColor="var(--muted)" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t.search} aria-label={t.search} />
</label>
{showCreate ? (
<form className="create-inline" onSubmit={(e) => void onCreate(e)}>
<input autoFocus value={newName} placeholder={t.agentNamePlaceholder} onChange={(e) => setNewName(e.target.value)} />
</form>
) : null}
<div className="bot-list">
<section className="bot-group">
<div className="group-label">{t.agents}</div>
{visibleAgents.length === 0 ? <div className="group-label">{agents.length === 0 ? t.noAgents : t.noMatchingAgents}</div> : null}
{visibleAgents.map((agent) => (
<div
key={agent.id}
role="button"
tabIndex={0}
className={`bot-row ${agent.id === active?.id ? "selected" : ""}`}
onClick={() => void openAgent(agent.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
void openAgent(agent.id);
}}
onContextMenu={(event) => {
event.preventDefault();
setAgentMenu({ x: event.clientX, y: event.clientY, agent });
}}
>
<span className="avatar-wrap">
<Avatar lookId={agent.id} name={agent.name} active={agent.id === active?.id} online thinking={Boolean(agent.running)} />
</span>
<span className="bot-copy">
<strong>{agent.name}</strong>
<small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small>
</span>
<button
type="button"
className="row-delete"
title={t.deleteAgent}
aria-label={t.deleteAgent}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onDeleteAgent(agent);
}}
>
<TrashIcon />
</button>
</div>
))}
</section>
</div>
<div className="sidebar-bottom">
<div className="account-wrap" ref={accountRef}>
{accountOpen ? (
<div className="account-menu" role="menu">
<button
type="button"
role="menuitem"
onClick={() => {
setAccountOpen(false);
setAccountOverlay("settings");
}}
>
<Settings />
{t.settings}
</button>
<button
type="button"
role="menuitem"
onClick={() => {
setAccountOpen(false);
setAccountOverlay("about");
}}
>
<Info />
{t.about}
</button>
</div>
) : null}
<button
type="button"
className={`account ${accountOpen ? "open" : ""}`}
title={t.localWorkspace}
aria-haspopup="menu"
aria-expanded={accountOpen}
onClick={() => setAccountOpen((open) => !open)}
>
<span className="workspace-avatar">GB</span>
<span>{t.localWorkspace}</span>
<ChevronDown className="chevron" />
</button>
</div>
</div>
</aside>
<main className="chat-panel">
<header className="topbar">
<button type="button" className="icon-button mobile-menu" aria-label={t.pickAgent} onClick={() => setSidebarOpen((open) => !open)}>
<UseAnimations animation={menu} size={18} strokeColor="var(--ink)" />
</button>
{active ? (
<>
<Avatar lookId={active.id} name={active.name} active online thinking={working} size={32} />
<strong id="sand-conversation-heading">{active.name}</strong>
</>
) : (
<strong>{t.pickAgent}</strong>
)}
<span className="grow" />
<nav className="top-tools" aria-label={t.workTools}>
{working ? (
<button type="button" className="top-tool-button" title={t.stop} aria-label={t.stop} onClick={() => void onStop()}>
<Square />
</button>
) : null}
<button
type="button"
className={`top-tool-button ${showStage || overlayOpen ? "active" : ""}`}
title={t.computer}
aria-label={t.computer}
onClick={toggleComputer}
>
<Computer />
</button>
</nav>
</header>
<div
className="messages"
ref={scroller}
onScroll={() => {
const el = scroller.current;
if (!el) return;
stickToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
}}
>
{active && transcript.length === 0 && !typing ? (
<div className="welcome">
<Avatar lookId={active.id} name={active.name} active online size={64} />
<h1>{format("startWorkWith", { name: active.name })}</h1>
<p>{t.startWorkHint}</p>
</div>
) : null}
{visibleTranscript(transcript).filter((item) => !(question && item.role === "assistant" && item.content.trim() === question.prompt.trim())).map((item, i, items) => (
<div key={`${item.role}-${item.at || i}-${i}`}>
{item.at && !sameLocalDay(item.at, items[i - 1]?.at) ? (
<div className="day-divider">
<time dateTime={item.at}>{formatDay(item.at, locale)}</time>
</div>
) : null}
<div className={`message ${item.role}`}>
{item.role === "assistant" ? (
<div className="message-stack">
<MarkdownBody content={item.content} />
</div>
) : (
<MarkdownBody content={item.content} />
)}
{item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
</div>
</div>
))}
</div>
<div className={`composer-dock ${working ? "has-status" : ""}`}>
{working && active ? (
<div className="thinking-row">
<Avatar lookId={active.id} name={active.name} thinking={typing} online />
<span className="working-copy">
<span className="working-label">{activityState === "queued" ? t.queuedWork : format("workingWith", { name: active.name })}</span>
{liveStep ? <span className="working-step">{liveStep}</span> : null}
</span>
</div>
) : null}
{error ? <div className="thinking-row" role="alert">{error}</div> : null}
{activityState === "unknown" ? <div className="thinking-row" role="status">{t.activityUnknown}</div> : null}
{handover && question ? (
<section className="question-card handover-card" aria-label={t.handoverTitle}>
<strong>{t.handoverTitle}</strong>
<p>{question.prompt}</p>
{question.site_url ? <p className="handover-site">{question.site_url}</p> : null}
<p>{t.handoverHint}</p>
{question.surface === "local_browser" ? <p>{t.handoverLocal}</p> : !overlayOpen ? <div className="handover-viewer">{frame}</div> : null}
{computerStatus && !computerReady ? <p role="status">{computerStatus}</p> : null}
<div className="question-card__options">
{question.surface !== "local_browser" ? <button type="button" onClick={() => { void ensureComputer(); setOverlayOpen(true); }}>{t.enlarge}</button> : null}
{question.task_id && question.question_id
? <button type="button" disabled={returningControl || question.submitted} onClick={() => void returnControl()}>{returningControl || question.submitted ? t.handoverReturning : t.handoverDone}</button>
: <button type="button" onClick={() => void onSend(question.options[0] || t.handoverDone)}>{t.handoverDone}</button>}
<button type="button" disabled={returningControl || question.submitted} onClick={() => question.task_id && question.question_id ? void returnControl(true) : void onStop()}>{t.stop}</button>
</div>
</section>
) : question ? (
<div className="question-card">
<p>{question.prompt}</p>
<div className="question-card__options">
{question.options.map((option) => (
<button key={option} type="button" onClick={() => void onSend(option)}>{option}</button>
))}
</div>
</div>
) : null}
{executionDetails.length ? <details className="execution-details"><summary>{t.executionDetails}</summary><pre>{executionDetails.join("\n")}</pre></details> : null}
<form className="composer" onSubmit={(e) => void onSend(undefined, e)}>
<button type="button" className="composer-plus" disabled title={t.moreActions} aria-label={t.moreActions}>
<Plus />
</button>
<textarea
ref={composerRef}
rows={1}
value={draft}
placeholder={handover ? t.handoverHint : active ? format("messageTo", { name: active.name }) : t.pickAgentFirst}
disabled={!active || handover}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return;
if (e.key === "Enter" && !e.shiftKey && !isPhone()) {
e.preventDefault();
if (sendingRef.current || working) return;
e.currentTarget.form?.requestSubmit();
}
}}
/>
{working ? (
<button type="button" className="send stop-send" title={t.stop} aria-label={t.stop} onClick={() => void onStop()}>
<Square />
</button>
) : (
<button className="send" disabled={!active || !hasPayload} title={t.send} aria-label={t.send}>
<UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)" />
</button>
)}
</form>
</div>
</main>
<section className={`meeting-stage ${showStage ? "" : "is-hidden"}`}>
<header className="meeting-stage-head">
<span className="side-card-title">{t.computer}</span>
<button type="button" className="icon-button" title={t.collapse} onClick={() => setRightOpen(false)}>
<ChevronsRight />
</button>
</header>
<div className="side-card-body">
<div className="computer-part">
<div className="computer-status-row">
<span>{active ? format("computerOf", { name: active.name }) : t.computer}</span>
<i className={`state-dot ${computerBusy ? "booting" : computerReady ? "running" : "booting"}`} />
<small>{computerLive}</small>
</div>
<div className="preview">{!handover && !overlayOpen ? frame : null}{hud}</div>
<div className="computer-caption">
<button type="button" className="outline" onClick={() => setOverlayOpen(true)}>{t.enlarge}</button>
</div>
<p className="computer-login-hint">{t.loginHint}</p>
</div>
</div>
</section>
{overlayOpen ? (
<div className="computer-overlay">
<header>
<div>
{active ? <Avatar lookId={active.id} name={active.name} active online thinking={working} size={30} /> : null}
<strong>{active ? format("computerOf", { name: active.name }) : t.computer}</strong>
<span className={`control-badge ${computerReady && !computerBusy ? "" : "is-off"}`}>{computerLive}</span>
</div>
<div>
<button type="button" className="icon-button" aria-label={t.close} onClick={() => { setOverlayOpen(false); if (!isPhone() && !handover) setRightOpen(true); }}>
<X />
</button>
</div>
</header>
<div className="overlay-screen">
<div className="overlay-desktop">{frame}{hud}</div>
</div>
</div>
) : null}
<nav className="phone-tabs" aria-label={t.mainMenu}>
<button type="button" aria-current={!overlayOpen} onClick={() => { setOverlayOpen(false); setSidebarOpen(true); }}>{t.chat}</button>
<button type="button" aria-current={overlayOpen} onClick={openComputer}>{t.computer}</button>
</nav>
{agentMenu ? (
<>
<button type="button" className="context-backdrop" aria-label={t.close} onClick={() => setAgentMenu(null)} />
<div
className="context-menu"
role="menu"
style={{
left: Math.min(agentMenu.x, window.innerWidth - 220),
top: Math.min(agentMenu.y, window.innerHeight - 80),
}}
>
<button
type="button"
role="menuitem"
className="danger-item"
onClick={() => void onDeleteAgent(agentMenu.agent)}
>
<TrashIcon />
{t.deleteAgent}
</button>
</div>
</>
) : null}
{accountOverlay === "settings" ? (
<SettingsDialog
onClose={() => setAccountOverlay(null)}
computer={{
pending: computerBusy,
working,
upToDate: imageFresh,
onUpdate: () => void runComputer("update"),
onRestart: () => void runComputer("restart"),
}}
/>
) : null}
{accountOverlay === "about" ? <AboutDialog onClose={() => setAccountOverlay(null)} /> : null}
</div>
</AvatarLookProvider>
);
}