LazyBoy2/web/src/App.tsx

1531 lines
62 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, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
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 (
<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), (max-width: 1100px) and (pointer: coarse)").matches;
}
function avatarProps(agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url">) {
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<string[]>([]);
const host = agents.find((agent) => agent.id === selected[0]);
return (
<div className="modal-backdrop" onClick={onClose}>
<form
className="dialog"
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => {
event.preventDefault();
const groupName = name.trim();
if (!groupName || selected.length < 2) return;
onCreate(groupName, selected);
}}
>
<div className="dialog-title">
<h2>{t.addChannel}</h2>
<button type="button" aria-label={t.close} onClick={onClose}><X /></button>
</div>
<p className="dialog-lead">{t.channelDescription}</p>
<label>
{t.channelName}
<input autoFocus value={name} maxLength={30} onChange={(event) => setName(event.target.value)} placeholder={t.channelNamePlaceholder} />
</label>
<fieldset className="group-picker">
<legend>{t.chooseAgents}</legend>
{agents.map((agent) => (
<label key={agent.id}>
<input
type="checkbox"
checked={selected.includes(agent.id)}
onChange={() => setSelected((ids) => ids.includes(agent.id) ? ids.filter((id) => id !== agent.id) : [...ids, agent.id])}
/>
<Avatar {...avatarProps(agent)} size={28} gaze={false} />
<span>{agent.name}</span>
</label>
))}
</fieldset>
{host ? <p className="dialog-note">{format("channelHostHint", { name: host.name })}</p> : null}
<div className="dialog-actions">
<button type="button" className="outline" onClick={onClose}>{t.cancel}</button>
<button className="primary" disabled={!name.trim() || selected.length < 2}>{t.createChannel}</button>
</div>
</form>
</div>
);
}
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 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>): 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 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 19V5m-6 6 6-6 6 6" /></svg>;
});
const MessageLog = memo(function MessageLog({
transcript,
questionPrompt,
locale,
agent,
scrollRef,
}: {
scrollRef: React.RefObject<HTMLDivElement | null>;
transcript: TranscriptItem[];
questionPrompt: string | null;
locale: Locale;
agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url"> | null;
}) {
const items = useMemo(() => transcript
.map((item, index) => ({ item, index }))
.filter(({ item }) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt)), [transcript, questionPrompt]);
const rows = useVirtualizer({
count: items.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 140,
overscan: 6,
getItemKey: useCallback((i: number) => items[i].index, [items]),
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: 96,
});
const initialized = useRef(false);
useLayoutEffect(() => {
if (!initialized.current && items.length) {
initialized.current = true;
rows.scrollToEnd();
}
}, [items.length, rows]);
return (
<div className="message-window" style={{ height: rows.getTotalSize(), position: "relative", width: "100%" }}>
{rows.getVirtualItems().map((row) => {
const { item, index } = items[row.index];
const prev = items[row.index - 1]?.item;
const next = items[row.index + 1]?.item;
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 (
<div key={row.key} data-index={row.index} ref={rows.measureElement}
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${row.start}px)`, paddingBottom: 10 }}>
<div data-message-index={index}
className={`message-block${!clusterStart ? " tight" : ""}${newDay ? " has-day" : ""}`}
key={`${item.role}-${item.at || index}-${index}`}
>
{newDay && item.at ? (
<div className="day-divider">
<time dateTime={item.at}>{formatDay(item.at, locale)}</time>
</div>
) : null}
<div className={`message ${item.role}${clusterStart ? " cluster-start" : " cluster-follow"}${clusterEnd ? " cluster-end" : ""}`}>
{item.role === "assistant" ? (
<span className="msg-avatar">
{clusterStart && agent ? (
<Avatar {...avatarProps(agent)} size={28} gaze={false} />
) : null}
</span>
) : null}
{item.role === "user" && showTime && item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
{item.role === "assistant" ? (
<div className="message-stack">
<MarkdownBody content={item.content} />
</div>
) : (
<MarkdownBody content={item.content} />
)}
{item.role === "assistant" && showTime && item.at ? (
<time className="message-time" dateTime={item.at}>
{formatClock(item.at, locale)}
</time>
) : null}
</div>
</div>
</div>
);
})}
</div>
);
}, (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<AgentRow[]>([]);
const [activeId, setActiveId] = useState(() => isPhone() ? "" : localStorage.getItem("lazyboy.agent") || localStorage.getItem("grokboy.agent") || "");
const [phone, setPhone] = useState(isPhone);
const [mobilePage, setMobilePage] = useState<"list" | "chat">("list");
const mobilePageRef = useRef(mobilePage);
mobilePageRef.current = mobilePage;
useEffect(() => {
const media = window.matchMedia("(max-width: 700px), (max-width: 1100px) and (pointer: coarse)");
const update = () => setPhone(media.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
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 [rightOpen, setRightOpen] = useState(() => !isPhone());
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<string | 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 stoppingAgents = useRef(new Set<string>());
const stoppedTasks = useRef(new Map<string, Set<string>>());
const activitySnapshots = useRef(new Map<string, AgentActivity>());
const activityPending = useRef<string | null>(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<Channel[]>(() => readChannels());
const [activeChannelId, setActiveChannelId] = useState<string | null>(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<AgentRow | null>(null);
const [query, setQuery] = useState("");
const [mobileSearch, setMobileSearch] = useState(false);
const accountRef = useRef<HTMLDivElement>(null);
const createMenuRef = 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) => {
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<Promise<AgentRow[]> | null>(null);
const loadAgents = useCallback(() => {
if (rosterRequest.current) return rosterRequest.current;
const pending = api.agents().then((data) => {
const next = (data.agents || []).map((agent) =>
stoppedTasks.current.has(agent.id) ? { ...agent, running: false } : agent
);
setAgents((cur) => adoptAgents(cur, next));
return next;
}).finally(() => { rosterRequest.current = null; });
rosterRequest.current = pending;
return pending;
}, []);
const applyActivity = useCallback((data: AgentActivity) => {
const id = activeIdRef.current;
activitySnapshots.current.set(id, data);
const stopped = stoppedTasks.current.get(id);
if (stopped) {
const newTask = data.active_task_ids.some((task) => !stopped.has(task));
if (!stoppingAgents.current.has(id) && (data.state === "idle" || newTask)) {
stoppedTasks.current.delete(id);
} else {
return;
}
}
setAgents((cur) => cur.map((agent) => agent.id === id && agent.running !== data.running
? { ...agent, running: data.running } : agent));
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;
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<AbortController | null>(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);
if (isPhone()) {
if (mobilePageRef.current !== "chat") window.history.pushState({ ...window.history.state, lazyboyMobileChat: true }, "");
mobilePageRef.current = "chat";
setMobilePage("chat");
}
setUserProgress("");
setExecutionDetails([]);
setError("");
setTyping(false);
setComputerReady(false);
setComputerUrl("");
setImageFresh(false);
setComputerBusy(isPhone() ? null : "start");
setComputerStatus(isPhone() ? "" : tRef.current.computerStarting);
if (!isPhone()) 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) => {
if (isPhone()) return;
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]);
useEffect(() => {
if (!pageVisible) return;
const poll = window.setInterval(() => { void loadAgents().catch(() => undefined); }, 4000);
return () => window.clearInterval(poll);
}, [pageVisible, loadAgents]);
useEffect(() => {
if (!phone && !activeId && agents[0]) void openAgent(agents[0].id);
}, [phone, activeId, agents, openAgent]);
useEffect(() => {
// Each phone visit begins at the inbox, including a reload of a chat.
if (isPhone()) window.history.replaceState({ ...window.history.state, lazyboyMobileChat: false }, "");
const onBack = (event: PopStateEvent) => {
if (!isPhone()) return;
const page = event.state?.lazyboyMobileChat && activeIdRef.current ? "chat" : "list";
mobilePageRef.current = page;
setMobilePage(page);
setOverlayOpen(false);
setCreateMenuOpen(false);
setAccountOpen(false);
setAccountOverlay(null);
composerRef.current?.blur();
};
window.addEventListener("popstate", onBack);
return () => window.removeEventListener("popstate", onBack);
}, []);
useEffect(() => {
if (phone && accountOverlay && !window.history.state?.lazyboySettings) {
window.history.pushState({ ...window.history.state, lazyboySettings: true }, "");
}
}, [phone, accountOverlay]);
function closeAccountSettings() {
if (phone && window.history.state?.lazyboySettings) window.history.back();
else setAccountOverlay(null);
}
function backToChats() {
setOverlayOpen(false);
composerRef.current?.blur();
if (window.history.state?.lazyboyMobileChat) window.history.back();
else { mobilePageRef.current = "list"; setMobilePage("list"); }
}
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 (!overlayOpen) return;
const viewport = window.visualViewport;
const update = () => {
document.documentElement.style.setProperty("--visible-height", `${viewport?.height ?? window.innerHeight}px`);
document.documentElement.style.setProperty("--visible-top", `${viewport?.offsetTop ?? 0}px`);
};
update();
viewport?.addEventListener("resize", update);
viewport?.addEventListener("scroll", update);
return () => {
viewport?.removeEventListener("resize", update);
viewport?.removeEventListener("scroll", update);
document.documentElement.style.removeProperty("--visible-height");
document.documentElement.style.removeProperty("--visible-top");
};
}, [overlayOpen]);
useEffect(() => {
if (rightOpen || overlayOpen) void ensureComputer();
}, [rightOpen, overlayOpen, ensureComputer]);
useEffect(() => {
function onMessage(event: MessageEvent) {
if (event.origin !== window.location.origin) return;
const desktop = document.querySelector<HTMLIFrameElement>("iframe.desktop-frame");
if (!desktop || event.source !== desktop.contentWindow) 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() ?? Promise.reject(new Error("Clipboard unavailable")))
.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);
return () => {
source.close();
transcriptRequest.current?.controller.abort();
window.clearInterval(poll);
};
}, [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 (isPhone()) {
setActiveId("");
setTranscript([]);
setQuestion(null);
setTyping(false);
backToChats();
} else 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;
}
stoppedTasks.current.delete(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 || stoppingAgents.current.has(id)) return;
stoppingAgents.current.add(id);
const snapshot = activitySnapshots.current.get(id);
stoppedTasks.current.set(id, new Set([...(snapshot?.active_task_ids || []), ...(snapshot?.queued_task_ids || [])]));
activityGeneration.current += 1;
setTyping(false);
setActivityState("idle");
setWorkingStep(null);
setUserProgress("");
setQuestion(null);
setAgents((cur) => cur.map((agent) => agent.id === id ? { ...agent, running: false } : agent));
try {
await api.stop(id);
} catch (err) {
stoppedTasks.current.delete(id);
setError(err instanceof Error ? err.message : String(err));
} finally {
stoppingAgents.current.delete(id);
activityGeneration.current += 1;
void refreshActivity(id);
}
}
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 && !phone && !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 ? (
<iframe
key={computerGen}
className="desktop-frame"
title={active ? format("computerOf", { name: active.name }) : t.computer}
src={computerUrl}
allow="clipboard-read; clipboard-write; fullscreen"
referrerPolicy="no-referrer"
/>
) : (
<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"} mobile-${mobilePage}`} data-mobile={phone || undefined}>
<aside id="conversation-sidebar" className="sidebar" aria-label={t.chats}>
<div className="brand">
{phone ? <button type="button" className="mobile-profile" aria-label={t.settings} title={`${t.localWorkspace} · ${t.settings}`} onClick={() => setAccountOverlay("settings")}><span aria-hidden="true">{Array.from(t.localWorkspace.trim())[0]}</span></button> : <><img className="brand-icon" src="/lazyboy-round.svg" alt="" /><span>LazyBoy</span></>}
{phone ? <button type="button" className="mobile-search-toggle" aria-label={t.search} aria-expanded={mobileSearch} onClick={() => { setMobileSearch(value => !value); setQuery(""); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4 4"/></svg></button> : null}
<div className="create-menu-wrap" ref={createMenuRef} onClick={(event) => event.stopPropagation()}>
<button
type="button"
className="icon-button"
aria-label={t.add}
aria-haspopup="menu"
aria-expanded={createMenuOpen}
onClick={() => setCreateMenuOpen((open) => !open)}
>
<Plus />
</button>
{createMenuOpen ? (
<div className="create-menu" role="menu">
<button
type="button"
role="menuitem"
onClick={() => {
setCreateMenuOpen(false);
setCreateOpen(true);
}}
>
<BotIcon />
{t.addAgent}
</button>
<button
type="button"
role="menuitem"
disabled={agents.length < 2}
onClick={() => {
setCreateMenuOpen(false);
setShowChannel(true);
}}
>
<Users />
{t.addChannel}
</button>
</div>
) : null}
</div>
</div>
{!phone || mobileSearch ? <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> : null}
{phone && mobilePage === "list" && error ? <p className="error-banner" role="alert">{error}</p> : null}
<div className="bot-list">
{visibleChannels.length > 0 ? (
<section className="bot-group">
<div className="group-label">{t.channels}</div>
{visibleChannels.map((channel) => {
const members = channel.memberIds.flatMap((id) => {
const agent = agents.find((row) => row.id === id);
if (!agent) return [];
return [roomMember(agent)];
});
const host = agents.find((row) => row.id === channel.memberIds[0]);
return (
<button
type="button"
key={channel.id}
className={`bot-row room-row ${channel.id === activeChannelId ? "selected" : ""}`}
onClick={() => {
const hostId = channel.memberIds[0];
if (!hostId) return;
setActiveChannelId(channel.id);
void openAgent(hostId, true);
}}
onContextMenu={(event) => {
event.preventDefault();
setChannelMenu({ x: event.clientX, y: event.clientY, channel });
}}
>
<span className="avatar-wrap">
<AvatarStack members={members} size={phone ? 44 : 32} online thinkingIds={members.filter((member) => agents.find((row) => row.id === member.id)?.running).map((member) => member.id)} />
</span>
<span className="bot-copy">
<strong>{channel.name}</strong>
<small>{host?.preview || format("membersCount", { count: String(members.length) })}</small>
</span>
<span className="bot-trailing">
{host?.last_at ? <time className="row-time" dateTime={host.last_at}>{formatInboxTime(host.last_at, locale)}</time> : null}
</span>
</button>
);
})}
</section>
) : null}
<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) => (
<button
type="button"
key={agent.id}
className={`bot-row ${agent.id === active?.id && !activeChannelId ? "selected" : ""}`}
onClick={() => void openAgent(agent.id)}
onContextMenu={(event) => {
event.preventDefault();
setAgentMenu({ x: event.clientX, y: event.clientY, agent });
}}
>
<span className="avatar-wrap">
<Avatar {...avatarProps(agent)} size={phone ? 44 : 32} active={agent.id === active?.id && !activeChannelId} online thinking={agent.id === activeId ? typing : Boolean(agent.running) && !stoppedTasks.current.has(agent.id)} />
</span>
<span className="bot-copy">
<strong className="bot-name">{agent.name}{phone && agent.tags?.[0] ? <span className="bot-tag">{agent.tags[0]}</span> : null}</strong>
<small>{agent.running ? t.agentWorking : agent.preview || t.newChat}</small>
</span>
<span className="bot-trailing">
{agent.last_at ? <time className="row-time" dateTime={agent.last_at}>{formatInboxTime(agent.last_at, locale)}</time> : null}
{!phone && agent.tags?.[0] ? <span className="bot-tag side-tag">{agent.tags[0]}</span> : null}
</span>
</button>
))}
</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={phone ? t.settings : t.localWorkspace}
aria-haspopup={phone ? "dialog" : "menu"}
aria-expanded={phone ? accountOverlay === "settings" : accountOpen}
onClick={() => phone ? setAccountOverlay("settings") : setAccountOpen((open) => !open)}
>
{phone ? <><Settings /><span>{t.settings}</span></> : <><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.backToChats} onClick={backToChats}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m14 6-6 6 6 6" /></svg>
</button>
{activeChannel && channelMembers.length > 0 ? (
<>
<AvatarStack members={channelMembers} size={32} online />
<strong id="sand-conversation-heading">{activeChannel.name}</strong>
</>
) : active ? (
<button
type="button"
className="topbar-identity"
onClick={() => setSettingsAgent(active)}
>
<Avatar {...avatarProps(active)} active online size={32} />
<strong id="sand-conversation-heading">{active.name}</strong>
</button>
) : (
<strong>{t.pickAgent}</strong>
)}
<span className="grow" />
<nav className="top-tools" aria-label={t.workTools}>
<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 {...avatarProps(active)} active online size={64} />
<h1>{format("startWorkWith", { name: active.name })}</h1>
<p>{active.description || t.startWorkHint}</p>
</div>
) : null}
<MessageLog
key={activeId}
scrollRef={scroller}
transcript={transcript}
questionPrompt={question?.prompt || null}
locale={locale}
agent={active}
/>
</div>
<div className={`composer-dock ${working ? "has-status" : ""}`}>
{working && active ? (
<div className="thinking-row">
<Avatar {...avatarProps(active)} 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}>
<SendArrow />
</button>
)}
</form>
</div>
</main>
<section className={`meeting-stage ${showStage ? "" : "is-hidden"}`}>
<header className="meeting-stage-head">
<span className="side-card-title"><Computer />{t.computer}</span>
<span className={`state-dot ${computerReady ? "running" : computerBusy ? "booting" : ""}`} title={computerLive} />
<button type="button" className="computer-expand" title={t.enlarge} onClick={() => setOverlayOpen(true)}> {t.enlarge}</button>
<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="preview">{showStage && !overlayOpen ? frame : null}{hud}</div>
<div className="computer-caption">{active ? format("computerOf", { name: active.name }) : t.computer}<span>{computerLive}</span></div>
</div>
</div>
</section>
{overlayOpen ? (
<div className="computer-overlay">
<header>
<div>
{active ? <Avatar {...avatarProps(active)} 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}
{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"
onClick={() => {
setSettingsAgent(agentMenu.agent);
setAgentMenu(null);
}}
>
<Settings />
{t.agentSettings}
</button>
<hr />
<button
type="button"
role="menuitem"
className="danger-item"
onClick={() => void onDeleteAgent(agentMenu.agent)}
>
<TrashIcon />
{t.deleteAgent}
</button>
</div>
</>
) : null}
{channelMenu ? (
<>
<button type="button" className="context-backdrop" aria-label={t.close} onClick={() => setChannelMenu(null)} />
<div
className="context-menu"
role="menu"
style={{
left: Math.min(channelMenu.x, window.innerWidth - 220),
top: Math.min(channelMenu.y, window.innerHeight - 80),
}}
>
<button
type="button"
role="menuitem"
className="danger-item"
onClick={() => {
const next = channels.filter((channel) => channel.id !== channelMenu.channel.id);
setChannels(next);
writeChannels(next);
if (activeChannelId === channelMenu.channel.id) setActiveChannelId(null);
setChannelMenu(null);
}}
>
<TrashIcon />
{t.deleteChannel}
</button>
</div>
</>
) : null}
{showChannel ? (
<ChannelDialog
agents={agents}
onClose={() => setShowChannel(false)}
onCreate={(name, memberIds) => {
const channel: Channel = { id: crypto.randomUUID(), name, memberIds };
const next = [channel, ...channels];
setChannels(next);
writeChannels(next);
setShowChannel(false);
setActiveChannelId(channel.id);
void openAgent(memberIds[0], true);
}}
/>
) : null}
{accountOverlay === "settings" ? (
<SettingsDialog
mobile={phone}
onAbout={() => setAccountOverlay("about")}
onClose={closeAccountSettings}
computer={{
pending: computerBusy,
working,
upToDate: imageFresh,
status: computerStatus,
onUpdate: () => void runComputer("update"),
onRestart: () => void runComputer("restart"),
}}
/>
) : null}
{accountOverlay === "about" ? <AboutDialog mobile={phone} onClose={() => phone ? setAccountOverlay("settings") : setAccountOverlay(null)} /> : null}
{createOpen ? (
<AgentSettingsDialog
mode="create"
onClose={() => setCreateOpen(false)}
onSaved={(row) => {
setCreateOpen(false);
void (async () => {
await loadAgents();
await openAgent(row.id || row.name);
})();
}}
/>
) : null}
{settingsAgent ? (
<AgentSettingsDialog
agent={agents.find((row) => row.id === settingsAgent.id) || settingsAgent}
onClose={() => setSettingsAgent(null)}
onSaved={() => {
void loadAgents();
}}
/>
) : null}
</div>
</AvatarLookProvider>
);
}