2026-09-15 09:07:16 +00:00
|
|
|
|
import { FormEvent, memo, 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";
|
2026-09-15 09:07:16 +00:00
|
|
|
|
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";
|
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 09:07:16 +00:00
|
|
|
|
import { formatClock, formatDay, formatInboxTime, sameChatCluster, sameLocalDay } from "./time";
|
|
|
|
|
|
import type { AvatarShape, RoomMember } from "./types";
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 05:20:44 +00:00
|
|
|
|
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
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
const SendArrow = memo(function SendArrow() {
|
|
|
|
|
|
return <UseAnimations animation={arrowUp} size={20} strokeColor="var(--on-send)" />;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const MessageLog = memo(function MessageLog({
|
|
|
|
|
|
transcript,
|
|
|
|
|
|
questionPrompt,
|
|
|
|
|
|
locale,
|
|
|
|
|
|
agent,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
transcript: TranscriptItem[];
|
|
|
|
|
|
questionPrompt: string | null;
|
|
|
|
|
|
locale: Locale;
|
|
|
|
|
|
agent: Pick<AgentRow, "id" | "name" | "avatar_color" | "avatar_shape" | "avatar_url"> | null;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const items = transcript.filter((item) => !(questionPrompt && item.role === "assistant" && item.content.trim() === questionPrompt));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{items.map((item, i) => {
|
|
|
|
|
|
const prev = items[i - 1];
|
|
|
|
|
|
const next = items[i + 1];
|
|
|
|
|
|
const newDay = Boolean(item.at && !sameLocalDay(item.at, prev?.at));
|
|
|
|
|
|
const clusterStart = newDay || !sameChatCluster(prev, item);
|
|
|
|
|
|
const clusterEnd = !sameChatCluster(item, next);
|
|
|
|
|
|
const showTime = Boolean(item.at) && clusterEnd;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`message-block${!clusterStart ? " tight" : ""}${newDay ? " has-day" : ""}`}
|
|
|
|
|
|
key={`${item.role}-${item.at || i}-${i}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}, (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 || "")
|
|
|
|
|
|
));
|
|
|
|
|
|
|
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-15 09:07:16 +00:00
|
|
|
|
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);
|
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-15 09:07:16 +00:00
|
|
|
|
const [settingsAgent, setSettingsAgent] = useState<AgentRow | null>(null);
|
2026-09-14 09:08:20 +00:00
|
|
|
|
const [query, setQuery] = useState("");
|
2026-09-15 03:20:42 +00:00
|
|
|
|
const accountRef = useRef<HTMLDivElement>(null);
|
2026-09-15 09:07:16 +00:00
|
|
|
|
const createMenuRef = 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;
|
2026-09-15 09:07:16 +00:00
|
|
|
|
return agents.filter((agent) => {
|
|
|
|
|
|
const haystack = [
|
|
|
|
|
|
agent.name,
|
|
|
|
|
|
agent.title,
|
|
|
|
|
|
agent.description,
|
|
|
|
|
|
agent.preview,
|
|
|
|
|
|
...(agent.tags || []),
|
|
|
|
|
|
]
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
.join(" ")
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
return haystack.includes(needle);
|
|
|
|
|
|
});
|
2026-09-14 09:08:20 +00:00
|
|
|
|
}, [agents, query]);
|
|
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-09-14 09:08:20 +00:00
|
|
|
|
const loadAgents = useCallback(async () => {
|
|
|
|
|
|
const data = await api.agents();
|
2026-09-15 09:07:16 +00:00
|
|
|
|
const next = data.agents || [];
|
|
|
|
|
|
setAgents((cur) => adoptAgents(cur, next));
|
|
|
|
|
|
return next;
|
2026-09-14 09:08:20 +00:00
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-15 03:20:42 +00:00
|
|
|
|
const applyActivity = useCallback((data: AgentActivity) => {
|
2026-09-15 09:07:16 +00:00
|
|
|
|
setActivityState((cur) => (cur === data.state ? cur : data.state));
|
2026-09-15 03:20:42 +00:00
|
|
|
|
setTyping(data.state === "running");
|
2026-09-15 09:07:16 +00:00
|
|
|
|
if (data.state !== "running") setWorkingStep((cur) => (cur == null ? cur : null));
|
|
|
|
|
|
if (data.state === "idle") setUserProgress((cur) => (cur ? "" : cur));
|
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-15 09:07:16 +00:00
|
|
|
|
const applyTranscript = useCallback((items: TranscriptItem[]) => {
|
|
|
|
|
|
const next = visibleTranscript(items);
|
|
|
|
|
|
setTranscript((cur) => (sameSpoken(cur, next) ? cur : next));
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const syncTranscript = useCallback(async (id: string) => {
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
const generation = ++transcriptGeneration.current;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await api.agent(id);
|
|
|
|
|
|
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 */
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [applyTranscript]);
|
|
|
|
|
|
|
|
|
|
|
|
const openAgent = useCallback(async (id: string, keepChannel = false) => {
|
2026-09-15 03:20:42 +00:00
|
|
|
|
activeIdRef.current = id;
|
|
|
|
|
|
activityGeneration.current += 1;
|
2026-09-15 09:07:16 +00:00
|
|
|
|
transcriptGeneration.current += 1;
|
|
|
|
|
|
const generation = transcriptGeneration.current;
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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 09:07:16 +00:00
|
|
|
|
if (!keepChannel) setActiveChannelId(null);
|
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 09:07:16 +00:00
|
|
|
|
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
|
|
|
|
|
|
applyTranscript(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 09:07:16 +00:00
|
|
|
|
}, [applyActivity, applyTranscript]);
|
2026-09-15 03:20:42 +00:00
|
|
|
|
|
|
|
|
|
|
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 09:07:16 +00:00
|
|
|
|
if (!accountOpen && !accountOverlay && !createMenuOpen && !showChannel) 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 09:07:16 +00:00
|
|
|
|
setCreateMenuOpen(false);
|
|
|
|
|
|
setShowChannel(false);
|
2026-09-15 03:20:42 +00:00
|
|
|
|
};
|
|
|
|
|
|
const onPointer = (event: PointerEvent) => {
|
2026-09-15 09:07:16 +00:00
|
|
|
|
const target = event.target as Node;
|
|
|
|
|
|
if (accountOpen && !accountRef.current?.contains(target)) setAccountOpen(false);
|
|
|
|
|
|
if (createMenuOpen && !createMenuRef.current?.contains(target)) setCreateMenuOpen(false);
|
2026-09-15 03:20:42 +00:00
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
|
|
window.addEventListener("pointerdown", onPointer);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener("keydown", onKey);
|
|
|
|
|
|
window.removeEventListener("pointerdown", onPointer);
|
|
|
|
|
|
};
|
2026-09-15 09:07:16 +00:00
|
|
|
|
}, [accountOpen, accountOverlay, createMenuOpen, showChannel]);
|
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-15 09:07:16 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
function onMessage(event: MessageEvent) {
|
|
|
|
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
|
|
const data = event.data as { type?: string; text?: string } | null;
|
|
|
|
|
|
if (!data || typeof data !== "object") return;
|
|
|
|
|
|
const source = event.source as Window | null;
|
|
|
|
|
|
if (data.type === "lazyboy-clipboard-write" && typeof data.text === "string") {
|
|
|
|
|
|
void navigator.clipboard.writeText(data.text).catch(() => undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (data.type === "lazyboy-clipboard-read" && source) {
|
|
|
|
|
|
void navigator.clipboard
|
|
|
|
|
|
.readText()
|
|
|
|
|
|
.then((text) => source.postMessage({ type: "lazyboy-clipboard-text", text }, event.origin))
|
|
|
|
|
|
.catch(() => source.postMessage({ type: "lazyboy-clipboard-text", text: "" }, event.origin));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener("message", onMessage);
|
|
|
|
|
|
return () => window.removeEventListener("message", onMessage);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-09-14 09:08:20 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeId) return;
|
|
|
|
|
|
const sourceAgent = activeId;
|
|
|
|
|
|
const source = new EventSource(`/api/agents/${encodeURIComponent(sourceAgent)}/events`);
|
2026-09-15 09:07:16 +00:00
|
|
|
|
let opened = false;
|
2026-09-14 09:08:20 +00:00
|
|
|
|
source.onmessage = (ev) => {
|
|
|
|
|
|
if (activeIdRef.current !== sourceAgent) return;
|
|
|
|
|
|
let payload: { events?: TeamEvent[] };
|
|
|
|
|
|
try {
|
|
|
|
|
|
payload = JSON.parse(ev.data);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-15 09:07:16 +00:00
|
|
|
|
for (const event of payload.events || []) handleEventRef.current(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 09:07:16 +00:00
|
|
|
|
source.onopen = () => {
|
|
|
|
|
|
void refreshActivity(sourceAgent);
|
|
|
|
|
|
if (opened) return;
|
|
|
|
|
|
opened = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
source.onerror = () => { /* reconnect is normal; activity poll covers gaps */ };
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
handleEventRef.current = (event: TeamEvent) => {
|
2026-09-14 09:08:20 +00:00
|
|
|
|
const p = event.payload || {};
|
|
|
|
|
|
switch (event.kind) {
|
|
|
|
|
|
case "reply":
|
2026-09-15 09:07:16 +00:00
|
|
|
|
setWorkingStep((cur) => (cur == null ? cur : null));
|
|
|
|
|
|
void syncTranscript(activeIdRef.current);
|
2026-09-14 09:08:20 +00:00
|
|
|
|
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 09:07:16 +00:00
|
|
|
|
};
|
2026-09-14 09:08:20 +00:00
|
|
|
|
|
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();
|
2026-09-15 09:07:16 +00:00
|
|
|
|
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);
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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 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) {
|
2026-09-15 09:07:16 +00:00
|
|
|
|
sendingRef.current = false;
|
|
|
|
|
|
setCreateOpen(true);
|
|
|
|
|
|
return;
|
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 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"
|
2026-09-15 09:07:16 +00:00
|
|
|
|
referrerPolicy="no-referrer"
|
2026-09-15 03:20:42 +00:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<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>
|
2026-09-15 03:20:42 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
<div className="bot-list">
|
2026-09-15 09:07:16 +00:00
|
|
|
|
{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} 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}
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<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 09:07:16 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-09-14 09:08:20 +00:00
|
|
|
|
key={agent.id}
|
2026-09-15 09:07:16 +00:00
|
|
|
|
className={`bot-row ${agent.id === active?.id && !activeChannelId ? "selected" : ""}`}
|
2026-09-14 09:08:20 +00:00
|
|
|
|
onClick={() => void openAgent(agent.id)}
|
2026-09-15 03:20:42 +00:00
|
|
|
|
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">
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<Avatar {...avatarProps(agent)} active={agent.id === active?.id && !activeChannelId} 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 09:07:16 +00:00
|
|
|
|
<span className="bot-trailing">
|
|
|
|
|
|
{agent.last_at ? <time className="row-time" dateTime={agent.last_at}>{formatInboxTime(agent.last_at, locale)}</time> : null}
|
|
|
|
|
|
{agent.tags?.[0] ? <span className="bot-tag side-tag">{agent.tags[0]}</span> : null}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
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>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
{activeChannel && channelMembers.length > 0 ? (
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<AvatarStack members={channelMembers} size={32} online />
|
|
|
|
|
|
<strong id="sand-conversation-heading">{activeChannel.name}</strong>
|
2026-09-15 03:20:42 +00:00
|
|
|
|
</>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
) : 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>
|
2026-09-15 03:20:42 +00:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
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">
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<Avatar {...avatarProps(active)} active online size={64} />
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<h1>{format("startWorkWith", { name: active.name })}</h1>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<p>{active.description || t.startWorkHint}</p>
|
2026-09-15 03:20:42 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<MessageLog
|
|
|
|
|
|
transcript={transcript}
|
|
|
|
|
|
questionPrompt={question?.prompt || null}
|
|
|
|
|
|
locale={locale}
|
|
|
|
|
|
agent={active}
|
|
|
|
|
|
/>
|
2026-09-14 09:08:20 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<div className={`composer-dock ${working ? "has-status" : ""}`}>
|
|
|
|
|
|
{working && active ? (
|
|
|
|
|
|
<div className="thinking-row">
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<Avatar {...avatarProps(active)} thinking={typing} online />
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<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}>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<SendArrow />
|
2026-09-15 03:20:42 +00:00
|
|
|
|
</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">
|
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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{overlayOpen ? (
|
|
|
|
|
|
<div className="computer-overlay">
|
|
|
|
|
|
<header>
|
|
|
|
|
|
<div>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
{active ? <Avatar {...avatarProps(active)} active online thinking={working} size={30} /> : null}
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<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
|
|
|
|
{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),
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-09-15 09:07:16 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSettingsAgent(agentMenu.agent);
|
|
|
|
|
|
setAgentMenu(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings />
|
|
|
|
|
|
{t.agentSettings}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<hr />
|
2026-09-15 03:20:42 +00:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
role="menuitem"
|
|
|
|
|
|
className="danger-item"
|
|
|
|
|
|
onClick={() => void onDeleteAgent(agentMenu.agent)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<TrashIcon />
|
|
|
|
|
|
{t.deleteAgent}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-09-15 09:07:16 +00:00
|
|
|
|
{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}
|
|
|
|
|
|
|
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-15 09:07:16 +00:00
|
|
|
|
{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}
|
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
|
|
|
|
);
|
|
|
|
|
|
}
|