LazyBoy2/web/src/App.tsx

959 lines
37 KiB
TypeScript
Raw Normal View History

2026-09-14 09:08:20 +00:00
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
2026-09-15 03:20:42 +00:00
import arrowUp from "react-useanimations/lib/arrowUp";
import menu from "react-useanimations/lib/menu";
import searchToX from "react-useanimations/lib/searchToX";
2026-09-15 05:20:44 +00:00
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";
2026-09-15 03:20:42 +00:00
import { Avatar, AvatarLookProvider, readAvatarLooks } from "./avatar";
2026-09-15 05:20:44 +00:00
import { useI18n, type MessageKey } from "./i18n";
2026-09-15 03:20:42 +00:00
import { MarkdownBody } from "./markdown";
2026-09-15 05:20:44 +00:00
import { AboutDialog, SettingsDialog } from "./settings";
2026-09-15 03:20:42 +00:00
import { formatClock, formatDay, sameLocalDay } from "./time";
import UseAnimations from "./use-animations";
2026-09-14 09:08:20 +00:00
2026-09-15 03:20:42 +00:00
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";
2026-09-14 09:08:20 +00:00
2026-09-15 05:20:44 +00:00
type QuestionCard = Omit<HumanQuestion, "options"> & { prompt: string; options: string[] };
2026-09-15 03:20:42 +00:00
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();
}
2026-09-15 05:20:44 +00:00
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]);
}
2026-09-15 03:20:42 +00:00
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;
}
2026-09-14 09:08:20 +00:00
export function App() {
2026-09-15 05:20:44 +00:00
const { locale, t, format } = useI18n();
2026-09-14 09:08:20 +00:00
const [agents, setAgents] = useState<AgentRow[]>([]);
2026-09-15 05:20:44 +00:00
const [activeId, setActiveId] = useState(localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || "");
2026-09-14 09:08:20 +00:00
const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
2026-09-15 03:20:42 +00:00
const [workingStep, setWorkingStep] = useState<StepKey | null>(null);
2026-09-15 05:20:44 +00:00
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);
2026-09-15 03:20:42 +00:00
const [error, setError] = useState("");
2026-09-14 09:08:20 +00:00
const [draft, setDraft] = useState("");
const [sidebarOpen, setSidebarOpen] = useState(false);
2026-09-15 03:20:42 +00:00
const [rightOpen, setRightOpen] = useState(true);
const [overlayOpen, setOverlayOpen] = useState(false);
2026-09-14 09:08:20 +00:00
const [computerUrl, setComputerUrl] = useState("");
const [computerStatus, setComputerStatus] = useState("");
2026-09-15 03:20:42 +00:00
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;
2026-09-14 09:08:20 +00:00
const [typing, setTyping] = useState(false);
2026-09-15 03:20:42 +00:00
const [activityState, setActivityState] = useState<AgentActivity["state"] | "unknown">("idle");
const activityGeneration = useRef(0);
const activityPending = useRef<string | null>(null);
2026-09-14 09:08:20 +00:00
const [showCreate, setShowCreate] = useState(false);
2026-09-15 03:20:42 +00:00
const [accountOpen, setAccountOpen] = useState(false);
2026-09-15 05:20:44 +00:00
const [accountOverlay, setAccountOverlay] = useState<"settings" | "about" | null>(null);
2026-09-15 03:20:42 +00:00
const [agentMenu, setAgentMenu] = useState<{ x: number; y: number; agent: AgentRow } | null>(null);
2026-09-14 09:08:20 +00:00
const [newName, setNewName] = useState("");
const [query, setQuery] = useState("");
2026-09-15 03:20:42 +00:00
const accountRef = useRef<HTMLDivElement>(null);
2026-09-14 09:08:20 +00:00
const scroller = useRef<HTMLDivElement>(null);
2026-09-15 05:20:44 +00:00
const stickToBottom = useRef(true);
2026-09-15 03:20:42 +00:00
const composerRef = useRef<HTMLTextAreaElement>(null);
const sendingRef = useRef(false);
2026-09-14 09:08:20 +00:00
const activeIdRef = useRef(activeId);
activeIdRef.current = activeId;
2026-09-15 03:20:42 +00:00
const [looks] = useState(() => readAvatarLooks());
2026-09-14 09:08:20 +00:00
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 || [];
}, []);
2026-09-15 03:20:42 +00:00
const applyActivity = useCallback((data: AgentActivity) => {
setActivityState(data.state);
setTyping(data.state === "running");
if (data.state !== "running") setWorkingStep(null);
2026-09-15 05:20:44 +00:00
if (data.state === "idle") setUserProgress("");
2026-09-15 03:20:42 +00:00
const q = data.question;
2026-09-15 05:20:44 +00:00
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));
2026-09-15 03:20:42 +00:00
}, []);
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]);
2026-09-14 09:08:20 +00:00
const openAgent = useCallback(async (id: string) => {
2026-09-15 03:20:42 +00:00
activeIdRef.current = id;
activityGeneration.current += 1;
setActivityState("idle");
2026-09-15 05:20:44 +00:00
stickToBottom.current = true;
2026-09-14 09:08:20 +00:00
setActiveId(id);
2026-09-15 05:20:44 +00:00
localStorage.setItem("lazyboy.agent", id);
2026-09-14 09:08:20 +00:00
setSidebarOpen(false);
2026-09-15 05:20:44 +00:00
setUserProgress("");
setExecutionDetails([]);
2026-09-15 03:20:42 +00:00
setError("");
2026-09-14 09:08:20 +00:00
setTyping(false);
try {
const data = await api.agent(id);
2026-09-15 03:20:42 +00:00
if (activeIdRef.current !== id) return;
setTranscript(visibleTranscript(data.transcript || []));
2026-09-14 09:08:20 +00:00
setQuestion(null);
2026-09-15 03:20:42 +00:00
setWorkingStep(null);
applyActivity(data.activity);
2026-09-14 09:08:20 +00:00
} catch (err) {
2026-09-15 03:20:42 +00:00
if (activeIdRef.current !== id) return;
setActivityState("unknown");
setTyping(false);
2026-09-14 09:08:20 +00:00
setTranscript([]);
2026-09-15 03:20:42 +00:00
setError(err instanceof Error ? err.message : String(err));
2026-09-14 09:08:20 +00:00
}
2026-09-15 03:20:42 +00:00
}, [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);
2026-09-14 09:08:20 +00:00
}, []);
2026-09-15 03:20:42 +00:00
useEffect(() => {
2026-09-15 05:20:44 +00:00
if (!accountOpen && !accountOverlay) return;
2026-09-15 03:20:42 +00:00
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setAccountOpen(false);
2026-09-15 05:20:44 +00:00
setAccountOverlay(null);
2026-09-15 03:20:42 +00:00
};
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);
};
2026-09-15 05:20:44 +00:00
}, [accountOpen, accountOverlay]);
2026-09-15 03:20:42 +00:00
2026-09-14 09:08:20 +00:00
useEffect(() => {
loadAgents()
.then((list) => {
2026-09-15 05:20:44 +00:00
const remembered = localStorage.getItem("lazyboy.agent");
2026-09-14 09:08:20 +00:00
const pick = list.find((a) => a.id === remembered || a.name === remembered) || list[0];
if (pick) return openAgent(pick.id);
})
2026-09-15 03:20:42 +00:00
.catch((err) => setError(String(err.message)));
2026-09-14 09:08:20 +00:00
}, [loadAgents, openAgent]);
2026-09-15 05:20:44 +00:00
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));
}
}
2026-09-15 03:20:42 +00:00
useEffect(() => {
if (rightOpen || overlayOpen) void ensureComputer();
}, [rightOpen, overlayOpen, ensureComputer]);
2026-09-14 09:08:20 +00:00
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);
2026-09-15 03:20:42 +00:00
if ((payload.events || []).some((event) => event.kind !== "timing")) {
void refreshActivity(sourceAgent);
}
2026-09-14 09:08:20 +00:00
};
2026-09-15 03:20:42 +00:00
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);
2026-09-14 09:08:20 +00:00
return () => {
source.close();
window.clearInterval(poll);
2026-09-15 03:20:42 +00:00
window.clearInterval(rosterPoll);
2026-09-14 09:08:20 +00:00
};
2026-09-15 03:20:42 +00:00
}, [activeId, loadAgents, refreshActivity]);
2026-09-14 09:08:20 +00:00
useEffect(() => {
2026-09-15 05:20:44 +00:00
const el = scroller.current;
if (!el || !stickToBottom.current) return;
el.scrollTo(0, el.scrollHeight);
}, [transcript, question]);
2026-09-15 03:20:42 +00:00
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() }];
});
}
2026-09-14 09:08:20 +00:00
function handleEvent(event: TeamEvent) {
const p = event.payload || {};
switch (event.kind) {
case "reply":
2026-09-15 03:20:42 +00:00
if (typeof p.message === "string") appendAssistant(p.message);
setWorkingStep(null);
2026-09-14 09:08:20 +00:00
void loadAgents();
break;
case "runtime": {
const type = String(p.type || "");
2026-09-15 05:20:44 +00:00
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;
}
2026-09-14 09:08:20 +00:00
if (type === "message" && typeof p.content === "string") {
2026-09-15 03:20:42 +00:00
appendAssistant(p.content);
2026-09-14 09:08:20 +00:00
} else if (type === "tool_started") {
2026-09-15 03:20:42 +00:00
const key = stepKey(String(p.name || ""));
if (key) setWorkingStep(key);
2026-09-14 09:08:20 +00:00
} else if (type === "progress" || type === "status") {
2026-09-15 03:20:42 +00:00
const key = stepFromProgress(String(p.message || ""));
if (key) setWorkingStep(key);
} else if (type === "waiting") {
setWorkingStep("thinking");
2026-09-14 09:08:20 +00:00
} else if (type === "question") {
2026-09-15 05:20:44 +00:00
const q = (p.question || {}) as HumanQuestion;
2026-09-15 03:20:42 +00:00
setWorkingStep(null);
2026-09-14 09:08:20 +00:00
setQuestion({
2026-09-15 05:20:44 +00:00
...q,
task_id: q.task_id || event.task_id || undefined,
2026-09-15 03:20:42 +00:00
prompt: q.question || q.reason || tRef.current.needsReply,
2026-09-15 05:20:44 +00:00
options: (q.options || []).map(choiceLabel).filter(Boolean),
2026-09-14 09:08:20 +00:00
});
}
break;
}
case "error":
2026-09-15 03:20:42 +00:00
setWorkingStep(null);
2026-09-14 09:08:20 +00:00
break;
default:
break;
}
}
2026-09-15 03:20:42 +00:00
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("");
2026-09-15 05:20:44 +00:00
localStorage.removeItem("lazyboy.agent");
2026-09-15 03:20:42 +00:00
setTranscript([]);
setQuestion(null);
setTyping(false);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
2026-09-14 09:08:20 +00:00
}
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) {
2026-09-15 03:20:42 +00:00
setError(err instanceof Error ? err.message : String(err));
2026-09-14 09:08:20 +00:00
}
}
async function onSend(text?: string, ev?: FormEvent) {
ev?.preventDefault();
2026-09-15 03:20:42 +00:00
if (sendingRef.current) return;
const fromComposer = text == null;
if (fromComposer && (typing || activityState === "queued")) return;
2026-09-14 09:08:20 +00:00
const value = (text ?? draft).trim();
if (!value) return;
2026-09-15 03:20:42 +00:00
sendingRef.current = true;
2026-09-14 09:08:20 +00:00
let id = activeId;
try {
2026-09-15 03:20:42 +00:00
if (!id) {
const created = await api.createAgent(`agent_${Date.now().toString(36)}`);
id = created.id;
await loadAgents();
setActiveId(id);
2026-09-15 05:20:44 +00:00
localStorage.setItem("lazyboy.agent", id);
2026-09-15 03:20:42 +00:00
}
if (fromComposer) setDraft("");
2026-09-15 05:20:44 +00:00
stickToBottom.current = true;
2026-09-15 03:20:42 +00:00
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("");
2026-09-15 05:20:44 +00:00
setUserProgress("");
setExecutionDetails([]);
2026-09-15 03:20:42 +00:00
setQuestion(null);
setActivityState("queued");
setTyping(false);
setWorkingStep(null);
2026-09-14 09:08:20 +00:00
await api.send(id, value);
2026-09-15 03:20:42 +00:00
void refreshActivity(id);
2026-09-14 09:08:20 +00:00
} catch (err) {
2026-09-15 03:20:42 +00:00
setActivityState("unknown");
2026-09-14 09:08:20 +00:00
setTyping(false);
2026-09-15 03:20:42 +00:00
setWorkingStep(null);
if (id) void refreshActivity(id);
setError(err instanceof Error ? err.message : String(err));
} finally {
sendingRef.current = false;
2026-09-14 09:08:20 +00:00
}
}
async function onStop() {
const id = activeId;
if (!id) return;
try {
await api.stop(id);
2026-09-15 03:20:42 +00:00
activityGeneration.current += 1;
await refreshActivity(id);
2026-09-14 09:08:20 +00:00
} catch (err) {
2026-09-15 03:20:42 +00:00
setError(err instanceof Error ? err.message : String(err));
2026-09-14 09:08:20 +00:00
}
}
2026-09-15 03:20:42 +00:00
function openComputer() {
if (isPhone()) {
setOverlayOpen(true);
setRightOpen(false);
} else {
setRightOpen(true);
setOverlayOpen(false);
2026-09-14 09:08:20 +00:00
}
2026-09-15 03:20:42 +00:00
}
function toggleComputer() {
if (isPhone() || overlayOpen) {
setOverlayOpen((open) => !open);
return;
2026-09-14 09:08:20 +00:00
}
2026-09-15 03:20:42 +00:00
setRightOpen((open) => !open);
2026-09-14 09:08:20 +00:00
}
const hasPayload = draft.trim().length > 0;
2026-09-15 03:20:42 +00:00
const working = typing || activityState === "queued";
2026-09-15 05:20:44 +00:00
const showStage = rightOpen && !isPhone() && !handover;
const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null);
2026-09-15 03:20:42 +00:00
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;
2026-09-14 09:08:20 +00:00
return (
2026-09-15 03:20:42 +00:00
<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="" />
2026-09-15 05:20:44 +00:00
<span>LazyBoy</span>
2026-09-15 03:20:42 +00:00
<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>
2026-09-14 09:08:20 +00:00
{showCreate ? (
2026-09-15 03:20:42 +00:00
<form className="create-inline" onSubmit={(e) => void onCreate(e)}>
<input autoFocus value={newName} placeholder={t.agentNamePlaceholder} onChange={(e) => setNewName(e.target.value)} />
2026-09-14 09:08:20 +00:00
</form>
) : null}
2026-09-15 03:20:42 +00:00
<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}
2026-09-14 09:08:20 +00:00
{visibleAgents.map((agent) => (
2026-09-15 03:20:42 +00:00
<div
2026-09-14 09:08:20 +00:00
key={agent.id}
2026-09-15 03:20:42 +00:00
role="button"
tabIndex={0}
className={`bot-row ${agent.id === active?.id ? "selected" : ""}`}
2026-09-14 09:08:20 +00:00
onClick={() => void openAgent(agent.id)}
2026-09-15 03:20:42 +00:00
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 });
}}
2026-09-14 09:08:20 +00:00
>
2026-09-15 03:20:42 +00:00
<span className="avatar-wrap">
<Avatar lookId={agent.id} name={agent.name} active={agent.id === active?.id} online thinking={Boolean(agent.running)} />
2026-09-14 09:08:20 +00:00
</span>
2026-09-15 03:20:42 +00:00
<span className="bot-copy">
<strong>{agent.name}</strong>
<small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small>
2026-09-14 09:08:20 +00:00
</span>
2026-09-15 03:20:42 +00:00
<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>
2026-09-14 09:08:20 +00:00
))}
2026-09-15 03:20:42 +00:00
</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);
2026-09-15 05:20:44 +00:00
setAccountOverlay("settings");
2026-09-15 03:20:42 +00:00
}}
>
<Settings />
{t.settings}
</button>
2026-09-15 05:20:44 +00:00
<button
type="button"
role="menuitem"
onClick={() => {
setAccountOpen(false);
setAccountOverlay("about");
}}
>
<Info />
{t.about}
</button>
2026-09-15 03:20:42 +00:00
</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" />
2026-09-14 09:08:20 +00:00
</button>
</div>
</div>
</aside>
2026-09-15 03:20:42 +00:00
<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}>
2026-09-14 09:08:20 +00:00
{working ? (
2026-09-15 03:20:42 +00:00
<button type="button" className="top-tool-button" title={t.stop} aria-label={t.stop} onClick={() => void onStop()}>
<Square />
</button>
2026-09-14 09:08:20 +00:00
) : null}
2026-09-15 03:20:42 +00:00
<button
type="button"
className={`top-tool-button ${showStage || overlayOpen ? "active" : ""}`}
title={t.computer}
aria-label={t.computer}
onClick={toggleComputer}
>
<Computer />
</button>
</nav>
</header>
2026-09-14 09:08:20 +00:00
2026-09-15 05:20:44 +00:00
<div
className="messages"
ref={scroller}
onScroll={() => {
const el = scroller.current;
if (!el) return;
stickToBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 96;
}}
>
2026-09-15 03:20:42 +00:00
{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}
2026-09-15 05:20:44 +00:00
{visibleTranscript(transcript).filter((item) => !(question && item.role === "assistant" && item.content.trim() === question.prompt.trim())).map((item, i, items) => (
2026-09-15 03:20:42 +00:00
<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>
2026-09-14 09:08:20 +00:00
</div>
))}
</div>
2026-09-15 03:20:42 +00:00
<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>
2026-09-14 09:08:20 +00:00
</div>
2026-09-15 03:20:42 +00:00
) : null}
{error ? <div className="thinking-row" role="alert">{error}</div> : null}
{activityState === "unknown" ? <div className="thinking-row" role="status">{t.activityUnknown}</div> : null}
2026-09-15 05:20:44 +00:00
{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 ? (
2026-09-15 03:20:42 +00:00
<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>
))}
2026-09-14 09:08:20 +00:00
</div>
</div>
2026-09-15 03:20:42 +00:00
) : null}
2026-09-15 05:20:44 +00:00
{executionDetails.length ? <details className="execution-details"><summary>{t.executionDetails}</summary><pre>{executionDetails.join("\n")}</pre></details> : null}
2026-09-15 03:20:42 +00:00
<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}
2026-09-15 05:20:44 +00:00
placeholder={handover ? t.handoverHint : active ? format("messageTo", { name: active.name }) : t.pickAgentFirst}
disabled={!active || handover}
2026-09-15 03:20:42 +00:00
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>
)}
2026-09-14 09:08:20 +00:00
</form>
</div>
</main>
2026-09-15 03:20:42 +00:00
<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>
2026-09-15 05:20:44 +00:00
<div className="preview">{!handover && !overlayOpen ? frame : null}{hud}</div>
2026-09-15 03:20:42 +00:00
<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>
2026-09-15 05:20:44 +00:00
<button type="button" className="icon-button" aria-label={t.close} onClick={() => { setOverlayOpen(false); if (!isPhone() && !handover) setRightOpen(true); }}>
2026-09-15 03:20:42 +00:00
<X />
</button>
</div>
2026-09-14 09:08:20 +00:00
</header>
2026-09-15 03:20:42 +00:00
<div className="overlay-screen">
<div className="overlay-desktop">{frame}{hud}</div>
2026-09-14 09:08:20 +00:00
</div>
</div>
2026-09-15 03:20:42 +00:00
) : null}
2026-09-14 09:08:20 +00:00
2026-09-15 03:20:42 +00:00
<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>
2026-09-14 09:08:20 +00:00
</nav>
2026-09-15 03:20:42 +00:00
{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}
2026-09-15 05:20:44 +00:00
{accountOverlay === "settings" ? (
<SettingsDialog
onClose={() => setAccountOverlay(null)}
computer={{
pending: computerBusy,
working,
upToDate: imageFresh,
onUpdate: () => void runComputer("update"),
onRestart: () => void runComputer("restart"),
}}
/>
2026-09-15 03:20:42 +00:00
) : null}
2026-09-15 05:20:44 +00:00
{accountOverlay === "about" ? <AboutDialog onClose={() => setAccountOverlay(null)} /> : null}
2026-09-14 09:08:20 +00:00
</div>
2026-09-15 03:20:42 +00:00
</AvatarLookProvider>
2026-09-14 09:08:20 +00:00
);
}