diff --git a/.github/scripts/ds-bot-patches.test.mjs b/.github/scripts/ds-bot-patches.test.mjs
index 96a61b027..7eb4bbd4e 100644
--- a/.github/scripts/ds-bot-patches.test.mjs
+++ b/.github/scripts/ds-bot-patches.test.mjs
@@ -193,10 +193,11 @@ if (gitAvailable) {
const root = join(import.meta.dirname, '..', '..')
const patchesDir = join(root, 'patches', 'ds-bot')
const stored = readdirSync(patchesDir).filter(name => /^\d{4}-.*\.patch$/.test(name)).sort()
- // 10, not 11: `0017-task-pool-role-queues.patch` only edited whole-file mirrors
+ // 11: `0017-task-pool-role-queues.patch` only edited whole-file mirrors
// (`src/host/tasks.js`, `src/host/hooks.js`) plus one `tools.js` line that lives in `0002`,
- // so the generator folds it away instead of storing a patch that applies nothing.
- assert.equal(stored.length, 10)
+ // so the generator folds it away instead of storing a patch that applies nothing. The count is
+ // 11 since `0018-project-dossier.patch`, which edits upstream files and so is stored.
+ assert.equal(stored.length, 11)
const viaGit = mkdtempSync(join(tmpdir(), 'patch-git-'))
const viaOurs = mkdtempSync(join(tmpdir(), 'patch-ours-'))
try {
diff --git a/changelog.d/1864-project-dossier.added.md b/changelog.d/1864-project-dossier.added.md
new file mode 100644
index 000000000..78f62b565
--- /dev/null
+++ b/changelog.d/1864-project-dossier.added.md
@@ -0,0 +1 @@
+The Project board shows each Project Bot's dossier — persona, memory head and topic names, and attached skills — from one read-only `project-team` read, with a `Dossier` control on the Chief, my-agent and role-pool rows; the owner's own Bot chat header now has an entry that opens the Project assistant's chat, without un-hiding it in the global Bot list ([#1864](https://github.com/ThinkFlowLab/FlyBot/issues/1864)).
diff --git a/core/client.js b/core/client.js
index 9f8f5f276..c86611a3f 100644
--- a/core/client.js
+++ b/core/client.js
@@ -1450,6 +1450,15 @@ var ZH = {
"Role pool": "角色池",
"Can switch to {roles}": "可切换为 {roles}",
"No one in this pool yet.": "角色池里还没有人。",
+ // #1864: the Project team's dossier — persona, memory and skills are read-only in this slice.
+ "Dossier": "档案",
+ "Persona": "人设",
+ "Skills": "技能",
+ "No memory yet.": "还没有记忆。",
+ "No skills attached.": "还没有附加技能。",
+ "Open chat": "打开对话",
+ "Open my agent": "打开我的项目助手",
+ "Open {name}'s dossier": "打开 {name} 的档案",
"No cards yet.": "还没有卡片。",
"Open Projects in Settings": "在设置中打开项目"
};
@@ -10378,10620 +10387,10789 @@ var BUSY_ACK_CSS = `
@media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}}
`;
-// src/client/header.js
+// src/client/project-board.js
var import_react19 = require("react");
-var import_react_dom6 = require("react-dom");
-var GoArrow = () => (0, import_react19.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" }));
-var MEMORY_NOTICE_MS = 3200;
-var noNews = (select) => select({});
-function useMemoryNotice(ids, useMemoryNews) {
- const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0)));
- const [shown, setShown] = (0, import_react19.useState)(false);
- (0, import_react19.useEffect)(() => {
- const left = seen + MEMORY_NOTICE_MS - Date.now();
- setShown(seen > 0 && left > 0);
- if (seen === 0 || left <= 0) return void 0;
- const timer = setTimeout(() => setShown(false), left);
- return () => clearTimeout(timer);
- }, [seen]);
- return shown;
-}
-function MemoryNotice({ shown }) {
- const inner = (0, import_react19.useRef)(null);
- const [width, setWidth] = (0, import_react19.useState)(0);
- const label = t("Memory updated");
- (0, import_react19.useLayoutEffect)(() => {
- if (shown) setWidth(inner.current?.scrollWidth ?? 0);
- }, [label, shown]);
- return (0, import_react19.createElement)(
- "span",
- { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } },
- (0, import_react19.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react19.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react19.createElement)(MemoryIcon)), label)
- );
+var PROJECT_BOARD_CSS = `
+/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the
+ bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the
+ Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */
+.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))}
+.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto}
+.bt-project-board{padding:16px 18px}
+.bt-project-group{margin:0 0 14px}
+.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
+.bt-project-row[data-column="done"]{opacity:.7}
+.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
+.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
+.bt-project-empty{padding:22px 4px;opacity:.7}
+/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new
+ component language: a heading, a list, and a monospace hash the reader can compare. */
+.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
+.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55}
+.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
+.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
+.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
+.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-missing{font-size:11px;opacity:.6}
+.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
+/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
+ carries. A severe card is the only one painted louder, because it is the only one that waits. */
+.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
+.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
+.bt-project-recipients{font-size:11px;opacity:.65}
+.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
+.bt-project-holder{font-size:11px;opacity:.7}
+.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
+.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
+.bt-project-metrics{margin:0 0 14px}
+.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
+.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px}
+.bt-project-metric-value{font-size:18px;font-weight:600}
+.bt-project-metric-label{font-size:12px;font-weight:600}
+.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3}
+.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7}
+.bt-project-running{margin:0 0 14px}
+.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0}
+.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em}
+.bt-project-running-row[role="button"]{cursor:pointer}
+.bt-project-running-row[data-overdue="true"]{color:#c2410c}
+.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8}
+.bt-project-counts b{font-weight:600}
+.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default}
+.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto}
+.bt-project-state{font-size:11px;margin-left:auto}
+.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b}
+.bt-project-reason{font-size:11px;opacity:.7}
+
+/* #1855: the finished cards are one group of their own, closed until the reader opens it — a
+ Project with months of history reads as the work in flight, not as its archive. */
+.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
+.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none}
+.bt-project-done>summary::-webkit-details-marker{display:none}
+.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6}
+.bt-project-done[open]>summary::before{content:'\\25BE'}
+.bt-project-done[open]>summary{margin:0 0 8px}
+
+/* #1749: management panels. Same surface as the board, one form row per control, and a status
+ line that quotes the host's own answer — a refused save reads where the reader pressed it. */
+.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
+.bt-project-panel h3{margin:0}
+.bt-project-field{display:flex;flex-direction:column;gap:4px}
+.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
+.bt-project-field textarea{min-height:88px;resize:vertical}
+.bt-project-row-actions{display:flex;gap:8px;align-items:center}
+.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
+.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
+.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6}
+.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px}
+.bt-project-note{font-size:12px;opacity:.65;margin:0}
+
+/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool.
+ One quiet block above the cards, and a Bot row is a button because a reader is looking a
+ person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */
+.bt-project-team{margin:0 0 14px}
+.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
+.bt-project-team-row[data-open-bot]{cursor:pointer}
+.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px}
+.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
+.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
+.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
+
+/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the
+ owner's own chat header carries the same entry to the Project assistant's chat. */
+.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
+.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px}
+.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}
+.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7}
+.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap}
+.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))}
+.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+`;
+var PROJECT_SOURCES = ["bot", "group", "workflow"];
+var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" };
+var text = (value) => typeof value === "string" ? value.trim() : "";
+var DONE_COLUMN = "done";
+var boardOfRow = (row, task) => {
+ if (task === void 0) return null;
+ if (row.source === "group") {
+ const id = text(task.origin?.roomId);
+ return id === "" ? null : { kind: "group", id };
+ }
+ if (row.source === "bot") {
+ const id = text(task.ownerBotId);
+ return id === "" ? null : { kind: "bot", id };
+ }
+ return null;
+};
+var decisionOf = (file) => {
+ if (file === null || typeof file !== "object") return null;
+ const path = text(file.path);
+ if (path === "") return null;
+ return {
+ key: `decision:${path}`,
+ path,
+ name: text(file.name) || path.slice(path.lastIndexOf("/") + 1),
+ date: text(file.date),
+ title: text(file.title),
+ sha256: text(file.sha256),
+ shortHash: text(file.sha256).slice(0, 12)
+ };
+};
+var meetingOf = (meeting, status) => {
+ if (meeting === null || typeof meeting !== "object") return null;
+ const id = text(meeting.id);
+ if (id === "") return null;
+ return {
+ key: `meeting:${id}`,
+ id,
+ date: text(meeting.date),
+ question: text(meeting.question) || text(meeting.title),
+ owner: text(meeting.owner) || null,
+ status,
+ path: text(meeting.path),
+ blockingQuestion: text(meeting.blockingQuestion) || null,
+ // #1831: the level and recipients the host answered, and the refusal to advance a severe card.
+ // Nothing is inferred here: a record written before the slice carries an ordinary card and no
+ // recipients, exactly as the host reads it.
+ level: text(meeting.level) || "ordinary",
+ recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [],
+ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null },
+ // #1861: the host's participants with who each position speaks for, so the row can say a
+ // Project Chief or an agent held it for someone rather than for themselves.
+ participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : []
+ };
+};
+var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : [];
+function projectBoardRecord({ decisions, meetings } = {}) {
+ const dir = text(decisions?.dir) || null;
+ const files = listOf(decisions?.files);
+ const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : [];
+ const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : [];
+ return {
+ decisions: {
+ dir,
+ files,
+ count: files.length,
+ empty: files.length === 0,
+ emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`)
+ },
+ meetings: {
+ decided,
+ pending,
+ count: decided.length + pending.length,
+ empty: decided.length === 0 && pending.length === 0,
+ emptyText: text(meetings?.reason) || "No meeting of this Project is recorded."
+ }
+ };
}
-function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
- const roster = useRoster((value) => value);
- const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? "");
- const firstTurns = useSessions((list) => {
- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
- return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : "";
- });
- const hiddenTurns = roster.exchangeTurns?.[sessionId];
- const visit = useUi((state) => state.visits?.[sessionId]);
- const opening = useTurnClock((state) => {
- const first = openingTurn(state[sessionId], firstTurns, hiddenTurns);
- return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : "";
+function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) {
+ const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== "";
+ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
+ const list = [];
+ const archived = [];
+ const seen = /* @__PURE__ */ new Set();
+ for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) {
+ if (!isRow(row)) continue;
+ const id = text(row.id);
+ if (seen.has(id)) continue;
+ seen.add(id);
+ const column = text(row.column) || "not-started";
+ (column === DONE_COLUMN ? archived : list).push(row);
+ }
+ const projectRow = (row) => {
+ const task = byId.get(text(row.id));
+ return {
+ id: text(row.id),
+ key: `task:${text(row.id)}`,
+ column: text(row.column) || "not-started",
+ assignee: text(row.assignee) || null,
+ runId: text(row.runId) || null,
+ nodeId: text(row.nodeId) || null,
+ title: text(task?.title) || text(task?.note) || null,
+ priority: text(task?.priority) || null,
+ audit: text(row.audit) || null,
+ // #1831: who is doing the card, and who is speaking for whom. Both are host truth;
+ // a card carrying neither renders exactly as before.
+ holder: text(row.holder) || null,
+ speaksFor: text(row.speaksFor) || null,
+ board: boardOfRow(row, task)
+ };
+ };
+ const groups = PROJECT_SOURCES.map((source) => ({
+ source,
+ label: SOURCE_LABELS[source],
+ rows: list.filter((row) => row.source === source).map(projectRow)
+ })).filter((group) => group.rows.length > 0);
+ const countOf = (value) => value === void 0 || value === null ? null : value;
+ const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => {
+ const id = text(item.id);
+ const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source);
+ const task = byId.get(id);
+ return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) };
});
- (0, import_react19.useEffect)(() => {
- if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening));
- else document.body.style.removeProperty("--bt-first-sep");
- return () => document.body.style.removeProperty("--bt-first-sep");
- }, [opening]);
- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
- const owner = roster.byId[sessionId]?.id ?? sessionId;
- const doing = useActivity((map) => map[owner]);
- const bot = roster.byId[sessionId];
- const room2 = roster.roomsById[sessionId];
- const detailsOpen = useUi((state) => state.details === sessionId);
- const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
- const face = room2?.origin === "face-to-face";
- const name = face ? faceRowTitle(full2) : full2;
- const useNews = useMemoryNews ?? noNews;
- const memoryIds = bot ? [bot.id] : room2?.members ?? [];
- const notice = useMemoryNotice(memoryIds, useNews);
- const changed2 = useNews((map) => latestMemoryChange(map, memoryIds));
- const memoryNotice = notice && changed2 !== null && roster.memory !== false;
+ const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
+ const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" }));
+ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null }));
+ const queueGroups = [
+ { group: "waiting", label: "Waiting", items: waiting },
+ { group: "inflight", label: "In flight", items: inflight },
+ { group: "blocked", label: "Blocked", items: blocked }
+ ].filter((entry) => entry.items.length > 0);
+ const rawCounts = queues?.counts ?? null;
+ return {
+ projectId: text(project?.id) || null,
+ title: text(project?.name) || "Project",
+ canManage: canManage === true,
+ groups,
+ // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a
+ // finished card is still a card — only its place on the board changed.
+ done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) },
+ queues: queueGroups,
+ counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) },
+ count: list.length,
+ empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0,
+ emptyText: "No cards yet.",
+ record: projectBoardRecord({ decisions, meetings }),
+ metrics: metricsView(metrics, { tasks })
+ };
+}
+function projectRowNode(row, onOpen) {
+ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
return (0, import_react19.createElement)(
"div",
- { className: "bt-head" },
+ {
+ key: row.key,
+ className: "bt-project-row",
+ "data-task-id": row.id,
+ "data-column": row.column,
+ "data-priority": row.priority ?? void 0,
+ role: open === void 0 ? void 0 : "button",
+ tabIndex: open === void 0 ? void 0 : 0,
+ onClick: open,
+ onKeyDown: open === void 0 ? void 0 : (event) => {
+ if (event.key === "Enter" || event.key === " ") open();
+ }
+ },
+ (0, import_react19.createElement)("span", { className: "bt-project-column" }, t(row.column)),
(0, import_react19.createElement)(
- "button",
- {
- type: "button",
- className: "bt-pill",
- "aria-expanded": detailsOpen,
- "data-memory": memoryNotice ? "updated" : void 0,
- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"),
- onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId)
- },
- bot ? (0, import_react19.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react19.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null,
- (0, import_react19.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")),
- (0, import_react19.createElement)(MemoryNotice, { shown: memoryNotice }),
- (0, import_react19.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react19.createElement)(GoArrow))
+ "span",
+ { className: "bt-project-title-wrap" },
+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
+ // The audit text the row already carries (#1730): shown inside the card detail, never a
+ // second read. A row without one renders exactly as before.
+ row.audit === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-audit" }, row.audit)
),
- (0, import_react19.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""),
- (0, import_react19.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
- (0, import_react19.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
- (0, import_react19.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive })
+ // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
+ // says so, so the three identities (Bot, Assistant, user) never read as one.
+ row.holder === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`),
+ row.speaksFor === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })),
+ row.priority === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-priority" }, row.priority),
+ row.runId === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
);
}
-function useTranscriptView() {
- const [view, setView] = (0, import_react19.useState)(null);
- (0, import_react19.useEffect)(() => {
- let frame = 0;
- let watched = null;
- const observer = new MutationObserver(() => schedule());
- const measure = () => {
- frame = 0;
- const scroller = document.querySelector("[data-conversation-scroll]");
- if (scroller !== watched) {
- observer.disconnect();
- watched = scroller;
- if (scroller) observer.observe(scroller, { childList: true, subtree: true });
- }
- if (!scroller) {
- setView(null);
- return;
- }
- const box = scroller.getBoundingClientRect();
- const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50;
- const sep = scroller.querySelector(".bt-new-sep");
- let divider = null;
- if (sep) {
- const at = sep.getBoundingClientRect();
- divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view";
- }
- const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152;
- const next = {
- unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
- divider,
- center: Math.round(box.left + box.width / 2),
- top: Math.round(headerBottom + 8),
- bottom: Math.round(window.innerHeight - box.bottom + composer + 8)
- };
- setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next);
- };
- const schedule = () => {
- if (!frame) frame = requestAnimationFrame(measure);
- };
- document.addEventListener("scroll", schedule, true);
- window.addEventListener("resize", schedule);
- schedule();
- return () => {
- cancelAnimationFrame(frame);
- observer.disconnect();
- document.removeEventListener("scroll", schedule, true);
- window.removeEventListener("resize", schedule);
- };
- }, []);
- return view;
+function ProjectBoardRows({ view, onOpen }) {
+ if (view.empty) return (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText));
+ return view.groups.map((group) => (0, import_react19.createElement)(
+ "section",
+ { key: group.source, className: "bt-project-group", "data-source": group.source },
+ (0, import_react19.createElement)("h4", null, t(group.label)),
+ group.rows.map((row) => projectRowNode(row, onOpen))
+ ));
}
-function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) {
- const replies = useSessions((list) => {
- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
- return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0;
- });
- const visit = useUi((state) => state.visits?.[sessionId]);
- const unread = useTurnClock((state) => {
- if (typeof visit?.seen !== "number") return 0;
- let count = 0;
- for (const entry of Object.values(state[sessionId] ?? {})) {
- if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1;
- }
- return count;
- });
- const view = useTranscriptView();
- const unpinned = view?.unpinned === true;
- const [release, setRelease] = (0, import_react19.useState)({ base: null, dismissed: false });
- const [above, setAbove] = (0, import_react19.useState)({ seen: false, dismissed: false });
- (0, import_react19.useEffect)(() => {
- setRelease({ base: unpinned ? replies : null, dismissed: false });
- }, [unpinned]);
- (0, import_react19.useEffect)(() => {
- if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true });
- }, [view?.divider]);
- const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns));
- (0, import_react19.useEffect)(() => {
- if (above.seen && movedOn) actions?.settleVisit?.(sessionId);
- }, [above.seen, movedOn, sessionId]);
- const fresh = release.base === null ? 0 : replies - release.base;
- const showDown = unpinned && fresh > 0 && !release.dismissed;
- const showUp = view?.divider === "above" && !above.seen && !above.dismissed;
- (0, import_react19.useEffect)(() => {
- const scroller = document.querySelector("[data-conversation-scroll]");
- if (!scroller) return void 0;
- scroller.toggleAttribute("data-bt-news", showDown);
- return () => scroller.removeAttribute("data-bt-news");
- }, [showDown]);
- if (!view || !showDown && !showUp) return null;
- const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages");
- const pill = (direction, count, style, onJump, onDismiss) => (0, import_react19.createElement)(
- "div",
- { className: "bt-news", "data-direction": direction, style },
- (0, import_react19.createElement)(
- "button",
- { type: "button", className: "bt-news-jump", onClick: onJump },
- (0, import_react19.createElement)("span", { className: "bt-news-ico" }, (0, import_react19.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)),
- label(count)
- ),
- (0, import_react19.createElement)("button", {
- type: "button",
- className: "bt-news-x",
- "aria-label": t("Dismiss new messages"),
- onClick: (event) => {
- event.stopPropagation();
- onDismiss();
- }
- }, (0, import_react19.createElement)(CloseIcon))
- );
- const toLatest = () => {
- const scroller = document.querySelector("[data-conversation-scroll]");
- const back = scroller?.querySelector('[class*="_toBottomSlot"]>button');
- if (back) back.click();
- else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
- };
- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" });
- return (0, import_react_dom6.createPortal)(
- (0, import_react19.createElement)(
- import_react19.Fragment,
- null,
- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null,
- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null
- ),
- document.body
+function ProjectDoneGroup({ view, onOpen }) {
+ if (view.done.rows.length === 0) return null;
+ return (0, import_react19.createElement)(
+ "details",
+ { className: "bt-project-group bt-project-done", "data-group": "done" },
+ (0, import_react19.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`),
+ view.done.rows.map((row) => projectRowNode(row, onOpen))
);
}
-
-// src/client/composer-label.js
-var ATTRIBUTES = ["aria-label", "data-placeholder"];
-function holdComposerLabel(doc, label) {
- doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label));
- const shell = /* @__PURE__ */ new Map();
- const apply2 = () => {
- for (const el of doc.querySelectorAll("[data-composer-input]")) {
- for (const name of ATTRIBUTES) {
- const current = el.getAttribute(name);
- if (current === label) continue;
- if (!shell.has(el)) shell.set(el, {});
- shell.get(el)[name] = current;
- el.setAttribute(name, label);
- }
- }
- };
- apply2();
- const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
- const watcher = typeof Observer === "function" ? new Observer(apply2) : null;
- watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES });
- return () => {
- watcher?.disconnect();
- for (const [el, values] of shell) {
- if (!el.isConnected) continue;
- for (const [name, value] of Object.entries(values)) {
- if (value == null) el.removeAttribute(name);
- else el.setAttribute(name, value);
- }
- }
- };
+function waitedLabel(ms) {
+ if (!Number.isFinite(ms) || ms < 0) return null;
+ const minutes = Math.floor(ms / 6e4);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ return `${Math.floor(hours / 24)}d`;
}
-
-// src/client/header-effects.js
-function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) {
- if (!doc) return () => {
- };
- const slots = /* @__PURE__ */ new Map();
- const run = (id, dep, setup) => {
- const held = slots.get(id);
- if (held !== void 0 && held.dep === dep) return;
- held?.off();
- slots.set(id, { dep, off: setup() ?? (() => {
- }) });
- };
- let running = false;
- let again = false;
- const pass = () => {
- const list = sessions.getSnapshot();
- const state = roster.getSnapshot();
- const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0);
- const sessionId = row?.id;
- const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId];
- const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId];
- const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
- run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => {
- if (!bot?.remote) return void 0;
- doc.body.dataset.btFriendChat = "";
- return () => {
- delete doc.body.dataset.btFriendChat;
- };
- });
- run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => {
- if (!bot?.remote || row?.blank !== true) return void 0;
- doc.body.dataset.btRemoteChat = "empty";
- doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on.")));
- return () => {
- delete doc.body.dataset.btRemoteChat;
- doc.body.style.removeProperty("--bt-remote-empty");
- };
- });
- run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message")));
- run("visit", sessionId, () => {
- if (sessionId === void 0) return void 0;
- actions.beginVisit(sessionId);
- return () => actions.endVisit(sessionId);
- });
- run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => {
- if (room2?.origin !== "face-to-face") return void 0;
- const stop = actions.filterFaceAtMenu?.(sessionId);
- return typeof stop === "function" ? stop : void 0;
- });
- run("room-flag", `${sessionId}|${room2 !== void 0}`, () => {
- if (room2 === void 0) return void 0;
- doc.body.dataset.btRoom = "";
- return () => {
- delete doc.body.dataset.btRoom;
- };
- });
- };
- const apply2 = () => {
- if (running) {
- again = true;
- return;
- }
- running = true;
- try {
- pass();
- } finally {
- running = false;
- }
- if (again) {
- again = false;
- apply2();
- }
- };
- apply2();
- const offSessions = sessions.subscribe(apply2);
- const offRoster = roster.subscribe(apply2);
- return () => {
- offSessions?.();
- offRoster?.();
- for (const held of slots.values()) held.off();
- slots.clear();
- };
-}
-
-// src/client/composer.js
-var import_react20 = require("react");
-var import_react_dom7 = require("react-dom");
-var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition;
-var keepComposerFocus = (event) => event.preventDefault();
-function useDictation(onText) {
- const [listening, setListening] = (0, import_react20.useState)(false);
- const [note, setNote] = (0, import_react20.useState)("");
- const recognition = (0, import_react20.useRef)(null);
- const latest = (0, import_react20.useRef)(onText);
- latest.current = onText;
- (0, import_react20.useEffect)(() => () => recognition.current?.abort(), []);
- (0, import_react20.useEffect)(() => {
- if (!note) return void 0;
- const timer = setTimeout(() => setNote(""), 2600);
- return () => clearTimeout(timer);
- }, [note]);
- const start = ({ continuous = true, onEnd } = {}) => {
- const Api = speechApi();
- if (!Api) {
- setNote(t("Dictation is not available in this browser"));
- return false;
- }
- const engine = new Api();
- engine.lang = navigator.language || "zh-CN";
- engine.interimResults = true;
- engine.continuous = continuous;
- engine.onresult = (event) => {
- let interim = "";
- for (let index = event.resultIndex; index < event.results.length; index += 1) {
- const result = event.results[index];
- const text2 = result[0]?.transcript ?? "";
- if (result.isFinal) {
- if (text2.trim()) latest.current(text2, true);
- } else interim += text2;
- }
- if (interim) latest.current(interim, false);
- };
- engine.onerror = (event) => {
- if (event.error === "aborted" || event.error === "no-speech") return;
- setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that"));
- };
- engine.onend = () => {
- setListening(false);
- recognition.current = null;
- onEnd?.();
+var METRIC_CARDS = [
+ { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." },
+ { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." },
+ { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." },
+ { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." },
+ { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." },
+ { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." },
+ { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." }
+];
+var METRIC_DASH = "—";
+var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]);
+var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3;
+var metricText = (key, value) => {
+ if (!Number.isFinite(value)) return METRIC_DASH;
+ if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH;
+ return String(value);
+};
+function metricsView(metrics, { tasks } = {}) {
+ const source = metrics !== null && typeof metrics === "object" ? metrics : null;
+ const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {};
+ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
+ const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => {
+ const id = text(row.id);
+ const task = byId.get(id);
+ return {
+ id,
+ key: "task:" + id,
+ title: text(row.title) || text(task?.title) || text(task?.note) || id,
+ status: text(row.status),
+ who: text(row.who) || null,
+ sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null,
+ source: text(row.source) || null,
+ lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null,
+ overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS,
+ board: boardOfRow({ source: text(row.source) }, task)
};
- recognition.current = engine;
- try {
- engine.start();
- setListening(true);
- return true;
- } catch {
- setNote(t("Couldn't start dictation"));
- return false;
- }
+ });
+ return {
+ available: source?.ok === true,
+ reason: text(source?.reason) || null,
+ cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })),
+ running,
+ empty: source === null,
+ emptyText: "No metrics read yet."
};
- const stop = () => recognition.current?.stop();
- return { listening, note, start, stop };
}
-function ComposerIdentity({ sessionId, useRoster }) {
- const roster = useRoster((value) => value);
- const room2 = roster.roomsById?.[sessionId];
- const self = roster.self;
- const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0);
- if (room2?.viewerRole === "invited-owner") {
- return (0, import_react20.createElement)(
- "span",
- { className: "bt-composer-as", title: t("You can remove your Bot from this room") },
- t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") })
- );
- }
- if (!self || !room2 || humans.length < 2) return null;
- return (0, import_react20.createElement)(
- "span",
- { className: "bt-composer-as", title: t("Messages in this room are attributed to you") },
- t("Sending as {name}", { name: self.displayName || self.memberId })
+function ProjectMetrics({ view }) {
+ return (0, import_react19.createElement)(
+ "section",
+ { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" },
+ view.available || view.reason === null ? null : (0, import_react19.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-metric-cards" },
+ view.cards.map((card) => (0, import_react19.createElement)(
+ "div",
+ { key: card.key, className: "bt-project-metric", "data-metric": card.key },
+ (0, import_react19.createElement)("span", { className: "bt-project-metric-value" }, card.value),
+ (0, import_react19.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)),
+ (0, import_react19.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption))
+ ))
+ )
);
}
-function ComposerVoice({ sessionId, inputActions, actions, useUi }) {
- const dictation = useDictation((text2, final) => {
- if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion());
- });
- const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null);
- (0, import_react20.useEffect)(() => {
- if (!quote || !inputActions) return;
- actions.clearQuote(quote.token);
- const excerpt = quote.text.replace(/\s+/g, " ").trim();
- const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt}
-
-`;
- inputActions.insertText(line2, inputActions.captureInsertion());
- const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]');
- if (box instanceof HTMLElement) {
- box.focus();
- const selection = window.getSelection();
- selection?.selectAllChildren(box);
- selection?.collapseToEnd();
- }
- }, [quote?.token]);
- if (sessionId === void 0) return null;
- const pluginMic = actions.speech() !== void 0;
- return (0, import_react20.createElement)(
- import_react20.Fragment,
- null,
- pluginMic ? null : (0, import_react20.createElement)("button", {
- type: "button",
- className: "bt-mic",
- "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"),
- "aria-pressed": dictation.listening,
- title: dictation.listening ? t("Stop dictation") : t("Dictate"),
- onMouseDown: keepComposerFocus,
- onClick: () => dictation.listening ? dictation.stop() : dictation.start()
- }, (0, import_react20.createElement)(MicIcon)),
- (0, import_react20.createElement)("button", {
- type: "button",
- className: "bt-voice",
- "aria-label": t("Start voice chat"),
- title: t("Voice chat"),
- onMouseDown: keepComposerFocus,
- onClick: () => actions.openVoice(sessionId)
- }, (0, import_react20.createElement)(WaveIcon, { size: 18 })),
- dictation.note ? (0, import_react20.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null
+function ProjectRunningTable({ view, onOpen }) {
+ if (view.running.length === 0) return (0, import_react19.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react19.createElement)("h4", null, t("Running tasks")), (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running.")));
+ return (0, import_react19.createElement)(
+ "section",
+ { className: "bt-project-running", "data-running": String(view.running.length) },
+ (0, import_react19.createElement)("h4", null, t("Running tasks")),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-running-head" },
+ (0, import_react19.createElement)("span", null, t("Task")),
+ (0, import_react19.createElement)("span", null, t("Status")),
+ (0, import_react19.createElement)("span", null, t("Who")),
+ (0, import_react19.createElement)("span", null, t("Claimed for")),
+ (0, import_react19.createElement)("span", null, t("Source")),
+ (0, import_react19.createElement)("span", null, t("Last activity"))
+ ),
+ view.running.map((row) => {
+ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+ return (0, import_react19.createElement)(
+ "div",
+ {
+ key: row.key,
+ className: "bt-project-running-row",
+ "data-task-id": row.id,
+ "data-overdue": row.overdue ? "true" : "false",
+ role: open === void 0 ? void 0 : "button",
+ tabIndex: open === void 0 ? void 0 : 0,
+ onClick: open
+ },
+ (0, import_react19.createElement)("span", { className: "bt-project-running-title" }, row.title),
+ (0, import_react19.createElement)("span", { className: "bt-project-running-status" }, t(row.status)),
+ (0, import_react19.createElement)("span", null, row.who ?? METRIC_DASH),
+ (0, import_react19.createElement)("span", null, metricText("since", row.sinceMs)),
+ (0, import_react19.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)),
+ (0, import_react19.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale()))
+ );
+ })
);
}
-function menuIcons(roster) {
- const marks = /* @__PURE__ */ new Map();
- return (row) => {
- if (!row.bot) return PeopleIcon;
- const id = row.bot.id;
- if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react20.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size }));
- return marks.get(id);
- };
+function ProjectQueueGroups({ view, onOpen }) {
+ return view.queues.map((entry) => (0, import_react19.createElement)(
+ "section",
+ { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group },
+ (0, import_react19.createElement)("h4", null, t(entry.label)),
+ entry.items.map((row) => {
+ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+ const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason);
+ return (0, import_react19.createElement)(
+ "div",
+ {
+ key: row.key,
+ className: "bt-project-row",
+ "data-task-id": row.id,
+ "data-state": row.state ?? void 0,
+ role: open === void 0 ? void 0 : "button",
+ tabIndex: open === void 0 ? void 0 : 0,
+ onClick: open,
+ onKeyDown: open === void 0 ? void 0 : (event) => {
+ if (event.key === "Enter" || event.key === " ") open();
+ }
+ },
+ (0, import_react19.createElement)(
+ "span",
+ { className: "bt-project-title-wrap" },
+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`)
+ ),
+ note
+ );
+ })
+ ));
}
-function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
- const roster = useRoster((value) => value);
- const [open, setOpen] = (0, import_react20.useState)(null);
- const [shown, leaving] = useLinger(open, 120);
- const button = (0, import_react20.useRef)(null);
- const menu = (0, import_react20.useRef)(null);
- (0, import_react20.useEffect)(() => {
- if (!open) return void 0;
- const onDown = (event) => {
- if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null);
- };
- const onKey = (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- setOpen(null);
- }
- };
- window.addEventListener("mousedown", onDown, true);
- window.addEventListener("keydown", onKey, true);
- return () => {
- window.removeEventListener("mousedown", onDown, true);
- window.removeEventListener("keydown", onKey, true);
- };
- }, [open]);
- const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id;
- if (sessionId === void 0 || target === void 0) return null;
- const toggle = () => {
- if (open) {
- setOpen(null);
- return;
- }
- const rect = button.current.getBoundingClientRect();
- setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 });
- };
- const attach = () => {
- setOpen(null);
- button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click();
- };
- const schedule = () => {
- setOpen(null);
- actions.openSettings("schedules", target);
+var countText = (value) => value === null ? t("unknown") : String(value);
+function ProjectBoardRecord({ record }) {
+ const { decisions, meetings } = record;
+ const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)(
+ "div",
+ { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 },
+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date),
+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
+ (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
+ ));
+ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", ");
+ const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", ");
+ const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting));
+ const levelOf = (meeting) => (0, import_react19.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level));
+ const decidedRows = meetings.decided.map((meeting) => (0, import_react19.createElement)(
+ "div",
+ { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" },
+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date),
+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.question),
+ meeting.owner === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`),
+ proxyRow(meeting),
+ // A decided meeting whose file is gone still reads as decided; the missing path says so
+ // instead of the row disappearing with the file.
+ meeting.path === "" ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react19.createElement)("span", { className: "bt-project-path" }, meeting.path)
+ ));
+ const pendingRows = meetings.pending.map((meeting) => (0, import_react19.createElement)(
+ "div",
+ { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level },
+ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date),
+ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion),
+ levelOf(meeting),
+ recipientText(meeting.recipients) === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)),
+ meeting.advance.allowed ? null : (0, import_react19.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason),
+ proxyRow(meeting),
+ (0, import_react19.createElement)("span", { className: "bt-project-status" }, meeting.status)
+ ));
+ return (0, import_react19.createElement)(
+ "section",
+ { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) },
+ (0, import_react19.createElement)("h4", null, t("Decisions")),
+ decisions.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows,
+ (0, import_react19.createElement)("h4", null, t("Meetings")),
+ meetings.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows,
+ meetings.pending.length === 0 ? null : (0, import_react19.createElement)("h5", null, t("Not decided")),
+ meetings.pending.length === 0 ? null : pendingRows
+ );
+}
+var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : "";
+var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : [];
+function projectTeamView(project) {
+ const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {};
+ const owner = [];
+ const addOwner = (id) => {
+ const clean = text(id);
+ if (clean !== "" && !owner.includes(clean)) owner.push(clean);
};
- const reference = () => {
- setOpen(null);
- actions.referenceChat(sessionId, inputActions?.captureInsertion?.());
+ addOwner(project?.ownerUserId);
+ for (const id of idList(project?.ownerUserIds)) addOwner(id);
+ const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id));
+ const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id));
+ return {
+ chief: botIdOf(project?.chief),
+ agent: botIdOf(project?.assistant),
+ roles: { owner, maintainers, members },
+ empty: owner.length === 0 && maintainers.length === 0 && members.length === 0,
+ // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the
+ // `project-read` record answers no rank, and the panel never re-derives the host's policy.
+ role: text(project?.role),
+ choices: idList(project?.choices)
};
- const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat();
- return (0, import_react20.createElement)(
- import_react20.Fragment,
- null,
- (0, import_react20.createElement)("button", {
- ref: button,
- type: "button",
- className: "bt-plus",
- "aria-label": t("Add"),
- title: t("Add"),
- "aria-haspopup": "menu",
- "aria-expanded": Boolean(open),
- onMouseDown: keepComposerFocus,
- onClick: toggle
- }, (0, import_react20.createElement)(PlusIcon)),
- shown ? (0, import_react_dom7.createPortal)((0, import_react20.createElement)(
+}
+var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react19.createElement)(
+ "div",
+ {
+ key: role,
+ className: "bt-project-team-row",
+ "data-team-role": role,
+ // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
+ ...botId === "" ? { "data-team-state": "not-set" } : {
+ "data-open-bot": botId,
+ role: "button",
+ tabIndex: 0,
+ "aria-label": t("Open {name}'s chat", { name: botId }),
+ onClick: () => onOpenBot?.(botId),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onOpenBot?.(botId);
+ }
+ }
+ }
+ },
+ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, label),
+ botId === "" ? (0, import_react19.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react19.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`),
+ // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a
+ // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set".
+ (0, import_react19.createElement)("button", {
+ key: "dossier",
+ type: "button",
+ className: "bt-project-team-dossier",
+ "data-dossier-role": role,
+ ...botId === "" ? {} : { "data-dossier-bot": botId },
+ "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }),
+ onClick: (event) => {
+ event?.stopPropagation();
+ onOpenDossier?.({ role, label, botId });
+ },
+ onKeyDown: (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event?.stopPropagation();
+ onOpenDossier?.({ role, label, botId });
+ }
+ }
+ }, t("Dossier"))
+);
+var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]];
+function ProjectTeam({ project, onOpenBot, onOpenDossier }) {
+ const view = projectTeamView(project);
+ const poolId = (id) => (0, import_react19.createElement)("button", {
+ key: id,
+ type: "button",
+ className: "bt-project-team-dossier",
+ "data-dossier-role": "pool",
+ "data-dossier-bot": id,
+ "aria-label": t("Open {name}'s dossier", { name: id }),
+ onClick: (event) => {
+ event?.stopPropagation();
+ onOpenDossier?.({ role: "pool", label: t("Role pool"), botId: id });
+ }
+ }, `@${id}`);
+ const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)(
+ "span",
+ { className: "bt-project-team-roles" },
+ POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react19.createElement)(
+ "span",
+ { key: role, className: "bt-project-team-role", "data-pool-role": role },
+ `${roleText(role)}: `,
+ view.roles[key].map((id) => poolId(id))
+ ))
+ );
+ return (0, import_react19.createElement)(
+ "section",
+ { className: "bt-project-team", "data-team": "chief,agent,pool" },
+ (0, import_react19.createElement)("h4", null, t("Project team")),
+ teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier),
+ teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier),
+ (0, import_react19.createElement)(
"div",
- {
- ref: menu,
- className: "bt-menu bt-menu-up bt-plus-menu",
- role: "menu",
- "aria-label": t("Add"),
- style: { left: shown.left, bottom: shown.bottom },
- "data-leaving": leaving || void 0
- },
- (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react20.createElement)(PaperclipIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Add files"))),
- canReference ? (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react20.createElement)(ChatsIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null,
- (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react20.createElement)(ClockIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task")))
- ), document.body) : null
+ { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" },
+ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")),
+ ranks,
+ // The caller's own rank and what the host said they may switch to: the same answer the
+ // sidebar menu reads, printed where the pool is.
+ view.role === "" ? null : (0, import_react19.createElement)(
+ "span",
+ { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role },
+ [
+ t("My role"),
+ ": ",
+ roleText(view.role),
+ view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}`
+ ]
+ )
+ )
);
}
-
-// src/client/at-menu.js
-var BOTS_SOURCE = "ds-bot";
-var CHATS_SOURCE = "ds-bot-chats";
-var ROOM_ALL = ["all", "everyone", "所有人", "全体"];
-var OTHER_CHATS_MAX = 8;
-var conversationOf = (roster, sessionId) => {
- if (!roster.ready) return null;
- const room2 = roster.roomsById[sessionId];
- if (room2) return { room: room2 };
- const self = roster.byId[sessionId];
- return self ? { self } : null;
-};
-function mentionRows(roster, sessionId, query = "") {
- const here = conversationOf(roster, sessionId);
- if (here === null) return null;
- const face = isFaceRoom(here.room);
- const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0);
- const bots = here.room ? [
- ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot),
- ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
- // A remote seat already listed under its handle (a duplicate participant record) is offered once.
- ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
- id: p.handle || p.id,
- // A face room inserts the disambiguated title. An ordinary group inserts the name the
- // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
- name: face ? remoteBotTitle(p) : remoteCallName(p),
- kind: "bot",
- remote: p,
- handle: p.handle,
- label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p)
- }))
- ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
- const needle = query.trim().toLowerCase();
- const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` }));
- const everyone = t("Everyone");
- if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) {
- rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` });
- }
- if (face) return rows;
- const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean));
- if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))];
- const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle)));
- return [
- ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))),
- ...remoteMentionRows(roster, query)
- ];
+function dossierRowOf(team, botId) {
+ const id = text(botId);
+ if (id === "") return null;
+ const rows = Array.isArray(team?.rows) ? team.rows : [];
+ return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null;
}
-var isFaceRoom = (room2) => room2?.origin === "face-to-face";
-var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count });
-var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count });
-var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
-var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null;
-var myOwnerName = (roster, live, bot) => {
- const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle));
- return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, "");
-};
-var uniqueBy = (list, key) => {
- const seen = /* @__PURE__ */ new Set();
- return list.filter((item) => {
- const k = key(item);
- if (!k || seen.has(k)) return !k;
- seen.add(k);
- return true;
- });
-};
-var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot;
-function chatRows(roster, list, sessionId) {
- const here = conversationOf(roster, sessionId);
- if (here === null) return [];
- const byId = list?.byId ?? {};
- const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => {
- const id = currentPart(bot);
- return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt };
- });
- const team = /* @__PURE__ */ new Set([
- ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]),
- ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []])
- ]);
- const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt }));
- return [...bots, ...others];
-}
-function sessionMention(sessionId, label) {
- let binary = "";
- for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte);
- const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
- return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`;
+var dossierSection = (title, children) => (0, import_react19.createElement)(
+ "div",
+ { key: title, className: "bt-project-dossier-section" },
+ (0, import_react19.createElement)("h4", null, title),
+ children
+);
+function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) {
+ const known = row !== null && typeof row === "object";
+ const botId = known ? text(row.botId) : "";
+ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : [];
+ const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : [];
+ const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : [];
+ return (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId },
+ (0, import_react19.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`),
+ known ? [
+ dossierSection(t("Persona"), [
+ meta.length === 0 ? null : (0, import_react19.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react19.createElement)("span", { key: value }, value))),
+ (0, import_react19.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set"))
+ ]),
+ dossierSection(t("Memory"), [
+ (0, import_react19.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")),
+ topics.length === 0 ? null : (0, import_react19.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react19.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic)))
+ ]),
+ dossierSection(t("Skills"), (0, import_react19.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", ")))
+ ] : (0, import_react19.createElement)("p", { className: "bt-project-missing" }, t("Not set")),
+ botId === "" || !known ? null : (0, import_react19.createElement)(
+ "div",
+ { key: "actions", className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("button", {
+ type: "button",
+ className: "bt-soft bt-soft-sm",
+ "data-open-bot": botId,
+ onClick: () => onOpenBot?.(botId)
+ }, t("Open chat"))
+ ),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
+ );
}
-function atSources({ roster, sessions, launched, iconFor }) {
- const bots = {
- trigger: "@",
- name: BOTS_SOURCE,
- order: -20,
- showGroupTitle: false,
- async candidates(session, { query }) {
- const snapshot = roster.getSnapshot();
- const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
- const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
- // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
- // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
- // row and header (#498).
- name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"),
- ...row.bot?.role && !face ? { description: row.bot.role } : {},
- ...row.remote ? { description: row.remote.handle } : {},
- ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {},
- // A face room counts the Bots in it, mine and everyone else's (#305, #457).
- ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {},
- icon: iconFor(row),
- section: t("Bots"),
- value: row.text
- }));
- if (mentionRows(snapshot, session.sessionId, "") === null || face) return team;
- const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({
- name: row.name,
- ...row.description ? { description: row.description } : {},
- icon: row.icon ?? "session",
- section: row.section ?? "FlyBot",
- value: row.value
- }));
- return [...team, ...extra];
- },
- onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0
- };
- const chats = {
- trigger: "@",
- name: CHATS_SOURCE,
- order: -10,
- showGroupTitle: false,
- async candidates(session) {
- if (!launched(session.sessionId)) return [];
- return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({
- name: row.bot ? row.bot.name : row.label,
- ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {},
- icon: row.bot ? iconFor(row) : "session",
- section: row.bot ? t("Bots") : t("Conversations"),
- value: JSON.stringify({ sessionId: row.sessionId, label: row.label })
- }));
- },
- onPick({ candidate }) {
- const { sessionId, label } = JSON.parse(candidate.value);
- const mention = sessionMention(sessionId, label);
- return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } };
- },
- codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) }
+function projectAgentFor(bot, selfId) {
+ if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null;
+ const projectId = text(bot.projectId);
+ if (projectId === "") return null;
+ const owner = text(bot.ownerId);
+ const self = text(selfId);
+ if (self !== "" && owner !== "" && owner !== self) return null;
+ return { projectId };
+}
+function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
+ const [agentId, setAgentId] = (0, import_react19.useState)("");
+ const id = text(projectId);
+ const open = async () => {
+ let target = agentId;
+ if (target === "") {
+ try {
+ const record = await (read ?? call)("project-read", { id });
+ target = botIdOf(record?.project?.assistant);
+ } catch {
+ target = "";
+ }
+ if (target !== "") setAgentId(target);
+ }
+ if (target !== "") onOpen?.(target);
};
- return [bots, chats];
+ if (id === "") return null;
+ return (0, import_react19.createElement)("button", {
+ type: "button",
+ className: "bt-project-agent-entry",
+ "data-project-agent": id,
+ "data-open-bot": agentId === "" ? void 0 : agentId,
+ title: t("Open my agent"),
+ "aria-label": t("Open my agent"),
+ onClick: open
+ }, t("My agent"));
+}
+function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
+ const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics });
+ return (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" },
+ (0, import_react19.createElement)("h3", null, view.title),
+ (0, import_react19.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }),
+ // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
+ view.counts === null ? null : (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" },
+ (0, import_react19.createElement)("span", null, [t("Waiting"), ": ", (0, import_react19.createElement)("b", { key: "w" }, countText(view.counts.waiting))]),
+ (0, import_react19.createElement)("span", null, [t("In flight"), ": ", (0, import_react19.createElement)("b", { key: "f" }, countText(view.counts.inflight))]),
+ (0, import_react19.createElement)("span", null, [t("Blocked"), ": ", (0, import_react19.createElement)("b", { key: "b" }, countText(view.counts.blocked))]),
+ (0, import_react19.createElement)("span", null, [t("Done"), ": ", (0, import_react19.createElement)("b", { key: "d" }, countText(view.counts.done))])
+ ),
+ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses
+ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a
+ // control that silently disappeared.
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-actions" },
+ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")),
+ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members"))
+ ),
+ (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }),
+ (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }),
+ (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }),
+ (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }),
+ (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }),
+ (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record })
+ );
}
-
-// src/client/face-at-filter.js
-var REFERENCE_SOURCE = "reference";
-var optionId = (source, index) => `dsh-slash-option-${source}-${index}`;
-var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session";
-var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index;
-function planFaceMenu(state, previous = null) {
- const hidden = [];
- const ready = [];
- let shown = 0;
- let loading = false;
- for (const group of state?.groups ?? []) {
- const items = group.items ?? [];
- if (group.status !== "ready" && items.length === 0) loading = true;
- items.forEach((item, index) => {
- const hide = isSessionRow(group, item);
- if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section });
- else shown += 1;
- if (group.status === "ready") ready.push({ source: group.source, index, hide });
- });
- }
- const open = state?.open === true;
- const blank3 = open && hidden.length > 0 && shown === 0 && !loading;
- const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready");
- return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) };
+function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) {
+ const save = request ?? call;
+ const [kind, setKind] = (0, import_react19.useState)("bot");
+ const [memberId, setMemberId] = (0, import_react19.useState)("");
+ const [note, setNote] = (0, import_react19.useState)("");
+ const [assistantOff, setAssistantOff] = (0, import_react19.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true);
+ const [status, setStatus] = (0, import_react19.useState)("");
+ const id = text(project?.id);
+ const run = async (endpoint, payload, done) => {
+ try {
+ const result = await save(endpoint, payload);
+ setStatus(done);
+ onSaved?.(result);
+ return result;
+ } catch (error) {
+ setStatus(hostText(error?.message ?? String(error)));
+ return null;
+ }
+ };
+ return (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-panel", "data-panel": "project-settings" },
+ (0, import_react19.createElement)("h3", null, t("Project settings")),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-field" },
+ (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)(
+ "select",
+ { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) },
+ (0, import_react19.createElement)("option", { value: "bot" }, t("Bot")),
+ (0, import_react19.createElement)("option", { value: "group" }, t("Group"))
+ ),
+ (0, import_react19.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind"))
+ )
+ ),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-field" },
+ (0, import_react19.createElement)("label", null, t("Project Assistant")),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("label", null, (0, import_react19.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save"))
+ )
+ ),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-field" },
+ // The host knows two memory kinds; the free text the panel takes is recorded as a decision,
+ // never as a third kind the host would refuse (#1820).
+ (0, import_react19.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")),
+ (0, import_react19.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")) }, t("Save memory"))
+ )
+ ),
+ (0, import_react19.createElement)("div", { className: "bt-project-status-line", role: "status" }, status),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
+ );
}
-function steer(ready, highlight, previous) {
- const at = ready.findIndex((row) => same(row, highlight));
- if (at < 0 || !ready[at].hide) return null;
- const from = ready.findIndex((row) => same(row, previous));
- const n = ready.length;
- const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1;
- for (let step = 1; step < n; step += 1) {
- const row = ready[((at + dir * step) % n + n) % n];
- if (!row.hide) return { source: row.source, index: row.index };
+function ProjectMembersPanel({ project, rows, onClose } = {}) {
+ const owners = [];
+ const add = (id) => {
+ const clean = text(id);
+ if (clean !== "" && !owners.includes(clean)) owners.push(clean);
+ };
+ add(project?.ownerUserId);
+ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id);
+ const boardMembers = [];
+ for (const row of Array.isArray(rows) ? rows : []) {
+ const name = text(row?.assignee);
+ if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name);
}
- return null;
-}
-function faceMenuCss({ hidden, blank: blank3 }) {
- if (hidden.length === 0) return "";
- const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`);
- const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`);
- const css = [`${[...rows, ...titles].join(",")}{display:none!important}`];
- if (blank3) css.push("[data-trigger-menu]{display:none!important}");
- return css.join("\n");
+ const chief = text(project?.chief?.botId);
+ return (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-panel", "data-panel": "project-members" },
+ (0, import_react19.createElement)("h3", null, t("Members")),
+ (0, import_react19.createElement)(
+ "ul",
+ { className: "bt-project-members" },
+ owners.map((owner) => (0, import_react19.createElement)(
+ "li",
+ { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner },
+ (0, import_react19.createElement)("span", null, `@${owner}`),
+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Owner"))
+ )),
+ chief === "" ? null : (0, import_react19.createElement)(
+ "li",
+ { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief },
+ (0, import_react19.createElement)("span", null, t("Project Chief")),
+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project"))
+ ),
+ boardMembers.map((name) => (0, import_react19.createElement)(
+ "li",
+ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
+ (0, import_react19.createElement)("span", null, `@${name}`),
+ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
+ ))
+ ),
+ owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
+ (0, import_react19.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
+ (0, import_react19.createElement)(
+ "div",
+ { className: "bt-project-row-actions" },
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+ )
+ );
}
-function watchFaceMenu(controller, doc = globalThis.document) {
- if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => {
+async function readProjectBoard(read, wanted) {
+ const listing = await read("projects");
+ const projects = Array.isArray(listing?.projects) ? listing.projects : [];
+ const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted);
+ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } };
+ const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const [record, board, taskList, team] = await Promise.all([
+ read("project-read", { id: project.id }),
+ read("project-board", { id: project.id, timeZone: zone }),
+ read("tasks", {}),
+ read("project-team", { id: project.id })
+ ]);
+ const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? [];
+ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null };
+ const base = record?.project ?? project;
+ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] };
+ return {
+ project: withRole,
+ assistantMine: record?.assistantMine ?? null,
+ rows: board?.tasks ?? [],
+ done: board?.done ?? [],
+ canManage: record?.canManage === true,
+ tasks,
+ decisions: board?.decisions,
+ meetings: board?.meetings,
+ queues,
+ metrics: board?.metrics ?? null,
+ team: team !== null && typeof team === "object" ? team : { rows: [] }
};
- const style = doc.createElement("style");
- style.setAttribute("data-bt-face-at", "");
- doc.head.appendChild(style);
- let previous = null;
- let stopped = false;
- const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
- const apply2 = () => {
- if (stopped) return;
- const state = controller.menu.getSnapshot();
- const plan = planFaceMenu(state, previous);
- const css = faceMenuCss(plan);
- if (style.textContent !== css) style.textContent = css;
- if (plan.dismiss) later(() => {
- if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss();
- });
- else if (plan.target) later(() => {
- if (!stopped) controller.hover(plan.target.source, plan.target.index);
- });
- else previous = state.open ? state.highlight : null;
- };
- const unsubscribe = controller.menu.subscribe(apply2);
- apply2();
- return () => {
- stopped = true;
- unsubscribe?.();
- style.remove();
- };
-}
-
-// src/client/speech.js
-var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle";
-var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]);
-async function speechCatalog(speech) {
- const result = await speech.catalog();
- if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed");
- const catalog = result.value;
- const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId);
- return { catalog, usable: USABLE.has(provider?.preparation?.phase) };
-}
-function waveBytes(samples, rate = 16e3) {
- const bytes = new Uint8Array(44 + samples.length * 2);
- const view = new DataView(bytes.buffer);
- const ascii = (at, text2) => {
- for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index);
- };
- ascii(0, "RIFF");
- view.setUint32(4, 36 + samples.length * 2, true);
- ascii(8, "WAVEfmt ");
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, 1, true);
- view.setUint32(24, rate, true);
- view.setUint32(28, rate * 2, true);
- view.setUint16(32, 2, true);
- view.setUint16(34, 16, true);
- ascii(36, "data");
- view.setUint32(40, samples.length * 2, true);
- samples.forEach((sample, index) => {
- const value = Math.max(-1, Math.min(1, sample));
- view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true);
- });
- return bytes;
-}
-function base64Of(bytes) {
- let text2 = "";
- for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768));
- return btoa(text2);
}
-var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause });
-async function openCapture() {
- const devices = navigator.mediaDevices;
- if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable");
- let stream;
- try {
- stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
- } catch (error) {
- throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error);
- }
- const audio = new AudioContext();
- const analyser = audio.createAnalyser();
- analyser.fftSize = 512;
- audio.createMediaStreamSource(stream).connect(analyser);
- const frame = new Float32Array(analyser.fftSize);
- const recorder = new MediaRecorder(stream);
- const chunks = [];
- recorder.ondataavailable = (event) => {
- if (event.data.size > 0) chunks.push(event.data);
- };
- recorder.start();
- let closed = false;
- const close = () => {
- if (closed) return;
- closed = true;
- if (recorder.state !== "inactive") recorder.stop();
- stream.getTracks().forEach((track) => track.stop());
- void audio.close().catch(() => {
- });
- };
- return {
- level() {
- if (closed) return 0;
- analyser.getFloatTimeDomainData(frame);
- let sum = 0;
- for (const sample of frame) sum += sample * sample;
- return Math.min(1, Math.sqrt(sum / frame.length) * 4);
- },
- cancel: close,
- async finish(maxSeconds) {
- if (closed || recorder.state === "inactive") {
- close();
- return null;
- }
+var PROJECT_BOARD_POLL_MS = 5e3;
+var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project");
+function ProjectBoardOverlay({ actions, call: request } = {}) {
+ const read = request ?? call;
+ const [state, setState] = (0, import_react19.useState)(null);
+ const [panel, setPanel] = (0, import_react19.useState)(null);
+ const [dossier, setDossier] = (0, import_react19.useState)(null);
+ const [hash, setHash] = (0, import_react19.useState)(() => typeof window === "undefined" ? "" : window.location.hash);
+ const close = (0, import_react19.useCallback)(() => {
+ if (typeof window !== "undefined") window.location.hash = "";
+ setPanel(null);
+ setState(null);
+ }, []);
+ (0, import_react19.useEffect)(() => {
+ if (typeof window === "undefined") return void 0;
+ const onKey = (event) => {
+ if (event.key !== "Escape") return;
+ if (panel !== null) setPanel(null);
+ else close();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [panel, close]);
+ (0, import_react19.useEffect)(() => {
+ if (typeof window === "undefined") return void 0;
+ const sync = () => setHash(window.location.hash);
+ window.addEventListener("hashchange", sync);
+ sync();
+ return () => window.removeEventListener("hashchange", sync);
+ }, []);
+ (0, import_react19.useEffect)(() => {
+ if (typeof window === "undefined") return void 0;
+ if (!hash.startsWith("#project")) {
+ setState(null);
+ return void 0;
+ }
+ const wanted = hash.slice("#project".length).replace(/^=/, "");
+ let gone = false;
+ const loadBoard = () => readProjectBoard(read, wanted);
+ const refresh = async (first) => {
try {
- await new Promise((resolve) => {
- recorder.onstop = resolve;
- recorder.stop();
- });
- stream.getTracks().forEach((track) => track.stop());
- const blob = new Blob(chunks, { type: recorder.mimeType });
- if (blob.size === 0) return null;
- const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
- const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
- const offline = new OfflineAudioContext(1, length, 16e3);
- const source = offline.createBufferSource();
- source.buffer = decoded;
- source.connect(offline.destination);
- source.start();
- return waveBytes((await offline.startRendering()).getChannelData(0));
- } finally {
- close();
+ const next = await loadBoard();
+ if (!gone) setState(next);
+ } catch {
+ if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] });
}
- }
- };
-}
-async function transcribeWave(speech, catalog, wave, signal) {
- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
- return String(result.value?.text ?? "").trim();
-}
-function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
- let spokeAt = 0;
- let quietSince = 0;
- return (level, now) => {
- if (level >= loud) {
- spokeAt ||= now;
- quietSince = 0;
- return "speech";
- }
- if (!spokeAt) return "waiting";
- if (level < quiet) quietSince ||= now;
- return quietSince && now - quietSince >= quietMs ? "done" : "speech";
- };
-}
-
-// src/client/model-menu.js
-var import_react21 = require("react");
-var import_react_dom8 = require("react-dom");
-var MENU_EXIT_MS = 120;
-function groupModels(models) {
- const groups = /* @__PURE__ */ new Map();
- for (const model of models ?? []) {
- const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0];
- if (!groups.has(provider)) groups.set(provider, []);
- groups.get(provider).push(model);
- }
- const names = /* @__PURE__ */ new Map();
- for (const [provider, list] of groups) {
- const name = list[0].providerName ?? provider;
- names.set(name, (names.get(name) ?? 0) + 1);
- }
- return [...groups].map(([provider, list]) => {
- const name = list[0].providerName ?? provider;
- return [names.get(name) > 1 ? `${name} (${provider})` : name, list];
- });
-}
-var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
-var effortCache = /* @__PURE__ */ new Map();
-function useEfforts(served, ref, actions) {
- const [fetched, setFetched] = (0, import_react21.useState)(void 0);
- (0, import_react21.useEffect)(() => {
- setFetched(void 0);
- if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0;
- let live = true;
- if (effortCache.has(ref)) setFetched(effortCache.get(ref));
- else void Promise.resolve(actions.efforts(ref)).then((value) => {
- const reasoning = value?.efforts ? value : null;
- effortCache.set(ref, reasoning);
- if (live) setFetched(reasoning);
- }).catch(() => {
- });
+ };
+ void refresh(true);
+ const timer = setInterval(() => {
+ if (!boardPollPaused()) void refresh(false);
+ }, PROJECT_BOARD_POLL_MS);
return () => {
- live = false;
+ gone = true;
+ clearInterval(timer);
};
- }, [ref, served?.reasoning !== void 0, actions]);
- return served?.reasoning ?? fetched;
-}
-function effortRows(reasoning) {
- if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return [];
- return [
- { effort: void 0, label: t("Default") },
- ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name }))
- ];
-}
-function EffortRows({ reasoning, current, onPick }) {
- return (0, import_react21.createElement)(
- import_react21.Fragment,
- null,
- (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
- effortRows(reasoning).map((row) => (0, import_react21.createElement)(
- "button",
- {
- key: row.effort ?? "default",
- type: "button",
- role: "menuitemradio",
- "aria-checked": row.effort === current,
- onClick: () => onPick(row.effort)
- },
- (0, import_react21.createElement)("span", { className: "bt-menu-label" }, row.label),
- (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react21.createElement)(CheckIcon) : null)
- ))
- );
-}
-function ModelRows({ roster, current, onPick }) {
- return groupModels(roster.models).map(([provider, list]) => (0, import_react21.createElement)(
- import_react21.Fragment,
- { key: list[0].provider ?? provider },
- (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
- list.map((model) => (0, import_react21.createElement)(
- "button",
- {
- key: model.ref,
- type: "button",
- role: "menuitemradio",
- "aria-checked": model.ref === current,
- title: model.ref,
- onClick: () => onPick(model.ref)
- },
- (0, import_react21.createElement)("span", { className: "bt-menu-label" }, model.name),
- model.ref === roster.defaultModel ? (0, import_react21.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
- (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react21.createElement)(CheckIcon) : null)
- ))
- ));
-}
-var Caret = () => (0, import_react21.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
-function useAnchoredMenu() {
- const [place, setPlace] = (0, import_react21.useState)(null);
- const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS);
- const button = (0, import_react21.useRef)(null);
- const menu = (0, import_react21.useRef)(null);
- const close = () => setPlace(null);
- (0, import_react21.useEffect)(() => {
- if (place === null) return void 0;
- const onDown = (event) => {
- if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return;
- close();
- };
- const onKey = (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- close();
- button.current?.focus();
- }
- };
- const onScroll = (event) => {
- if (!menu.current?.contains(event.target)) close();
- };
- window.addEventListener("mousedown", onDown);
- window.addEventListener("keydown", onKey, true);
- window.addEventListener("scroll", onScroll, true);
- return () => {
- window.removeEventListener("mousedown", onDown);
- window.removeEventListener("keydown", onKey, true);
- window.removeEventListener("scroll", onScroll, true);
- };
- }, [place]);
- const open = () => {
- const rect = button.current.getBoundingClientRect();
- const width = Math.max(240, rect.width);
- const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
- const below = window.innerHeight - rect.bottom;
- setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" });
- };
- return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close };
-}
-function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) {
- if (bot?.remote !== void 0) return null;
- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
- const shown = modelOf(bot, roster);
- const current = bot.model ?? bot.appliedModel;
- const served = servedOf(roster, current);
- const reasoning = useEfforts(served, current, actions);
- const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0;
- const currentEffort = picked ?? reasoning?.defaultEffort;
- const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort;
- const toggle = () => {
- if (isOpen) {
- close();
- return;
- }
- void actions?.refreshModels?.();
- open();
- };
- const pick2 = (ref) => {
- close();
- if (onPick) onPick(ref);
- else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref }));
- };
- const pickEffort = (effort) => {
- close();
- if (onPickEffort) onPickEffort(effort);
- else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null }));
- };
- const models = roster.models ?? [];
- return (0, import_react21.createElement)(
- import_react21.Fragment,
- null,
- (0, import_react21.createElement)(
- "button",
- {
- ref: button,
- type: "button",
- className: "bt-model-trigger",
- "data-wide": wide || void 0,
- "aria-haspopup": "menu",
- "aria-expanded": isOpen,
- "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }),
- "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0,
- onClick: toggle
- },
- (0, import_react21.createElement)(
- "span",
- { className: "bt-model-copy" },
- (0, import_react21.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
- shown ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
- ),
- (0, import_react21.createElement)(Caret)
- ),
- shownPlace ? (0, import_react_dom8.createPortal)((0, import_react21.createElement)(
- "div",
- { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 },
- models.length > 0 ? (0, import_react21.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
- effortRows(reasoning).length > 0 ? (0, import_react21.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
- (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
- close();
- openHarnessModels();
- } }, (0, import_react21.createElement)(SlidersIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
- ), document.body) : null
- );
-}
-function ModelChoiceDock({ sessionId, useRoster, actions }) {
- const roster = useRoster((value) => value);
- const bot = roster.byId[sessionId];
- const [busy, setBusy] = (0, import_react21.useState)("");
- const [error, setError] = (0, import_react21.useState)("");
- const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready;
- (0, import_react21.useEffect)(() => {
- if (shown) void actions.refreshModels?.();
- }, [shown, sessionId]);
- if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null;
- if (bot.remote) return bot.remote.gone ? (0, import_react21.createElement)(RemoteGoneDock, { bot }) : null;
- const models = roster.models ?? [];
- const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false;
- if (bot.model && (!lost || models.length === 0)) return null;
- const pick2 = (ref) => {
- setBusy(ref);
- setError("");
- void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => {
- setError(failure?.message ?? String(failure));
- void actions.refreshModels?.();
- }).finally(() => setBusy(""));
- };
- const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name });
- const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" })] : [
- ...groupModels(models).map(([provider, list]) => (0, import_react21.createElement)(
- "div",
- { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider },
- (0, import_react21.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
- (0, import_react21.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react21.createElement)("button", {
- key: model.ref,
- type: "button",
- className: "bt-model-chip",
- disabled: busy !== "",
- "aria-busy": busy === model.ref || void 0,
- "aria-label": `${model.name} · ${provider}`,
- title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref,
- onClick: () => pick2(model.ref)
- }, model.name, busy === model.ref ? (0, import_react21.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
- )),
- (0, import_react21.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react21.createElement)(SlidersIcon), t("Manage models"))
- ];
- return (0, import_react21.createElement)(
+ }, [hash]);
+ if (state === null) return null;
+ return (0, import_react19.createElement)(
"div",
- { className: "bt-model-dock" },
- (0, import_react21.createElement)(
+ { className: "bt-project-overlay" },
+ (0, import_react19.createElement)("div", { className: "bt-scrim", onClick: close }),
+ (0, import_react19.createElement)(
"div",
- { className: "bt-astack", "data-lead": "" },
- (0, import_react21.createElement)(
+ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
+ (0, import_react19.createElement)(
"div",
- { className: "bt-msg" },
- (0, import_react21.createElement)(
- "div",
- { className: "bt-msg-line" },
- (0, import_react21.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react21.createElement)("div", { className: "bt-bubble" }, text2)
- )
- )
- ),
- (0, import_react21.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
- error ? (0, import_react21.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
- );
-}
-function RemoteGoneDock({ bot }) {
- return (0, import_react21.createElement)(
- "div",
- { className: "bt-model-dock", "data-flybot": "remote-gone" },
- (0, import_react21.createElement)(
- "div",
- { className: "bt-astack", "data-lead": "" },
- (0, import_react21.createElement)(
+ { className: "bt-mem-head" },
+ (0, import_react19.createElement)("h2", {}, t("Project board")),
+ (0, import_react19.createElement)("span", { className: "bt-mem-head-gap" }),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
+ ),
+ (0, import_react19.createElement)(
"div",
- { className: "bt-msg" },
- (0, import_react21.createElement)(
+ { className: "bt-project-body" },
+ state.project === null ? (0, import_react19.createElement)(
"div",
- { className: "bt-msg-line" },
- (0, import_react21.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react21.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
- )
+ { className: "bt-project-empty" },
+ (0, import_react19.createElement)("p", null, t("No Project yet — create one.")),
+ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ close();
+ actions?.openSettings?.("projects");
+ } }, t("Open Projects in Settings"))
+ ) : panel === "dossier" ? (0, import_react19.createElement)(ProjectDossierPanel, {
+ row: dossierRowOf(state.team, dossier?.botId),
+ label: dossier?.label ?? "",
+ onClose: () => setPanel(null),
+ onOpenBot: (id) => {
+ close();
+ actions?.openSession?.(id);
+ }
+ }) : panel === "settings" ? (0, import_react19.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react19.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react19.createElement)(ProjectBoardPane, {
+ project: state.project,
+ rows: state.rows,
+ done: state.done,
+ canManage: state.canManage,
+ tasks: state.tasks,
+ decisions: state.decisions,
+ meetings: state.meetings,
+ queues: state.queues,
+ metrics: state.metrics,
+ team: state.team,
+ onSettings: () => setPanel("settings"),
+ onMembers: () => setPanel("members"),
+ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
+ onOpenDossier: (value) => {
+ setDossier(value);
+ setPanel("dossier");
+ },
+ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
+ // so it closes first — otherwise the chat opens behind it.
+ onOpenBot: (id) => {
+ close();
+ actions?.openSession?.(id);
+ },
+ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
+ // lands on that Bot or group. Nothing here re-decides which board that is.
+ onOpen: (board) => {
+ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
+ actions?.openBoard?.();
+ setState(null);
+ }
+ })
)
)
);
}
-function setupChoices(actions) {
- return [
- actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null,
- [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")],
- [t("Open settings to set up a custom model"), openHarnessModels]
- ].filter(Boolean);
-}
-var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md";
-function KeyHelpLink() {
- return (0, import_react21.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
-}
-function missingModelKey(message) {
- const text2 = String(message ?? "");
- if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0;
- return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" };
-}
-function modelKeyChoices(actions, route) {
- if (/^deepseek/i.test(route)) return setupChoices(actions);
- return [[t("Manage models"), openHarnessModels]];
+
+// src/client/header.js
+var import_react20 = require("react");
+var import_react_dom6 = require("react-dom");
+var GoArrow = () => (0, import_react20.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react20.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" }));
+var MEMORY_NOTICE_MS = 3200;
+var noNews = (select) => select({});
+function useMemoryNotice(ids, useMemoryNews) {
+ const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0)));
+ const [shown, setShown] = (0, import_react20.useState)(false);
+ (0, import_react20.useEffect)(() => {
+ const left = seen + MEMORY_NOTICE_MS - Date.now();
+ setShown(seen > 0 && left > 0);
+ if (seen === 0 || left <= 0) return void 0;
+ const timer = setTimeout(() => setShown(false), left);
+ return () => clearTimeout(timer);
+ }, [seen]);
+ return shown;
}
-function TurnErrorCell({ node, t: chatT, actions }) {
- const data = node?.data ?? {};
- const code = typeof data.code === "string" && data.code ? data.code : void 0;
- const raw = String(data.message ?? "");
- const shell = (key, fallback) => {
- const text2 = typeof chatT === "function" ? chatT(key) : void 0;
- return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback;
- };
- const missing = missingModelKey(raw);
- const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed"));
- const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw;
- const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [];
- const deepseek = missing ? /^deepseek/i.test(missing.route) : false;
- return (0, import_react21.createElement)(
- "div",
- { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 },
- (0, import_react21.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
- (0, import_react21.createElement)(
- "div",
- { className: "bt-turn-error-copy" },
- (0, import_react21.createElement)("span", { className: "bt-turn-error-title" }, title),
- (0, import_react21.createElement)("span", { className: "bt-turn-error-message" }, message),
- code ? (0, import_react21.createElement)("code", { className: "bt-turn-error-code" }, code) : null
- ),
- choices.length > 0 ? (0, import_react21.createElement)(
- "div",
- { className: "bt-turn-error-actions" },
- choices.map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
- deepseek ? (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" }) : null
- ) : null
+function MemoryNotice({ shown }) {
+ const inner = (0, import_react20.useRef)(null);
+ const [width, setWidth] = (0, import_react20.useState)(0);
+ const label = t("Memory updated");
+ (0, import_react20.useLayoutEffect)(() => {
+ if (shown) setWidth(inner.current?.scrollWidth ?? 0);
+ }, [label, shown]);
+ return (0, import_react20.createElement)(
+ "span",
+ { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } },
+ (0, import_react20.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react20.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react20.createElement)(MemoryIcon)), label)
);
}
-function DeepSeekSignIn({ useUi, actions }) {
- const open = useUi((state) => state.signIn);
- const entry = open ? actions.signInEntry?.() : null;
- if (!entry) return null;
- return (0, import_react21.createElement)(SignInHost, { key: open, entry, actions });
-}
-function SignInHost({ entry, actions }) {
- const [ops] = (0, import_react21.useState)(() => entry.inject?.() ?? {});
- const close = () => actions.closeSignIn();
- const useHook = (source) => (selector) => (0, import_react21.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
- const [hooks] = (0, import_react21.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
- return (0, import_react21.createElement)(entry.component, {
- ...ops,
- ...hooks,
- t: actions.accountT(),
- complete: close,
- useApiKey: () => {
- close();
- openHarnessOnboarding("deepseek-official");
- }
+function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
+ const roster = useRoster((value) => value);
+ const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? "");
+ const firstTurns = useSessions((list) => {
+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+ return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : "";
});
-}
-var MODEL_CSS = `
-.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px}
-.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135}
-.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere}
-.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135}
-.bt-turn-error-message{color:var(--bt-ink-2)}
-.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
-.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
-.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px}
-.bt-model-help:hover{color:var(--bt-ink)}
-.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px}
-.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease}
-.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)}
-.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
-.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e}
-.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)}
-.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)}
-.bt-model-copy{display:flex;flex-direction:column;min-width:0}
-.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}}
-.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)}
-.bt-model-menu{overflow-y:auto;scrollbar-width:thin}
-.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none}
-.bt-model-group:first-child{padding-top:2px}
-.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)}
-.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards}
-.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)}
-.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
-.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin}
-.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0}
-.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere}
-.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px}
-.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}
-.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)}
-.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
-.bt-model-chip-quiet{color:var(--bt-ink-2)}
-.bt-model-chip svg{width:14px;height:14px}
-.bt-model-chip:disabled{cursor:default}
-.bt-model-chip:disabled:not([aria-busy]){opacity:.5}
-.bt-model-error{margin-left:30px}
-@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}}
-.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite}
-@keyframes bt-model-spin{to{transform:rotate(360deg)}}
-@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}}
-`;
-
-// src/client/model-test.js
-var import_react22 = require("react");
-var FAILURE_COPY = {
- NO_MODEL: "Pick a model first.",
- NO_KEY: "This model has no API key yet. Set one up in the model settings.",
- AUTH: "The API key was rejected. Check it in the model settings.",
- QUOTA: "The account is out of quota.",
- RATE_LIMIT: "Too many requests right now. Try again in a moment.",
- TIMEOUT: "The model did not answer within 30 seconds.",
- NOT_SERVED: "This model is not available now. Check its provider in the model settings.",
- MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.",
- TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.",
- SERVER: "The model service failed. Try again later.",
- INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.",
- UNKNOWN: "The test failed."
-};
-function testOutcome(result, modelName) {
- if (result?.ok === true) {
- const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0;
- return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) };
- }
- const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN";
- return { ok: false, code, text: t(FAILURE_COPY[code]) };
-}
-var testModel = (id) => call("test-model", { id });
-function ModelTestRow({ bot, modelName, test = testModel }) {
- const [state, setState] = (0, import_react22.useState)({ busy: false, outcome: void 0 });
- const live = (0, import_react22.useRef)(true);
- (0, import_react22.useEffect)(() => () => {
- live.current = false;
- }, []);
- (0, import_react22.useEffect)(() => {
- setState({ busy: false, outcome: void 0 });
- }, [bot.id, bot.model]);
- const start = () => {
- if (state.busy) return;
- setState({ busy: true, outcome: void 0 });
- Promise.resolve().then(() => test(bot.id)).then(
- (result) => testOutcome(result, modelName ?? result?.ref ?? ""),
- (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) })
- ).then((outcome2) => {
- if (live.current) setState({ busy: false, outcome: outcome2 });
- });
- };
- const { busy, outcome } = state;
- return (0, import_react22.createElement)(
+ const hiddenTurns = roster.exchangeTurns?.[sessionId];
+ const visit = useUi((state) => state.visits?.[sessionId]);
+ const opening = useTurnClock((state) => {
+ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns);
+ return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : "";
+ });
+ (0, import_react20.useEffect)(() => {
+ if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening));
+ else document.body.style.removeProperty("--bt-first-sep");
+ return () => document.body.style.removeProperty("--bt-first-sep");
+ }, [opening]);
+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
+ const owner = roster.byId[sessionId]?.id ?? sessionId;
+ const doing = useActivity((map) => map[owner]);
+ const bot = roster.byId[sessionId];
+ const projectAgent = projectAgentFor(bot, roster.self?.memberId);
+ const room2 = roster.roomsById[sessionId];
+ const detailsOpen = useUi((state) => state.details === sessionId);
+ const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
+ const face = room2?.origin === "face-to-face";
+ const name = face ? faceRowTitle(full2) : full2;
+ const useNews = useMemoryNews ?? noNews;
+ const memoryIds = bot ? [bot.id] : room2?.members ?? [];
+ const notice = useMemoryNotice(memoryIds, useNews);
+ const changed2 = useNews((map) => latestMemoryChange(map, memoryIds));
+ const memoryNotice = notice && changed2 !== null && roster.memory !== false;
+ return (0, import_react20.createElement)(
"div",
- { className: "bt-set-row bt-bs-row bt-model-test" },
- (0, import_react22.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
- (0, import_react22.createElement)(
- "span",
- { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) },
- busy ? t("Testing…") : outcome === void 0 ? null : outcome.text,
- outcome?.code ? (0, import_react22.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
- )
+ { className: "bt-head" },
+ (0, import_react20.createElement)(
+ "button",
+ {
+ type: "button",
+ className: "bt-pill",
+ "aria-expanded": detailsOpen,
+ "data-memory": memoryNotice ? "updated" : void 0,
+ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"),
+ onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId)
+ },
+ bot ? (0, import_react20.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react20.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null,
+ (0, import_react20.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")),
+ (0, import_react20.createElement)(MemoryNotice, { shown: memoryNotice }),
+ (0, import_react20.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react20.createElement)(GoArrow))
),
- (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
+ (0, import_react20.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""),
+ projectAgent === null ? null : (0, import_react20.createElement)(ProjectAgentEntry, {
+ projectId: projectAgent.projectId,
+ read: call,
+ onOpen: (id) => actions?.openSession?.(id)
+ }),
+ (0, import_react20.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
+ (0, import_react20.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
+ (0, import_react20.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive })
);
}
-var MODEL_TEST_CSS = `
-.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
-.bt-model-test-result:empty{display:none}
-.bt-model-test-result[data-ok=true]{color:#1a7f37}
-.bt-model-test-result[data-ok=false]{color:#c21d2e}
-.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
-/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own
- words ("Test connection" on one line) and takes the room it needs. */
-.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch}
-.bt-model-test>.bt-soft{flex:none;white-space:nowrap}
-@container bt-panel (max-width:300px){
- .bt-model-test{flex-wrap:wrap;row-gap:6px}
- .bt-model-test>.bt-set-copy{flex:1 1 100%}
+function useTranscriptView() {
+ const [view, setView] = (0, import_react20.useState)(null);
+ (0, import_react20.useEffect)(() => {
+ let frame = 0;
+ let watched = null;
+ const observer = new MutationObserver(() => schedule());
+ const measure = () => {
+ frame = 0;
+ const scroller = document.querySelector("[data-conversation-scroll]");
+ if (scroller !== watched) {
+ observer.disconnect();
+ watched = scroller;
+ if (scroller) observer.observe(scroller, { childList: true, subtree: true });
+ }
+ if (!scroller) {
+ setView(null);
+ return;
+ }
+ const box = scroller.getBoundingClientRect();
+ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50;
+ const sep = scroller.querySelector(".bt-new-sep");
+ let divider = null;
+ if (sep) {
+ const at = sep.getBoundingClientRect();
+ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view";
+ }
+ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152;
+ const next = {
+ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
+ divider,
+ center: Math.round(box.left + box.width / 2),
+ top: Math.round(headerBottom + 8),
+ bottom: Math.round(window.innerHeight - box.bottom + composer + 8)
+ };
+ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next);
+ };
+ const schedule = () => {
+ if (!frame) frame = requestAnimationFrame(measure);
+ };
+ document.addEventListener("scroll", schedule, true);
+ window.addEventListener("resize", schedule);
+ schedule();
+ return () => {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ document.removeEventListener("scroll", schedule, true);
+ window.removeEventListener("resize", schedule);
+ };
+ }, []);
+ return view;
}
-`;
-
-// src/client/folder-menu.js
-var import_react23 = require("react");
-var import_react_dom9 = require("react-dom");
-var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path);
-var Tick = ({ on }) => (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react23.createElement)(CheckIcon) : null);
-function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) {
- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
- const [info, setInfo] = (0, import_react23.useState)(null);
- const [view, setView] = (0, import_react23.useState)("list");
- const [draft, setDraft] = (0, import_react23.useState)("");
- const [listing, setListing] = (0, import_react23.useState)(null);
- const [problem, setProblem] = (0, import_react23.useState)("");
- const fallback = group ? t("Each Bot's own folder") : t("Team workspace");
- const toggle = () => {
- if (isOpen) {
- close();
- return;
+function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) {
+ const replies = useSessions((list) => {
+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+ return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0;
+ });
+ const visit = useUi((state) => state.visits?.[sessionId]);
+ const unread = useTurnClock((state) => {
+ if (typeof visit?.seen !== "number") return 0;
+ let count = 0;
+ for (const entry of Object.values(state[sessionId] ?? {})) {
+ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1;
}
- setView("list");
- setProblem("");
- void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] }));
- open();
- };
- const choose = (path) => {
- close();
- if (path !== (value ?? "")) void onPick(path);
- };
- const browse = (path) => {
- setProblem("");
- void Promise.resolve(actions.folderList(path)).then((next) => {
- setListing(next);
- setView("browse");
- }).catch((failure) => setProblem(failure?.message ?? String(failure)));
- };
- const pickNative = () => {
- close();
- void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0));
- };
- const typed = () => {
- const path = draft.trim();
- if (path !== "") choose(path);
- };
- const known = info?.folders ?? [];
- const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known;
- const listView = (0, import_react23.createElement)(
+ return count;
+ });
+ const view = useTranscriptView();
+ const unpinned = view?.unpinned === true;
+ const [release, setRelease] = (0, import_react20.useState)({ base: null, dismissed: false });
+ const [above, setAbove] = (0, import_react20.useState)({ seen: false, dismissed: false });
+ (0, import_react20.useEffect)(() => {
+ setRelease({ base: unpinned ? replies : null, dismissed: false });
+ }, [unpinned]);
+ (0, import_react20.useEffect)(() => {
+ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true });
+ }, [view?.divider]);
+ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns));
+ (0, import_react20.useEffect)(() => {
+ if (above.seen && movedOn) actions?.settleVisit?.(sessionId);
+ }, [above.seen, movedOn, sessionId]);
+ const fresh = release.base === null ? 0 : replies - release.base;
+ const showDown = unpinned && fresh > 0 && !release.dismissed;
+ const showUp = view?.divider === "above" && !above.seen && !above.dismissed;
+ (0, import_react20.useEffect)(() => {
+ const scroller = document.querySelector("[data-conversation-scroll]");
+ if (!scroller) return void 0;
+ scroller.toggleAttribute("data-bt-news", showDown);
+ return () => scroller.removeAttribute("data-bt-news");
+ }, [showDown]);
+ if (!view || !showDown && !showUp) return null;
+ const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages");
+ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react20.createElement)(
"div",
- { key: "list", className: "bt-folder-view" },
- (0, import_react23.createElement)(
+ { className: "bt-news", "data-direction": direction, style },
+ (0, import_react20.createElement)(
"button",
- { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") },
- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, fallback),
- (0, import_react23.createElement)(Tick, { on: !value })
+ { type: "button", className: "bt-news-jump", onClick: onJump },
+ (0, import_react20.createElement)("span", { className: "bt-news-ico" }, (0, import_react20.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)),
+ label(count)
),
- folders.length > 0 ? (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
- folders.map((folder) => (0, import_react23.createElement)(
- "button",
- { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) },
- (0, import_react23.createElement)("span", { className: "bt-folder-copy" }, (0, import_react23.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react23.createElement)("span", { className: "bt-folder-path" }, folder.path)),
- (0, import_react23.createElement)(Tick, { on: folder.path === value })
- )),
- (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- info?.picker === "native" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
- info?.picker === "browse" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
- (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
- setDraft(value ?? "");
- setView("type");
- } }, (0, import_react23.createElement)(PencilIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
- );
- const typeView = (0, import_react23.createElement)(
- "div",
- { key: "type", className: "bt-folder-view" },
- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
- (0, import_react23.createElement)("input", {
- className: "bt-folder-input",
- autoFocus: true,
- value: draft,
- spellCheck: false,
- "aria-label": t("Folder path"),
- placeholder: t("Full path of a folder"),
- onChange: (event) => setDraft(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
+ (0, import_react20.createElement)("button", {
+ type: "button",
+ className: "bt-news-x",
+ "aria-label": t("Dismiss new messages"),
+ onClick: (event) => {
+ event.stopPropagation();
+ onDismiss();
}
- }),
- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
+ }, (0, import_react20.createElement)(CloseIcon))
);
- const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
- const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
- const browseView = listing ? (0, import_react23.createElement)(
- "div",
- { key: `browse:${listing.path}`, className: "bt-folder-view" },
- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
- (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
- parent ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
- inside.map((entry) => (0, import_react23.createElement)(
- "button",
- { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
- (0, import_react23.createElement)(FolderIcon),
- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, entry.name),
- (0, import_react23.createElement)(ChevronRightIcon)
- )),
- inside.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
- listing.truncated ? (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
- (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
- ) : null;
- const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
- return (0, import_react23.createElement)(
- import_react23.Fragment,
- null,
- (0, import_react23.createElement)(
- "button",
- {
- ref: button,
- type: "button",
- className: "bt-model-trigger",
- "data-wide": wide || void 0,
- "aria-haspopup": "menu",
- "aria-expanded": isOpen,
- "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
- title: value,
- onClick: toggle
- },
- (0, import_react23.createElement)(
- "span",
- { className: "bt-model-copy" },
- (0, import_react23.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
- value ? (0, import_react23.createElement)("span", { className: "bt-model-provider" }, value) : null
- ),
- (0, import_react23.createElement)(Caret)
+ const toLatest = () => {
+ const scroller = document.querySelector("[data-conversation-scroll]");
+ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button');
+ if (back) back.click();
+ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
+ };
+ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" });
+ return (0, import_react_dom6.createPortal)(
+ (0, import_react20.createElement)(
+ import_react20.Fragment,
+ null,
+ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null,
+ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null
),
- shownPlace ? (0, import_react_dom9.createPortal)((0, import_react23.createElement)(
- "div",
- { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
- shownView,
- problem ? (0, import_react23.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
- ), document.body) : null
+ document.body
);
}
-var FOLDER_CSS = `
-.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
-.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
-.bt-folder-copy .bt-menu-label{flex:none}
-.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
-.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
-.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
-.bt-folder-input:focus{border-color:var(--bt-accent)}
-.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
-.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
-.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
-@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
-`;
-// src/client/schedules.js
-var import_react24 = require("react");
-var POLL_MS4 = 3e4;
-var shared = null;
-var listeners = /* @__PURE__ */ new Set();
-function publish(view) {
- shared = view;
- for (const listener of listeners) listener(view);
-}
-function useSchedules(actions) {
- const [view, setView] = (0, import_react24.useState)(shared);
- (0, import_react24.useEffect)(() => {
- listeners.add(setView);
- let stopped = false;
- let timer;
- const load = () => actions.schedules?.().then((next) => {
- if (!stopped) publish(next);
- }).catch(() => {
- }).finally(() => {
- if (!stopped) timer = setTimeout(load, POLL_MS4);
- });
- load();
- return () => {
- stopped = true;
- clearTimeout(timer);
- listeners.delete(setView);
- };
- }, []);
- return view;
-}
-var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
-var clock2 = (time) => String(time ?? "").slice(0, 5);
-var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
-function dayAndTime(ms) {
- const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
- return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
-}
-function weekdayNames(days) {
- const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
- return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
-}
-function everyText(seconds) {
- if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
- if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
- if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 });
- return t("Every {count} seconds", { count: seconds });
-}
-function timingText(task) {
- const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
- switch (task.kind) {
- case "after":
- case "at":
- return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
- case "every":
- return everyText(task.everySeconds);
- case "daily":
- return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
- case "weekly": {
- const days = (task.weekdays ?? []).join();
- const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
- return `${text2}${zone}`;
- }
- case "cron":
- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
- default:
- return "";
- }
-}
-var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
-function lastRunText(run) {
- if (run === void 0 || !Number.isFinite(run.at)) return "";
- const time = dayAndTime(run.at);
- switch (run.result) {
- case "running":
- return t("Running since {time}", { time });
- case "stopped":
- return t("Last run {time} · Stopped", { time });
- case "failed":
- return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") });
- default:
- return t("Last run {time}", { time });
- }
-}
-function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) {
- const [confirming, setConfirming] = (0, import_react24.useState)(false);
- const [busy, setBusy] = (0, import_react24.useState)(false);
- (0, import_react24.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 3e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- const remove = () => {
- if (!confirming) {
- setConfirming(true);
- return;
+// src/client/composer-label.js
+var ATTRIBUTES = ["aria-label", "data-placeholder"];
+function holdComposerLabel(doc, label) {
+ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label));
+ const shell = /* @__PURE__ */ new Map();
+ const apply2 = () => {
+ for (const el of doc.querySelectorAll("[data-composer-input]")) {
+ for (const name of ATTRIBUTES) {
+ const current = el.getAttribute(name);
+ if (current === label) continue;
+ if (!shell.has(el)) shell.set(el, {});
+ shell.get(el)[name] = current;
+ el.setAttribute(name, label);
+ }
}
- setBusy(true);
- void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
};
- const toggle = () => {
- setBusy(true);
- const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause;
- void step(task.sessionId, task.id).then((next) => {
- setBusy(false);
- onView(next);
- }, () => setBusy(false));
+ apply2();
+ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
+ const watcher = typeof Observer === "function" ? new Observer(apply2) : null;
+ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES });
+ return () => {
+ watcher?.disconnect();
+ for (const [el, values] of shell) {
+ if (!el.isConnected) continue;
+ for (const [name, value] of Object.entries(values)) {
+ if (value == null) el.removeAttribute(name);
+ else el.setAttribute(name, value);
+ }
+ }
};
- const [retryError, setRetryError] = (0, import_react24.useState)("");
- const retry = () => {
- setBusy(true);
- setRetryError("");
- void actions.scheduleRetry(task.sessionId, task.id).then((next) => {
- setBusy(false);
- onView(next);
- }, (error) => {
- setBusy(false);
- setRetryError(hostText(error?.message ?? String(error)));
+}
+
+// src/client/header-effects.js
+function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) {
+ if (!doc) return () => {
+ };
+ const slots = /* @__PURE__ */ new Map();
+ const run = (id, dep, setup) => {
+ const held = slots.get(id);
+ if (held !== void 0 && held.dep === dep) return;
+ held?.off();
+ slots.set(id, { dep, off: setup() ?? (() => {
+ }) });
+ };
+ let running = false;
+ let again = false;
+ const pass = () => {
+ const list = sessions.getSnapshot();
+ const state = roster.getSnapshot();
+ const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0);
+ const sessionId = row?.id;
+ const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId];
+ const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId];
+ const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
+ run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => {
+ if (!bot?.remote) return void 0;
+ doc.body.dataset.btFriendChat = "";
+ return () => {
+ delete doc.body.dataset.btFriendChat;
+ };
+ });
+ run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => {
+ if (!bot?.remote || row?.blank !== true) return void 0;
+ doc.body.dataset.btRemoteChat = "empty";
+ doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on.")));
+ return () => {
+ delete doc.body.dataset.btRemoteChat;
+ doc.body.style.removeProperty("--bt-remote-empty");
+ };
+ });
+ run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message")));
+ run("visit", sessionId, () => {
+ if (sessionId === void 0) return void 0;
+ actions.beginVisit(sessionId);
+ return () => actions.endVisit(sessionId);
+ });
+ run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => {
+ if (room2?.origin !== "face-to-face") return void 0;
+ const stop = actions.filterFaceAtMenu?.(sessionId);
+ return typeof stop === "function" ? stop : void 0;
+ });
+ run("room-flag", `${sessionId}|${room2 !== void 0}`, () => {
+ if (room2 === void 0) return void 0;
+ doc.body.dataset.btRoom = "";
+ return () => {
+ delete doc.body.dataset.btRoom;
+ };
});
};
- const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
- const stoppable = task.status === "active" || task.status === "paused";
- const failed = task.lastRun?.result === "failed";
- const last = lastRunText(task.lastRun);
- return (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 },
- showBot ? (0, import_react24.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
- (0, import_react24.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react24.createElement)("span", { className: "bt-task-title" }, task.title),
- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
- task.prompt && task.prompt !== task.title ? (0, import_react24.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
- last ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
- retryError ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
- ),
- (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)),
- failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
- onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null,
- onCopy ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react24.createElement)(DuplicateIcon)) : null,
- stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
- (0, import_react24.createElement)("button", {
- type: "button",
- className: "bt-mini bt-task-remove",
- "data-confirm": confirming || void 0,
- disabled: busy,
- "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
- title: confirming ? void 0 : t("Delete task"),
- onClick: remove
- }, confirming ? t("Delete") : (0, import_react24.createElement)(TrashIcon))
- );
-}
-var pad = (number) => String(number).padStart(2, "0");
-function inAnHour() {
- const date = new Date(Date.now() + 36e5);
- date.setMinutes(0, 0, 0);
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
-}
-var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
-var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]];
-function localInput(ms) {
- const date = new Date(ms);
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
-}
-function formStart(task) {
- const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600;
- const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1];
- const once = task?.kind === "at" || task?.kind === "after";
- return {
- title: task?.title ?? "",
- prompt: task?.prompt ?? "",
- kind: once ? "at" : task?.kind ?? "daily",
- at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(),
- time: task?.time ? clock2(task.time) : "09:00",
- weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5],
- count: Math.max(1, Math.round(seconds / size)),
- unit,
- timeZone: task?.timeZone || localZone()
+ const apply2 = () => {
+ if (running) {
+ again = true;
+ return;
+ }
+ running = true;
+ try {
+ pass();
+ } finally {
+ running = false;
+ }
+ if (again) {
+ again = false;
+ apply2();
+ }
};
-}
-function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
- const first = formStart(task);
- const [target, setTarget] = (0, import_react24.useState)(task ? task.botId ?? task.roomId : start ?? "");
- const [title, setTitle] = (0, import_react24.useState)(first.title);
- const [prompt, setPrompt] = (0, import_react24.useState)(first.prompt);
- const [kind, setKind] = (0, import_react24.useState)(first.kind);
- const [at, setAt] = (0, import_react24.useState)(first.at);
- const [time, setTime] = (0, import_react24.useState)(first.time);
- const [weekdays, setWeekdays] = (0, import_react24.useState)(first.weekdays);
- const [count, setCount] = (0, import_react24.useState)(first.count);
- const [unit, setUnit] = (0, import_react24.useState)(first.unit);
- const [busy, setBusy] = (0, import_react24.useState)(false);
- const [error, setError] = (0, import_react24.useState)("");
- const timing = () => {
- if (kind === "cron") return { kind: "keep" };
- if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone };
- if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
- return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} };
+ apply2();
+ const offSessions = sessions.subscribe(apply2);
+ const offRoster = roster.subscribe(apply2);
+ return () => {
+ offSessions?.();
+ offRoster?.();
+ for (const held of slots.values()) held.off();
+ slots.clear();
};
- const submit = (event) => {
- event.preventDefault();
- if (busy || target === "") return;
- setBusy(true);
- setError("");
- let when3;
+}
+
+// src/client/composer.js
+var import_react21 = require("react");
+var import_react_dom7 = require("react-dom");
+var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition;
+var keepComposerFocus = (event) => event.preventDefault();
+function useDictation(onText) {
+ const [listening, setListening] = (0, import_react21.useState)(false);
+ const [note, setNote] = (0, import_react21.useState)("");
+ const recognition = (0, import_react21.useRef)(null);
+ const latest = (0, import_react21.useRef)(onText);
+ latest.current = onText;
+ (0, import_react21.useEffect)(() => () => recognition.current?.abort(), []);
+ (0, import_react21.useEffect)(() => {
+ if (!note) return void 0;
+ const timer = setTimeout(() => setNote(""), 2600);
+ return () => clearTimeout(timer);
+ }, [note]);
+ const start = ({ continuous = true, onEnd } = {}) => {
+ const Api = speechApi();
+ if (!Api) {
+ setNote(t("Dictation is not available in this browser"));
+ return false;
+ }
+ const engine = new Api();
+ engine.lang = navigator.language || "zh-CN";
+ engine.interimResults = true;
+ engine.continuous = continuous;
+ engine.onresult = (event) => {
+ let interim = "";
+ for (let index = event.resultIndex; index < event.results.length; index += 1) {
+ const result = event.results[index];
+ const text2 = result[0]?.transcript ?? "";
+ if (result.isFinal) {
+ if (text2.trim()) latest.current(text2, true);
+ } else interim += text2;
+ }
+ if (interim) latest.current(interim, false);
+ };
+ engine.onerror = (event) => {
+ if (event.error === "aborted" || event.error === "no-speech") return;
+ setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that"));
+ };
+ engine.onend = () => {
+ setListening(false);
+ recognition.current = null;
+ onEnd?.();
+ };
+ recognition.current = engine;
try {
- when3 = timing();
+ engine.start();
+ setListening(true);
+ return true;
} catch {
- setError(t("Pick a date and time"));
- setBusy(false);
- return;
+ setNote(t("Couldn't start dictation"));
+ return false;
}
- const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 });
- void saved.then(onDone, (failure) => {
- setError(hostText(failure?.message ?? String(failure)));
- setBusy(false);
- });
};
- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
- return (0, import_react24.createElement)(
- "form",
- { className: "bt-set-card bt-task-form", onSubmit: submit },
- (0, import_react24.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
- (0, import_react24.createElement)(
- "select",
- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
- (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
- (0, import_react24.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react24.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
- roster.rooms.length > 0 ? (0, import_react24.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react24.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
- )
- ),
- (0, import_react24.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
- (0, import_react24.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
- ),
- (0, import_react24.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
- (0, import_react24.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
- ),
- (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("When"))),
- (0, import_react24.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
- (0, import_react24.createElement)(
- "div",
- { key: kind, className: "bt-task-when bt-veil" },
- kind === "cron" ? (0, import_react24.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
- kind === "at" ? (0, import_react24.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
- kind === "weekly" ? (0, import_react24.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react24.createElement)("button", {
- key: day,
- type: "button",
- className: "bt-task-day",
- "aria-pressed": weekdays.includes(day),
- onClick: () => toggleDay(day)
- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
- kind === "daily" || kind === "weekly" ? (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
- kind === "every" ? (0, import_react24.createElement)(
- import_react24.Fragment,
- null,
- (0, import_react24.createElement)("span", { className: "bt-task-every" }, t("Every")),
- (0, import_react24.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
- (0, import_react24.createElement)(
- "select",
- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
- UNITS.map(([id, , label]) => (0, import_react24.createElement)("option", { key: id, value: id }, t(label)))
- )
- ) : null
- )
- ),
- error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
- (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-task-foot" },
- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
- (0, import_react24.createElement)(
- "button",
- { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" },
- task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task")
- )
- )
- );
-}
-function openPlugins(actions) {
- actions.closeSettings();
- actions.selectPanel("plugins");
-}
-function AutomationOff({ actions }) {
- return (0, import_react24.createElement)(
- "div",
- { className: "bt-set-card bt-task-off" },
- (0, import_react24.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react24.createElement)(ClockIcon)),
- (0, import_react24.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
- );
+ const stop = () => recognition.current?.stop();
+ return { listening, note, start, stop };
}
-function QuietHoursCard({ view, actions }) {
- const quiet = view.quietHours ?? { on: false };
- const [busy, setBusy] = (0, import_react24.useState)(false);
- const [error, setError] = (0, import_react24.useState)("");
- const [from, setFrom] = (0, import_react24.useState)(quiet.from ?? "22:00");
- const [to, setTo] = (0, import_react24.useState)(quiet.to ?? "07:00");
- const unchecked = view.quietUnchecked === true;
- const refresh = () => {
- void Promise.resolve(actions.schedules?.()).then((next) => {
- if (next) publish(next);
- }).catch(() => {
- });
- };
- const save = (next) => {
- if (busy) return;
- setBusy(true);
- setError("");
- const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false };
- void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => {
- publish({ ...view, quietHours: shown });
- refresh();
- }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
- };
- return (0, import_react24.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react24.createElement)("h3", null, t("Quiet hours")),
- (0, import_react24.createElement)(
- "div",
- { className: "bt-set-card" },
- (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react24.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react24.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")),
- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists."))
- ),
- (0, import_react24.createElement)("button", {
- type: "button",
- className: "bt-soft bt-soft-sm",
- disabled: busy,
- "aria-pressed": quiet.on,
- onClick: () => save(quiet.on ? { on: false } : { on: true, from, to })
- }, quiet.on ? t("Turn off") : t("Turn on"))
- ),
- quiet.on ? (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Window"))),
- (0, import_react24.createElement)(
- "div",
- { className: "bt-task-when" },
- (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }),
- (0, import_react24.createElement)("span", { className: "bt-task-every" }, "–"),
- (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }),
- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window"))
- )
- ) : null,
- error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
- quiet.on && unchecked ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null
- )
+function ComposerIdentity({ sessionId, useRoster }) {
+ const roster = useRoster((value) => value);
+ const room2 = roster.roomsById?.[sessionId];
+ const self = roster.self;
+ const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0);
+ if (room2?.viewerRole === "invited-owner") {
+ return (0, import_react21.createElement)(
+ "span",
+ { className: "bt-composer-as", title: t("You can remove your Bot from this room") },
+ t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") })
+ );
+ }
+ if (!self || !room2 || humans.length < 2) return null;
+ return (0, import_react21.createElement)(
+ "span",
+ { className: "bt-composer-as", title: t("Messages in this room are attributed to you") },
+ t("Sending as {name}", { name: self.displayName || self.memberId })
);
}
-function SchedulesPage({ roster, actions, start }) {
- const view = useSchedules(actions);
- const [adding, setAdding] = (0, import_react24.useState)(Boolean(start));
- const [editing, setEditing] = (0, import_react24.useState)(null);
- if (view === null) return (0, import_react24.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
- if (!view.available) return (0, import_react24.createElement)(AutomationOff, { actions });
- const shown = (next) => {
- publish(next);
- setAdding(false);
- setEditing(null);
- };
- const edit = (task) => {
- setAdding(false);
- setEditing(task);
- };
- const byChat = [
- ...roster.bots.map((bot) => [bot.id, (0, import_react24.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
- ...roster.rooms.map((room2) => [room2.id, (0, import_react24.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
- ].filter(([, , , tasks]) => tasks.length > 0);
- return [
- (0, import_react24.createElement)(QuietHoursCard, { key: "quiet", view, actions }),
- adding ? (0, import_react24.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react24.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
- setEditing(null);
- setAdding(true);
- } }, (0, import_react24.createElement)(PlusIcon), t("New task"))),
- byChat.length === 0 && !adding ? (0, import_react24.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react24.createElement)(
- "section",
- { key: id, className: "bt-set-section" },
- (0, import_react24.createElement)("h3", { className: "bt-task-head" }, avatar, name),
- (0, import_react24.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
- ))
- ];
-}
-function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) {
- const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0;
- const [copying, setCopying] = (0, import_react24.useState)(null);
- const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0;
- const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 }));
- return (0, import_react24.createElement)(
- import_react24.Fragment,
+function ComposerVoice({ sessionId, inputActions, actions, useUi }) {
+ const dictation = useDictation((text2, final) => {
+ if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion());
+ });
+ const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null);
+ (0, import_react21.useEffect)(() => {
+ if (!quote || !inputActions) return;
+ actions.clearQuote(quote.token);
+ const excerpt = quote.text.replace(/\s+/g, " ").trim();
+ const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt}
+
+`;
+ inputActions.insertText(line2, inputActions.captureInsertion());
+ const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]');
+ if (box instanceof HTMLElement) {
+ box.focus();
+ const selection = window.getSelection();
+ selection?.selectAllChildren(box);
+ selection?.collapseToEnd();
+ }
+ }, [quote?.token]);
+ if (sessionId === void 0) return null;
+ const pluginMic = actions.speech() !== void 0;
+ return (0, import_react21.createElement)(
+ import_react21.Fragment,
null,
- open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
- source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => {
- setCopying(null);
- onDone(next);
- }, onCancel: () => setCopying(null) }) : null,
- rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
+ pluginMic ? null : (0, import_react21.createElement)("button", {
+ type: "button",
+ className: "bt-mic",
+ "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"),
+ "aria-pressed": dictation.listening,
+ title: dictation.listening ? t("Stop dictation") : t("Dictate"),
+ onMouseDown: keepComposerFocus,
+ onClick: () => dictation.listening ? dictation.stop() : dictation.start()
+ }, (0, import_react21.createElement)(MicIcon)),
+ (0, import_react21.createElement)("button", {
+ type: "button",
+ className: "bt-voice",
+ "aria-label": t("Start voice chat"),
+ title: t("Voice chat"),
+ onMouseDown: keepComposerFocus,
+ onClick: () => actions.openVoice(sessionId)
+ }, (0, import_react21.createElement)(WaveIcon, { size: 18 })),
+ dictation.note ? (0, import_react21.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null
);
}
-function CopyRow({ task, roster, actions, onView, onCancel }) {
- const others = [
- ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]),
- ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name])
- ];
- const [target, setTarget] = (0, import_react24.useState)("");
- const [busy, setBusy] = (0, import_react24.useState)(false);
- const [error, setError] = (0, import_react24.useState)("");
- const submit = (event) => {
- event.preventDefault();
- if (busy || target === "") return;
- setBusy(true);
- setError("");
- void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => {
- setBusy(false);
- setError(hostText(failure?.message ?? String(failure)));
- });
+function menuIcons(roster) {
+ const marks = /* @__PURE__ */ new Map();
+ return (row) => {
+ if (!row.bot) return PeopleIcon;
+ const id = row.bot.id;
+ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react21.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size }));
+ return marks.get(id);
};
- return (0, import_react24.createElement)(
- "form",
- { className: "bt-set-row bt-task-copy", onSubmit: submit },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))),
- (0, import_react24.createElement)(
- "select",
- { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) },
- (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
- others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name))
- ),
- (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")),
- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
- error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null
- );
}
-function BotSchedules({ bot, roster, actions }) {
- const view = useSchedules(actions);
- const [editing, setEditing] = (0, import_react24.useState)(null);
- if (view === null) return null;
- const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
- return (0, import_react24.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react24.createElement)("h3", null, t("Scheduled tasks")),
- !view.available ? (0, import_react24.createElement)(
+function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
+ const roster = useRoster((value) => value);
+ const [open, setOpen] = (0, import_react21.useState)(null);
+ const [shown, leaving] = useLinger(open, 120);
+ const button = (0, import_react21.useRef)(null);
+ const menu = (0, import_react21.useRef)(null);
+ (0, import_react21.useEffect)(() => {
+ if (!open) return void 0;
+ const onDown = (event) => {
+ if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null);
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setOpen(null);
+ }
+ };
+ window.addEventListener("mousedown", onDown, true);
+ window.addEventListener("keydown", onKey, true);
+ return () => {
+ window.removeEventListener("mousedown", onDown, true);
+ window.removeEventListener("keydown", onKey, true);
+ };
+ }, [open]);
+ const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id;
+ if (sessionId === void 0 || target === void 0) return null;
+ const toggle = () => {
+ if (open) {
+ setOpen(null);
+ return;
+ }
+ const rect = button.current.getBoundingClientRect();
+ setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 });
+ };
+ const attach = () => {
+ setOpen(null);
+ button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click();
+ };
+ const schedule = () => {
+ setOpen(null);
+ actions.openSettings("schedules", target);
+ };
+ const reference = () => {
+ setOpen(null);
+ actions.referenceChat(sessionId, inputActions?.captureInsertion?.());
+ };
+ const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat();
+ return (0, import_react21.createElement)(
+ import_react21.Fragment,
+ null,
+ (0, import_react21.createElement)("button", {
+ ref: button,
+ type: "button",
+ className: "bt-plus",
+ "aria-label": t("Add"),
+ title: t("Add"),
+ "aria-haspopup": "menu",
+ "aria-expanded": Boolean(open),
+ onMouseDown: keepComposerFocus,
+ onClick: toggle
+ }, (0, import_react21.createElement)(PlusIcon)),
+ shown ? (0, import_react_dom7.createPortal)((0, import_react21.createElement)(
"div",
- { className: "bt-set-card" },
- (0, import_react24.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
- )
- ) : (0, import_react24.createElement)(TaskList, {
- tasks,
- editing,
- roster,
- actions,
- onEdit: setEditing,
- onDone: (next) => {
- publish(next);
- setEditing(null);
+ {
+ ref: menu,
+ className: "bt-menu bt-menu-up bt-plus-menu",
+ role: "menu",
+ "aria-label": t("Add"),
+ style: { left: shown.left, bottom: shown.bottom },
+ "data-leaving": leaving || void 0
},
- onCancel: () => setEditing(null),
- extra: [(0, import_react24.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react24.createElement)(PlusIcon), t("New task"))]
- })
+ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react21.createElement)(PaperclipIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Add files"))),
+ canReference ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react21.createElement)(ChatsIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null,
+ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react21.createElement)(ClockIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task")))
+ ), document.body) : null
);
}
-function ScheduleSummary({ bot, actions }) {
- const view = useSchedules(actions);
- const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
- return (0, import_react24.createElement)(
- "div",
- null,
- (0, import_react24.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
- tasks.length === 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react24.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react24.createElement)(
- "button",
- { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") },
- (0, import_react24.createElement)(ClockIcon),
- (0, import_react24.createElement)("span", { className: "bt-task-line-title" }, task.title),
- (0, import_react24.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
- )))
- );
-}
-var SCHEDULE_CSS = `
-.bt-task{gap:10px}
-.bt-task-copy{flex-wrap:wrap;gap:8px}
-.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%}
-.bt-task[data-done]{opacity:.6}
-.bt-task[data-busy]{opacity:.4}
-.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere}
-.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
-.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
-.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
-.bt-task-remove svg{width:14px;height:14px}
-.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
-.bt-task-tool svg{width:14px;height:14px}
-.bt-task-pause{height:26px;color:var(--bt-ink-2)}
-.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere}
-.bt-task-last[data-failed]{color:#c21d2e}
-.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)}
-.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135}
-.bt-task-head{display:flex;align-items:center;gap:6px}
-.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
-.bt-task-bot{width:auto;min-width:160px;max-width:58%}
-.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
-.bt-task-when .bt-input{width:auto;height:32px}
-.bt-task-time{min-width:110px}
-.bt-task-count{width:72px!important}
-.bt-task-unit{width:auto;height:32px}
-.bt-task-every{font-size:13px;color:var(--bt-ink-2)}
-.bt-task-days{display:flex;gap:4px}
-.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease}
-.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)}
-.bt-task-foot{justify-content:flex-end;gap:8px}
-.bt-task-foot .bt-send{height:28px;font-size:12px}
-.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center}
-.bt-task-off>*+*{border-top:0}
-.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
-.bt-task-off-mark svg{width:20px;height:20px}
-.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600}
-.bt-task-off .bt-set-hint{max-width:380px}
-.bt-task-list{display:flex;flex-direction:column;gap:2px}
-.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease}
-.bt-task-line:hover{background:var(--bt-hover)}
-.bt-task-line svg{flex:none;color:var(--bt-ink-3)}
-.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)}
-@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}}
-`;
-// src/client/capability-settings.js
-var import_react25 = require("react");
-var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] });
-var parseMap = (text2, label) => {
- let value;
- try {
- value = JSON.parse(text2 || "{}");
- } catch {
- throw new Error(t("{label} must be valid JSON.", { label }));
- }
- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label }));
- return value;
+// src/client/at-menu.js
+var BOTS_SOURCE = "ds-bot";
+var CHATS_SOURCE = "ds-bot-chats";
+var ROOM_ALL = ["all", "everyone", "所有人", "全体"];
+var OTHER_CHATS_MAX = 8;
+var conversationOf = (roster, sessionId) => {
+ if (!roster.ready) return null;
+ const room2 = roster.roomsById[sessionId];
+ if (room2) return { room: room2 };
+ const self = roster.byId[sessionId];
+ return self ? { self } : null;
};
-var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…");
-function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) {
- return (0, import_react25.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t(label))),
- (0, import_react25.createElement)(multiline ? "textarea" : "input", {
- className: multiline ? "bt-input bt-input-area" : "bt-input",
- value,
- disabled,
- maxLength: label === "Name" ? 32 : void 0,
- placeholder,
- spellCheck: false,
- onChange: (event) => onChange(event.target.value)
- })
- );
+function mentionRows(roster, sessionId, query = "") {
+ const here = conversationOf(roster, sessionId);
+ if (here === null) return null;
+ const face = isFaceRoom(here.room);
+ const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0);
+ const bots = here.room ? [
+ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot),
+ ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
+ // A remote seat already listed under its handle (a duplicate participant record) is offered once.
+ ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
+ id: p.handle || p.id,
+ // A face room inserts the disambiguated title. An ordinary group inserts the name the
+ // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
+ name: face ? remoteBotTitle(p) : remoteCallName(p),
+ kind: "bot",
+ remote: p,
+ handle: p.handle,
+ label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p)
+ }))
+ ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
+ const needle = query.trim().toLowerCase();
+ const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` }));
+ const everyone = t("Everyone");
+ if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) {
+ rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` });
+ }
+ if (face) return rows;
+ const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean));
+ if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))];
+ const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle)));
+ return [
+ ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))),
+ ...remoteMentionRows(roster, query)
+ ];
}
-function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
- const [error, setError] = (0, import_react25.useState)("");
- const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
- const submit = async (event) => {
- event.preventDefault();
- setError("");
- let payload;
- try {
- payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description };
- if (draft.kind === "mcp" && draft.transport === "stdio") {
- payload.transport = "stdio";
- payload.command = draft.command;
- payload.args = draft.argsText.split("\n").filter((line2) => line2 !== "");
- payload.env = parseMap(draft.envText, t("Environment"));
- } else if (draft.kind === "mcp") {
- payload.transport = "streamable-http";
- payload.url = draft.url;
- payload.headers = parseMap(draft.headersText, t("Headers"));
- } else if (draft.kind === "skill") payload.content = draft.content;
- else payload.items = draft.items;
- } catch (failure) {
- setError(failure?.message ?? String(failure));
- return;
- }
- try {
- await actions.saveCapability(payload);
- onClose();
- } catch (failure) {
- setError(failure?.message ?? String(failure));
- }
- };
- const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
- return (0, import_react25.createElement)(
- "form",
- { className: "bt-connector-form", onSubmit: submit },
- (0, import_react25.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
- (0, import_react25.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
- (0, import_react25.createElement)(
- "select",
- { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
- ["mcp", "skill", "pack"].map((kind) => (0, import_react25.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
- )
- ),
- (0, import_react25.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
- draft.kind === "mcp" ? (0, import_react25.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
- (0, import_react25.createElement)(
- "select",
- { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) },
- (0, import_react25.createElement)("option", { value: "stdio" }, t("Local command")),
- (0, import_react25.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
- )
- ) : null,
- draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react25.createElement)(
- "div",
- null,
- (0, import_react25.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
- (0, import_react25.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
- (0, import_react25.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
- ) : null,
- draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react25.createElement)(
- "div",
- null,
- (0, import_react25.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
- (0, import_react25.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
- ) : null,
- draft.kind === "skill" ? (0, import_react25.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
- draft.kind === "pack" ? (0, import_react25.createElement)(
- "div",
- { className: "bt-set-card" },
- available.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react25.createElement)(
- "label",
- { key: item.name, className: "bt-set-row bt-bs-row" },
- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, item.name)),
- (0, import_react25.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
- ))
- ) : null,
- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
- (0, import_react25.createElement)(
- "div",
- { className: "bt-connector-form-row" },
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
- (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
- )
- );
+var isFaceRoom = (room2) => room2?.origin === "face-to-face";
+var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count });
+var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count });
+var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
+var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null;
+var myOwnerName = (roster, live, bot) => {
+ const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle));
+ return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, "");
+};
+var uniqueBy = (list, key) => {
+ const seen = /* @__PURE__ */ new Set();
+ return list.filter((item) => {
+ const k = key(item);
+ if (!k || seen.has(k)) return !k;
+ seen.add(k);
+ return true;
+ });
+};
+var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot;
+function chatRows(roster, list, sessionId) {
+ const here = conversationOf(roster, sessionId);
+ if (here === null) return [];
+ const byId = list?.byId ?? {};
+ const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => {
+ const id = currentPart(bot);
+ return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt };
+ });
+ const team = /* @__PURE__ */ new Set([
+ ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]),
+ ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []])
+ ]);
+ const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt }));
+ return [...bots, ...others];
}
-function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
- const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
- const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
- return (0, import_react25.createElement)(
- "div",
- { className: "bt-connector" },
- (0, import_react25.createElement)(
- "div",
- { className: "bt-connector-head" },
- (0, import_react25.createElement)(
- "span",
- { className: "bt-connector-text" },
- (0, import_react25.createElement)("span", { className: "bt-connector-name" }, item.name),
- (0, import_react25.createElement)("span", { className: "bt-connector-status" }, secondary)
- ),
- (0, import_react25.createElement)(
- "span",
- { className: "bt-connector-actions" },
- item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
- )
- )
- );
+function sessionMention(sessionId, label) {
+ let binary = "";
+ for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte);
+ const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+ return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`;
}
-function CapabilitiesPage({ roster, actions, run }) {
- const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name));
- const [draft, setDraft] = (0, import_react25.useState)(null);
- const [confirming, setConfirming] = (0, import_react25.useState)("");
- (0, import_react25.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(""), 4e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- const startNew = () => setDraft(blank());
- const edit = (item) => setDraft({
- ...blank(),
- ...item,
- originalName: item.name,
- argsText: (item.args ?? []).join("\n"),
- envText: JSON.stringify(item.env ?? {}, null, 2),
- headersText: JSON.stringify(item.headers ?? {}, null, 2),
- items: [...item.items ?? []]
- });
- const remove = (item) => {
- if (confirming !== item.name) {
- setConfirming(item.name);
- return;
- }
- setConfirming("");
- void run(actions.deleteCapability(item.name));
+function atSources({ roster, sessions, launched, iconFor }) {
+ const bots = {
+ trigger: "@",
+ name: BOTS_SOURCE,
+ order: -20,
+ showGroupTitle: false,
+ async candidates(session, { query }) {
+ const snapshot = roster.getSnapshot();
+ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
+ const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
+ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
+ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
+ // row and header (#498).
+ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"),
+ ...row.bot?.role && !face ? { description: row.bot.role } : {},
+ ...row.remote ? { description: row.remote.handle } : {},
+ ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {},
+ // A face room counts the Bots in it, mine and everyone else's (#305, #457).
+ ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {},
+ icon: iconFor(row),
+ section: t("Bots"),
+ value: row.text
+ }));
+ if (mentionRows(snapshot, session.sessionId, "") === null || face) return team;
+ const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({
+ name: row.name,
+ ...row.description ? { description: row.description } : {},
+ icon: row.icon ?? "session",
+ section: row.section ?? "FlyBot",
+ value: row.value
+ }));
+ return [...team, ...extra];
+ },
+ onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0
};
- return (0, import_react25.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react25.createElement)("h3", null, t("Shared capabilities")),
- (0, import_react25.createElement)(
- "div",
- { className: "bt-actions" },
- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
- ),
- draft ? (0, import_react25.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
- (0, import_react25.createElement)(
- "div",
- { className: "bt-set-card bt-connectors-card" },
- items.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react25.createElement)(CapabilityRow, {
- key: item.name,
- item,
- confirming: confirming === item.name,
- onEdit: () => edit(item),
- onDelete: () => remove(item),
- onApprove: () => void run(actions.approveCapability(item.name))
- }))
- )
- );
-}
-function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
- const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name));
- const attached = new Set(bot.capabilities ?? []);
- const statuses = roster.capabilityStatuses?.[bot.id] ?? {};
- if (items.length === 0) return null;
- return (0, import_react25.createElement)(
- Card2,
- { title: t("Capabilities") },
- items.map((item) => (0, import_react25.createElement)(SwitchRow2, {
- key: item.name,
- label: item.name,
- hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0,
- on: attached.has(item.name),
- onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled))
- }))
- );
+ const chats = {
+ trigger: "@",
+ name: CHATS_SOURCE,
+ order: -10,
+ showGroupTitle: false,
+ async candidates(session) {
+ if (!launched(session.sessionId)) return [];
+ return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({
+ name: row.bot ? row.bot.name : row.label,
+ ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {},
+ icon: row.bot ? iconFor(row) : "session",
+ section: row.bot ? t("Bots") : t("Conversations"),
+ value: JSON.stringify({ sessionId: row.sessionId, label: row.label })
+ }));
+ },
+ onPick({ candidate }) {
+ const { sessionId, label } = JSON.parse(candidate.value);
+ const mention = sessionMention(sessionId, label);
+ return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } };
+ },
+ codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) }
+ };
+ return [bots, chats];
}
-// src/client/connector-bots.js
-var import_react26 = require("react");
-function ownBots(roster) {
- const me = roster?.self?.memberId;
- return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me));
-}
-var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender" && !(connector.kind === "channel" && connector.mode !== "app"));
-var isOn = (bot, id) => bot.connectors?.[id] !== false;
-function ownHint(bot, id) {
- return isOn(bot, id) ? t("On. This Bot can use it on every turn.") : t("Off. This Bot will not use it.");
-}
-function usageText(roster, id) {
- const bots = ownBots(roster);
- const own = bots.filter((bot) => isOn(bot, id)).length;
- return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length });
+// src/client/face-at-filter.js
+var REFERENCE_SOURCE = "reference";
+var optionId = (source, index) => `dsh-slash-option-${source}-${index}`;
+var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session";
+var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index;
+function planFaceMenu(state, previous = null) {
+ const hidden = [];
+ const ready = [];
+ let shown = 0;
+ let loading = false;
+ for (const group of state?.groups ?? []) {
+ const items = group.items ?? [];
+ if (group.status !== "ready" && items.length === 0) loading = true;
+ items.forEach((item, index) => {
+ const hide = isSessionRow(group, item);
+ if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section });
+ else shown += 1;
+ if (group.status === "ready") ready.push({ source: group.source, index, hide });
+ });
+ }
+ const open = state?.open === true;
+ const blank3 = open && hidden.length > 0 && shown === 0 && !loading;
+ const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready");
+ return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) };
}
-function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) {
- return (0, import_react26.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
+function steer(ready, highlight, previous) {
+ const at = ready.findIndex((row) => same(row, highlight));
+ if (at < 0 || !ready[at].hide) return null;
+ const from = ready.findIndex((row) => same(row, previous));
+ const n = ready.length;
+ const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1;
+ for (let step = 1; step < n; step += 1) {
+ const row = ready[((at + dir * step) % n + n) % n];
+ if (!row.hide) return { source: row.source, index: row.index };
+ }
+ return null;
}
-function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) {
- const bots = ownBots(roster);
- return (0, import_react26.createElement)(
- "div",
- { className: "bt-connector-bots" },
- bots.length === 0 ? (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react26.createElement)(
- "div",
- { key: bot.id, className: "bt-connector-bot" },
- (0, import_react26.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
- )),
- (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
- );
+function faceMenuCss({ hidden, blank: blank3 }) {
+ if (hidden.length === 0) return "";
+ const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`);
+ const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`);
+ const css = [`${[...rows, ...titles].join(",")}{display:none!important}`];
+ if (blank3) css.push("[data-trigger-menu]{display:none!important}");
+ return css.join("\n");
}
-function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
- const connected = connectedConnectors(roster);
- if (connected.length === 0) return null;
- return (0, import_react26.createElement)(
- Card2,
- { title: t("Connectors") },
- connected.map((connector) => (0, import_react26.createElement)(
- "div",
- { key: connector.id, className: "bt-connector-bot" },
- (0, import_react26.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
- ))
- );
+function watchFaceMenu(controller, doc = globalThis.document) {
+ if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => {
+ };
+ const style = doc.createElement("style");
+ style.setAttribute("data-bt-face-at", "");
+ doc.head.appendChild(style);
+ let previous = null;
+ let stopped = false;
+ const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
+ const apply2 = () => {
+ if (stopped) return;
+ const state = controller.menu.getSnapshot();
+ const plan = planFaceMenu(state, previous);
+ const css = faceMenuCss(plan);
+ if (style.textContent !== css) style.textContent = css;
+ if (plan.dismiss) later(() => {
+ if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss();
+ });
+ else if (plan.target) later(() => {
+ if (!stopped) controller.hover(plan.target.source, plan.target.index);
+ });
+ else previous = state.open ? state.highlight : null;
+ };
+ const unsubscribe = controller.menu.subscribe(apply2);
+ apply2();
+ return () => {
+ stopped = true;
+ unsubscribe?.();
+ style.remove();
+ };
}
-// src/client/memory-panel.js
-var import_react27 = require("react");
-var POLL_MS5 = 1500;
-var MENU_EXIT_MS2 = 120;
-var counts = /* @__PURE__ */ new Map();
-var countListeners = /* @__PURE__ */ new Set();
-function noteCount(botId, count) {
- if (counts.get(botId) === count) return;
- counts.set(botId, count);
- for (const listener of countListeners) listener();
-}
-function useMemoryCount(botId, actions) {
- const [count, setCount] = (0, import_react27.useState)(counts.get(botId) ?? null);
- (0, import_react27.useEffect)(() => {
- const listener = () => setCount(counts.get(botId) ?? null);
- countListeners.add(listener);
- listener();
- let stop = false;
- void actions.memoryView?.(botId).then((view) => {
- if (!stop) noteCount(botId, view.entries.length);
- }).catch(() => {
- });
- return () => {
- stop = true;
- countListeners.delete(listener);
- };
- }, [botId]);
- return count;
+// src/client/speech.js
+var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle";
+var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]);
+async function speechCatalog(speech) {
+ const result = await speech.catalog();
+ if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed");
+ const catalog = result.value;
+ const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId);
+ return { catalog, usable: USABLE.has(provider?.preparation?.phase) };
}
-function MemoryRow({ bot, actions }) {
- const count = useMemoryCount(bot.id, actions);
- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
- return (0, import_react27.createElement)(
- "div",
- null,
- (0, import_react27.createElement)("div", { className: "bt-section-title" }, t("Memory")),
- (0, import_react27.createElement)(
- "button",
- { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
- (0, import_react27.createElement)(MemoryIcon),
- (0, import_react27.createElement)("span", { className: "bt-mem-row-copy" }, label),
- (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon))
- )
- );
-}
-function ago(at, now) {
- const seconds = Math.max(0, Math.round((now - at) / 1e3));
- if (seconds < 60) return null;
- const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" });
- const minutes = Math.round(seconds / 60);
- if (minutes < 60) return format.format(-minutes, "minute");
- const hours = Math.round(minutes / 60);
- if (hours < 24) return format.format(-hours, "hour");
- return format.format(-Math.round(hours / 24), "day");
+function waveBytes(samples, rate = 16e3) {
+ const bytes = new Uint8Array(44 + samples.length * 2);
+ const view = new DataView(bytes.buffer);
+ const ascii = (at, text2) => {
+ for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index);
+ };
+ ascii(0, "RIFF");
+ view.setUint32(4, 36 + samples.length * 2, true);
+ ascii(8, "WAVEfmt ");
+ view.setUint32(16, 16, true);
+ view.setUint16(20, 1, true);
+ view.setUint16(22, 1, true);
+ view.setUint32(24, rate, true);
+ view.setUint32(28, rate * 2, true);
+ view.setUint16(32, 2, true);
+ view.setUint16(34, 16, true);
+ ascii(36, "data");
+ view.setUint32(40, samples.length * 2, true);
+ samples.forEach((sample, index) => {
+ const value = Math.max(-1, Math.min(1, sample));
+ view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true);
+ });
+ return bytes;
}
-function Skeleton({ label }) {
- return (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-skel", "aria-busy": true },
- [0, 1].map((block) => (0, import_react27.createElement)(
- "div",
- { key: block, className: "bt-mem-skel-block" },
- (0, import_react27.createElement)("span", { className: "bt-mem-skel-head" }),
- (0, import_react27.createElement)("span", null),
- (0, import_react27.createElement)("span", null),
- (0, import_react27.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
- )),
- label ? (0, import_react27.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
- );
+function base64Of(bytes) {
+ let text2 = "";
+ for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768));
+ return btoa(text2);
}
-function Summary({ bot, view, regenerate }) {
- if (view.entries.length === 0) {
- return (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-empty" },
- (0, import_react27.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react27.createElement)(MemoryIcon)),
- (0, import_react27.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
- (0, import_react27.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
- );
+var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause });
+async function openCapture() {
+ const devices = navigator.mediaDevices;
+ if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable");
+ let stream;
+ try {
+ stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
+ } catch (error) {
+ throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error);
}
- const failed = view.error && !view.summarizing ? (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-failed", role: "alert" },
- (0, import_react27.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
- (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
- ) : null;
- if (!view.summary) return (0, import_react27.createElement)(import_react27.Fragment, null, failed, failed ? null : (0, import_react27.createElement)(Skeleton, { label: t("Writing the summary…") }));
- return (0, import_react27.createElement)(
- import_react27.Fragment,
- null,
- failed,
- (0, import_react27.createElement)(
- "div",
- { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
- summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react27.createElement)("h3", { key: index }, block.text) : (0, import_react27.createElement)("p", { key: index }, block.text))
- )
- );
+ const audio = new AudioContext();
+ const analyser = audio.createAnalyser();
+ analyser.fftSize = 512;
+ audio.createMediaStreamSource(stream).connect(analyser);
+ const frame = new Float32Array(analyser.fftSize);
+ const recorder = new MediaRecorder(stream);
+ const chunks = [];
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) chunks.push(event.data);
+ };
+ recorder.start();
+ let closed = false;
+ const close = () => {
+ if (closed) return;
+ closed = true;
+ if (recorder.state !== "inactive") recorder.stop();
+ stream.getTracks().forEach((track) => track.stop());
+ void audio.close().catch(() => {
+ });
+ };
+ return {
+ level() {
+ if (closed) return 0;
+ analyser.getFloatTimeDomainData(frame);
+ let sum = 0;
+ for (const sample of frame) sum += sample * sample;
+ return Math.min(1, Math.sqrt(sum / frame.length) * 4);
+ },
+ cancel: close,
+ async finish(maxSeconds) {
+ if (closed || recorder.state === "inactive") {
+ close();
+ return null;
+ }
+ try {
+ await new Promise((resolve) => {
+ recorder.onstop = resolve;
+ recorder.stop();
+ });
+ stream.getTracks().forEach((track) => track.stop());
+ const blob = new Blob(chunks, { type: recorder.mimeType });
+ if (blob.size === 0) return null;
+ const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
+ const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
+ const offline = new OfflineAudioContext(1, length, 16e3);
+ const source = offline.createBufferSource();
+ source.buffer = decoded;
+ source.connect(offline.destination);
+ source.start();
+ return waveBytes((await offline.startRendering()).getChannelData(0));
+ } finally {
+ close();
+ }
+ }
+ };
}
-var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
- const key = `${entry.scope}:${entry.topic ?? ""}`;
- const group = groups.find((item) => item.key === key);
- if (group) group.entries.push(entry);
- else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
- return groups;
-}, []);
-function EntryRow({ entry, remove }) {
- const [confirming, setConfirming] = (0, import_react27.useState)(false);
- const [busy, setBusy] = (0, import_react27.useState)(false);
- (0, import_react27.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 3e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
- const meta = [entry.added, by].filter(Boolean).join(" · ");
- const click = async () => {
- if (!confirming) {
- setConfirming(true);
- return;
+async function transcribeWave(speech, catalog, wave, signal) {
+ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
+ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
+ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
+ return String(result.value?.text ?? "").trim();
+}
+function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
+ let spokeAt = 0;
+ let quietSince = 0;
+ return (level, now) => {
+ if (level >= loud) {
+ spokeAt ||= now;
+ quietSince = 0;
+ return "speech";
}
- setBusy(true);
- if (!await remove(entry)) setBusy(false);
+ if (!spokeAt) return "waiting";
+ if (level < quiet) quietSince ||= now;
+ return quietSince && now - quietSince >= quietMs ? "done" : "speech";
};
- return (0, import_react27.createElement)(
- "li",
- { className: "bt-mem-entry", "data-busy": busy || void 0 },
- (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-entry-copy" },
- (0, import_react27.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
- meta ? (0, import_react27.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
- ),
- (0, import_react27.createElement)("button", {
- type: "button",
- className: "bt-mem-remove",
- "data-confirm": confirming || void 0,
- disabled: busy,
- "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
- title: confirming ? void 0 : t("Remove from memory"),
- onClick: click
- }, confirming ? t("Remove") : (0, import_react27.createElement)(TrashIcon))
- );
}
-function Entries({ bot, view, remove }) {
- const groups = groupEntries(view.entries);
- const sections = [
- { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
- { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
+
+// src/client/model-menu.js
+var import_react22 = require("react");
+var import_react_dom8 = require("react-dom");
+var MENU_EXIT_MS = 120;
+function groupModels(models) {
+ const groups = /* @__PURE__ */ new Map();
+ for (const model of models ?? []) {
+ const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0];
+ if (!groups.has(provider)) groups.set(provider, []);
+ groups.get(provider).push(model);
+ }
+ const names = /* @__PURE__ */ new Map();
+ for (const [provider, list] of groups) {
+ const name = list[0].providerName ?? provider;
+ names.set(name, (names.get(name) ?? 0) + 1);
+ }
+ return [...groups].map(([provider, list]) => {
+ const name = list[0].providerName ?? provider;
+ return [names.get(name) > 1 ? `${name} (${provider})` : name, list];
+ });
+}
+var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
+var effortCache = /* @__PURE__ */ new Map();
+function useEfforts(served, ref, actions) {
+ const [fetched, setFetched] = (0, import_react22.useState)(void 0);
+ (0, import_react22.useEffect)(() => {
+ setFetched(void 0);
+ if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0;
+ let live = true;
+ if (effortCache.has(ref)) setFetched(effortCache.get(ref));
+ else void Promise.resolve(actions.efforts(ref)).then((value) => {
+ const reasoning = value?.efforts ? value : null;
+ effortCache.set(ref, reasoning);
+ if (live) setFetched(reasoning);
+ }).catch(() => {
+ });
+ return () => {
+ live = false;
+ };
+ }, [ref, served?.reasoning !== void 0, actions]);
+ return served?.reasoning ?? fetched;
+}
+function effortRows(reasoning) {
+ if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return [];
+ return [
+ { effort: void 0, label: t("Default") },
+ ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name }))
];
- return (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-entries" },
- sections.map((section) => (0, import_react27.createElement)(
- "section",
- { key: section.scope, className: "bt-mem-section" },
- (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-section-head" },
- (0, import_react27.createElement)("h3", null, section.title),
- section.hint ? (0, import_react27.createElement)("span", null, section.hint) : null,
- (0, import_react27.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
- ),
- groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react27.createElement)(
- import_react27.Fragment,
- { key: group.key },
- group.topic ? (0, import_react27.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
- (0, import_react27.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react27.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
- )) : (0, import_react27.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
+}
+function EffortRows({ reasoning, current, onPick }) {
+ return (0, import_react22.createElement)(
+ import_react22.Fragment,
+ null,
+ (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
+ effortRows(reasoning).map((row) => (0, import_react22.createElement)(
+ "button",
+ {
+ key: row.effort ?? "default",
+ type: "button",
+ role: "menuitemradio",
+ "aria-checked": row.effort === current,
+ onClick: () => onPick(row.effort)
+ },
+ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, row.label),
+ (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react22.createElement)(CheckIcon) : null)
))
);
}
-function changeLine(change) {
- const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
- if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
- if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
- if (change.op === "edit") return t("Updated: {text}", { text: text2 });
- return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
-}
-function Reply({ answer, dismiss }) {
- return (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-reply", role: "status" },
- (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-reply-head" },
- (0, import_react27.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
- answer.pending ? null : (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react27.createElement)(CloseIcon))
- ),
- answer.pending ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react27.createElement)(
- import_react27.Fragment,
- null,
- answer.reply ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
- answer.changes.length > 0 ? (0, import_react27.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react27.createElement)(
- "li",
- { key: index, "data-ok": change.ok || void 0 },
- change.ok ? (0, import_react27.createElement)(CheckIcon) : (0, import_react27.createElement)(CloseIcon),
- (0, import_react27.createElement)("span", null, changeLine(change))
- ))) : null
- )
- );
-}
-function useMemoryView(botId, actions) {
- const [view, setView] = (0, import_react27.useState)(null);
- const [error, setError] = (0, import_react27.useState)("");
- const timer = (0, import_react27.useRef)(0);
- const latest = (0, import_react27.useRef)(0);
- const alive = (0, import_react27.useRef)(true);
- const show = (0, import_react27.useCallback)((next) => {
- setView(next);
- setError("");
- noteCount(botId, next.entries.length);
- }, [botId]);
- const load = (0, import_react27.useCallback)(async (flags = {}) => {
- clearTimeout(timer.current);
- const ticket = ++latest.current;
- try {
- const next = await actions.memoryView(botId, flags);
- if (!alive.current || ticket !== latest.current) return;
- show(next);
- if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
- } catch (failure) {
- if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
- }
- }, [botId]);
- (0, import_react27.useEffect)(() => {
- alive.current = true;
- void load({ summarize: true });
- return () => {
- alive.current = false;
- clearTimeout(timer.current);
- };
- }, [botId]);
- return { view, error, load, show };
-}
-function useNow3(every) {
- const [now, setNow] = (0, import_react27.useState)(Date.now());
- (0, import_react27.useEffect)(() => {
- const id = setInterval(() => setNow(Date.now()), every);
- return () => clearInterval(id);
- }, [every]);
- return now;
+function ModelRows({ roster, current, onPick }) {
+ return groupModels(roster.models).map(([provider, list]) => (0, import_react22.createElement)(
+ import_react22.Fragment,
+ { key: list[0].provider ?? provider },
+ (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
+ list.map((model) => (0, import_react22.createElement)(
+ "button",
+ {
+ key: model.ref,
+ type: "button",
+ role: "menuitemradio",
+ "aria-checked": model.ref === current,
+ title: model.ref,
+ onClick: () => onPick(model.ref)
+ },
+ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, model.name),
+ model.ref === roster.defaultModel ? (0, import_react22.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
+ (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react22.createElement)(CheckIcon) : null)
+ ))
+ ));
}
-function MemoryDialog({ bot, actions, leaving = false }) {
- const { view, error, load, show } = useMemoryView(bot.id, actions);
- const [page, setPage] = (0, import_react27.useState)("summary");
- const [menu, setMenu] = (0, import_react27.useState)(false);
- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2);
- const [text2, setText] = (0, import_react27.useState)("");
- const [answer, setAnswer] = (0, import_react27.useState)(null);
- const menuRef = (0, import_react27.useRef)(null);
- const dotsRef = (0, import_react27.useRef)(null);
- const inputRef = (0, import_react27.useRef)(null);
- const now = useNow3(3e4);
- const close = (0, import_react27.useCallback)(() => actions.closeMemory(), []);
- useEscape(close);
- (0, import_react27.useEffect)(() => {
- if (!menu) return void 0;
+var Caret = () => (0, import_react22.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react22.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
+function useAnchoredMenu() {
+ const [place, setPlace] = (0, import_react22.useState)(null);
+ const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS);
+ const button = (0, import_react22.useRef)(null);
+ const menu = (0, import_react22.useRef)(null);
+ const close = () => setPlace(null);
+ (0, import_react22.useEffect)(() => {
+ if (place === null) return void 0;
const onDown = (event) => {
- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return;
- setMenu(false);
+ if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return;
+ close();
};
const onKey = (event) => {
if (event.key === "Escape") {
event.stopPropagation();
- setMenu(false);
- dotsRef.current?.focus();
+ close();
+ button.current?.focus();
}
};
+ const onScroll = (event) => {
+ if (!menu.current?.contains(event.target)) close();
+ };
window.addEventListener("mousedown", onDown);
window.addEventListener("keydown", onKey, true);
+ window.addEventListener("scroll", onScroll, true);
return () => {
window.removeEventListener("mousedown", onDown);
window.removeEventListener("keydown", onKey, true);
+ window.removeEventListener("scroll", onScroll, true);
};
- }, [menu]);
- const regenerate = () => {
- setMenu(false);
- setPage("summary");
- void load({ regenerate: true });
+ }, [place]);
+ const open = () => {
+ const rect = button.current.getBoundingClientRect();
+ const width = Math.max(240, rect.width);
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
+ const below = window.innerHeight - rect.bottom;
+ setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" });
};
- const openEntries = () => {
- setMenu(false);
- setPage("entries");
+ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close };
+}
+function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) {
+ if (bot?.remote !== void 0) return null;
+ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
+ const shown = modelOf(bot, roster);
+ const current = bot.model ?? bot.appliedModel;
+ const served = servedOf(roster, current);
+ const reasoning = useEfforts(served, current, actions);
+ const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0;
+ const currentEffort = picked ?? reasoning?.defaultEffort;
+ const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort;
+ const toggle = () => {
+ if (isOpen) {
+ close();
+ return;
+ }
+ void actions?.refreshModels?.();
+ open();
};
- const backToSummary = () => {
- setPage("summary");
- void load({ summarize: true });
+ const pick2 = (ref) => {
+ close();
+ if (onPick) onPick(ref);
+ else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref }));
};
- const remove = async (entry) => {
- try {
- show(await actions.memoryForget(bot.id, entry));
- return true;
- } catch (failure) {
- setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) });
- return false;
- }
+ const pickEffort = (effort) => {
+ close();
+ if (onPickEffort) onPickEffort(effort);
+ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null }));
};
- const ask = async (event) => {
- event.preventDefault();
- const asked = text2.trim();
- if (asked === "" || answer?.pending) return;
- setText("");
- setAnswer({ asked, pending: true });
- try {
- const result = await actions.memoryAsk(bot.id, asked);
- setAnswer({ asked, reply: result.reply, changes: result.changes });
- show(result.view);
- if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" });
- } catch (failure) {
- setAnswer({ asked, error: failure?.message ?? String(failure) });
- }
- inputRef.current?.focus();
+ const models = roster.models ?? [];
+ return (0, import_react22.createElement)(
+ import_react22.Fragment,
+ null,
+ (0, import_react22.createElement)(
+ "button",
+ {
+ ref: button,
+ type: "button",
+ className: "bt-model-trigger",
+ "data-wide": wide || void 0,
+ "aria-haspopup": "menu",
+ "aria-expanded": isOpen,
+ "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }),
+ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0,
+ onClick: toggle
+ },
+ (0, import_react22.createElement)(
+ "span",
+ { className: "bt-model-copy" },
+ (0, import_react22.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
+ shown ? (0, import_react22.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
+ ),
+ (0, import_react22.createElement)(Caret)
+ ),
+ shownPlace ? (0, import_react_dom8.createPortal)((0, import_react22.createElement)(
+ "div",
+ { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 },
+ models.length > 0 ? (0, import_react22.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react22.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
+ effortRows(reasoning).length > 0 ? (0, import_react22.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
+ (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+ close();
+ openHarnessModels();
+ } }, (0, import_react22.createElement)(SlidersIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
+ ), document.body) : null
+ );
+}
+function ModelChoiceDock({ sessionId, useRoster, actions }) {
+ const roster = useRoster((value) => value);
+ const bot = roster.byId[sessionId];
+ const [busy, setBusy] = (0, import_react22.useState)("");
+ const [error, setError] = (0, import_react22.useState)("");
+ const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready;
+ (0, import_react22.useEffect)(() => {
+ if (shown) void actions.refreshModels?.();
+ }, [shown, sessionId]);
+ if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null;
+ if (bot.remote) return bot.remote.gone ? (0, import_react22.createElement)(RemoteGoneDock, { bot }) : null;
+ const models = roster.models ?? [];
+ const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false;
+ if (bot.model && (!lost || models.length === 0)) return null;
+ const pick2 = (ref) => {
+ setBusy(ref);
+ setError("");
+ void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => {
+ setError(failure?.message ?? String(failure));
+ void actions.refreshModels?.();
+ }).finally(() => setBusy(""));
};
- const count = view?.entries.length ?? 0;
- const when3 = view?.summary ? ago(view.summary.at, now) : null;
- const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : "";
- const busy = answer?.pending === true;
- return (0, import_react27.createElement)(
+ const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name });
+ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" })] : [
+ ...groupModels(models).map(([provider, list]) => (0, import_react22.createElement)(
+ "div",
+ { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider },
+ (0, import_react22.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
+ (0, import_react22.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react22.createElement)("button", {
+ key: model.ref,
+ type: "button",
+ className: "bt-model-chip",
+ disabled: busy !== "",
+ "aria-busy": busy === model.ref || void 0,
+ "aria-label": `${model.name} · ${provider}`,
+ title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref,
+ onClick: () => pick2(model.ref)
+ }, model.name, busy === model.ref ? (0, import_react22.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
+ )),
+ (0, import_react22.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react22.createElement)(SlidersIcon), t("Manage models"))
+ ];
+ return (0, import_react22.createElement)(
"div",
- { className: "bt-mem-layer", "data-leaving": leaving || void 0 },
- (0, import_react27.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
- (0, import_react27.createElement)(
+ { className: "bt-model-dock" },
+ (0, import_react22.createElement)(
"div",
- { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) },
- (0, import_react27.createElement)(
+ { className: "bt-astack", "data-lead": "" },
+ (0, import_react22.createElement)(
"div",
- { className: "bt-mem-head" },
- page === "entries" ? (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react27.createElement)(ChevronLeftIcon)) : null,
- (0, import_react27.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
- (0, import_react27.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
- (0, import_react27.createElement)("span", { className: "bt-mem-head-gap" }),
- (0, import_react27.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react27.createElement)(DotsIcon)),
- (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react27.createElement)(CloseIcon)),
- menuShown ? (0, import_react27.createElement)(
+ { className: "bt-msg" },
+ (0, import_react22.createElement)(
"div",
- { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 },
- (0, import_react27.createElement)(
- "button",
- { type: "button", role: "menuitem", onClick: openEntries },
- (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
- (0, import_react27.createElement)("span", { className: "bt-menu-hint" }, String(count))
- ),
- (0, import_react27.createElement)(
- "button",
- { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate },
- (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
- )
- ) : null
- ),
- (0, import_react27.createElement)(
- "div",
- { className: "bt-mem-body" },
- error ? (0, import_react27.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
- view === null ? error ? null : (0, import_react27.createElement)(Skeleton) : page === "entries" ? (0, import_react27.createElement)(Entries, { bot, view, remove }) : (0, import_react27.createElement)(Summary, { bot, view, regenerate })
- ),
- (0, import_react27.createElement)(
+ { className: "bt-msg-line" },
+ (0, import_react22.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react22.createElement)("div", { className: "bt-bubble" }, text2)
+ )
+ )
+ ),
+ (0, import_react22.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
+ error ? (0, import_react22.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
+ );
+}
+function RemoteGoneDock({ bot }) {
+ return (0, import_react22.createElement)(
+ "div",
+ { className: "bt-model-dock", "data-flybot": "remote-gone" },
+ (0, import_react22.createElement)(
+ "div",
+ { className: "bt-astack", "data-lead": "" },
+ (0, import_react22.createElement)(
"div",
- { className: "bt-mem-foot" },
- answer ? (0, import_react27.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
- (0, import_react27.createElement)(
- "form",
- { className: "bt-mem-ask", onSubmit: ask },
- (0, import_react27.createElement)("input", {
- ref: inputRef,
- className: "bt-mem-input",
- value: text2,
- maxLength: 2e3,
- disabled: view === null,
- placeholder: t("Ask or update"),
- "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }),
- onChange: (event) => setText(event.target.value)
- }),
- (0, import_react27.createElement)(
- "button",
- { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") },
- busy ? (0, import_react27.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react27.createElement)(ArrowUpIcon)
- )
+ { className: "bt-msg" },
+ (0, import_react22.createElement)(
+ "div",
+ { className: "bt-msg-line" },
+ (0, import_react22.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react22.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
)
)
)
);
}
-
-// src/client/standing-approvals.js
-var import_react28 = require("react");
-var standingOf = (target, roster) => {
- const me = roster?.self?.memberId;
- return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me);
-};
-function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card2 }) {
- const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? [];
- if (grants.length === 0) return null;
- const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking.");
- return (0, import_react28.createElement)(
- Card2,
- { title: t("Always allowed") },
- grants.map((grant) => (0, import_react28.createElement)(
- "div",
- { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind },
- (0, import_react28.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react28.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
- (0, import_react28.createElement)("span", { className: "bt-set-hint" }, hint)
- ),
- (0, import_react28.createElement)("button", {
- type: "button",
- className: "bt-soft bt-soft-sm",
- "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }),
- onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind))
- }, t("Remove"))
- ))
- );
-}
-
-// src/client/bot-settings.js
-var import_react29 = require("react");
-var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
-async function avatarImage(file) {
- if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
- const bitmap = await createImageBitmap(file);
- const side = 160;
- const canvas = document.createElement("canvas");
- canvas.width = side;
- canvas.height = side;
- const context = canvas.getContext("2d");
- context.imageSmoothingQuality = "high";
- const crop = Math.min(bitmap.width, bitmap.height);
- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
- bitmap.close?.();
- for (const quality of [0.9, 0.75, 0.6]) {
- const url = canvas.toDataURL("image/webp", quality);
- if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
- }
- const png = canvas.toDataURL("image/png");
- if (png.length < 15e4) return png;
- throw new Error(t("That image is too large"));
-}
-function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) {
- const [draft, setDraft] = (0, import_react29.useState)(null);
- const reverting = (0, import_react29.useRef)(false);
- const commit = () => {
- const next = draft?.trim();
- setDraft(null);
- if (reverting.current) {
- reverting.current = false;
- return;
- }
- if (next === void 0 || next === (value ?? "") || required && next === "") return;
- void onSave(next);
- };
- return (0, import_react29.createElement)(multiline ? "textarea" : "input", {
- className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
- value: draft ?? value ?? "",
- placeholder,
- maxLength,
- required,
- "aria-label": label,
- spellCheck: false,
- onChange: (event) => setDraft(event.target.value),
- onBlur: commit,
- onKeyDown: (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- reverting.current = true;
- event.currentTarget.blur();
- }
- if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur();
- }
- });
-}
-function Row({ label, hint, stack = false, children }) {
- return (0, import_react29.createElement)(
- "label",
- { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null),
- children
- );
-}
-function Card({ title, children }) {
- return (0, import_react29.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react29.createElement)("h3", null, title) : null, (0, import_react29.createElement)("div", { className: "bt-set-card" }, children));
-}
-function Switch({ on, label, disabled, onChange }) {
- return (0, import_react29.createElement)(
- "button",
- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
- (0, import_react29.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
- );
-}
-function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) {
- return (0, import_react29.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null),
- (0, import_react29.createElement)(Switch, { on, label, disabled, onChange })
- );
+function setupChoices(actions) {
+ return [
+ actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null,
+ [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")],
+ [t("Open settings to set up a custom model"), openHarnessModels]
+ ].filter(Boolean);
}
-function modelHint(bot, roster) {
- const shown = modelOf(bot, roster);
- if (shown === void 0) return t("Pick the model this Bot runs on.");
- if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one.");
- if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it.");
- return t("Applies from the next message.");
+var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md";
+function KeyHelpLink() {
+ return (0, import_react22.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
}
-function ShapeRing({ shape, size }) {
- const maskId = (0, import_react29.useRef)("");
- if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`;
- const def = shapeOf(shape);
- const unit = def.size / size;
- const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`;
- return (0, import_react29.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
+function missingModelKey(message) {
+ const text2 = String(message ?? "");
+ if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0;
+ return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" };
}
-function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
- const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))];
- if (!shapes.includes(look.shape)) shapes.push(look.shape);
- const rows = [];
- for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS));
- return (0, import_react29.createElement)(
- import_react29.Fragment,
- null,
- (0, import_react29.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react29.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react29.createElement)("button", {
- key: id,
- type: "button",
- className: "bt-shape",
- "aria-label": t(shapeOf(id).label),
- title: t(shapeOf(id).label),
- "aria-pressed": !hasPhoto && look.shape === id,
- onClick: () => onShape(id)
- }, (0, import_react29.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react29.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
- (0, import_react29.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react29.createElement)("button", {
- key: color,
- type: "button",
- className: "bt-color",
- "aria-label": t(capitalize(color)),
- title: t(capitalize(color)),
- "aria-pressed": look.color === color,
- onClick: () => onColor(color)
- }, (0, import_react29.createElement)("span", { style: { background: paintCss(color) } }))))
- );
+function modelKeyChoices(actions, route) {
+ if (/^deepseek/i.test(route)) return setupChoices(actions);
+ return [[t("Manage models"), openHarnessModels]];
}
-function AvatarEditor({ bot, actions, run, onClose }) {
- const look = lookOf(bot);
- const [tab, setTab] = (0, import_react29.useState)(look.image ? "upload" : "bot");
- const [over, setOver] = (0, import_react29.useState)(false);
- const ref = (0, import_react29.useRef)(null);
- const fileRef = (0, import_react29.useRef)(null);
- const upload = (file) => {
- if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
+function TurnErrorCell({ node, t: chatT, actions }) {
+ const data = node?.data ?? {};
+ const code = typeof data.code === "string" && data.code ? data.code : void 0;
+ const raw = String(data.message ?? "");
+ const shell = (key, fallback) => {
+ const text2 = typeof chatT === "function" ? chatT(key) : void 0;
+ return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback;
};
- (0, import_react29.useEffect)(() => {
- const onDown = (event) => {
- if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
- onClose();
- };
- const onKey = (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- onClose();
- }
- };
- window.addEventListener("mousedown", onDown);
- window.addEventListener("keydown", onKey, true);
- return () => {
- window.removeEventListener("mousedown", onDown);
- window.removeEventListener("keydown", onKey, true);
- };
- }, [onClose]);
- (0, import_react29.useEffect)(() => {
- if (tab !== "upload") return void 0;
- const onPaste = (event) => {
- const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/"));
- if (file) {
- event.preventDefault();
- upload(file);
- }
- };
- window.addEventListener("paste", onPaste);
- return () => window.removeEventListener("paste", onPaste);
- }, [tab, bot]);
- const hasPhoto = Boolean(look.image);
- const glide = useGlide(tab);
- return (0, import_react29.createElement)(
+ const missing = missingModelKey(raw);
+ const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed"));
+ const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw;
+ const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [];
+ const deepseek = missing ? /^deepseek/i.test(missing.route) : false;
+ return (0, import_react22.createElement)(
"div",
- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
- (0, import_react29.createElement)(
+ { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 },
+ (0, import_react22.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
+ (0, import_react22.createElement)(
"div",
- { className: "bt-avedit-top" },
- (0, import_react29.createElement)(
- "div",
- { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
- (0, import_react29.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react29.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
- ),
- (0, import_react29.createElement)("button", {
- type: "button",
- className: "bt-avedit-tab bt-avedit-reset",
- title: t("Reset character to default"),
- onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
- }, t("Reset"))
+ { className: "bt-turn-error-copy" },
+ (0, import_react22.createElement)("span", { className: "bt-turn-error-title" }, title),
+ (0, import_react22.createElement)("span", { className: "bt-turn-error-message" }, message),
+ code ? (0, import_react22.createElement)("code", { className: "bt-turn-error-code" }, code) : null
),
- tab === "bot" ? (0, import_react29.createElement)(CharacterGrid, {
- look,
- hasPhoto,
- onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })),
- onColor: (color) => run(actions.updateBot(bot.id, { color }))
- }) : null,
- tab === "upload" ? (0, import_react29.createElement)(
+ choices.length > 0 ? (0, import_react22.createElement)(
"div",
- {
- className: "bt-dropzone",
- "data-over": over || void 0,
- onDragOver: (event) => {
- event.preventDefault();
- setOver(true);
- },
- onDragLeave: () => setOver(false),
- onDrop: (event) => {
- event.preventDefault();
- setOver(false);
- upload(event.dataTransfer.files?.[0]);
- }
- },
- hasPhoto ? (0, import_react29.createElement)("img", { src: look.image, alt: "" }) : null,
- (0, import_react29.createElement)("div", null, t("Drag, drop, or paste an image")),
- (0, import_react29.createElement)(
- "div",
- { className: "bt-actions", style: { justifyContent: "center" } },
- (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
- hasPhoto ? (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
- ),
- (0, import_react29.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
- const file = event.target.files?.[0];
- event.target.value = "";
- upload(file);
- } })
+ { className: "bt-turn-error-actions" },
+ choices.map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
+ deepseek ? (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" }) : null
) : null
);
}
-function MemoryCard({ bot, actions }) {
- const count = useMemoryCount(bot.id, actions);
- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
- return (0, import_react29.createElement)(
- Card,
- { title: t("Memory") },
- (0, import_react29.createElement)(
- "button",
- { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label)),
- (0, import_react29.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react29.createElement)(ChevronRightIcon))
- )
- );
+function DeepSeekSignIn({ useUi, actions }) {
+ const open = useUi((state) => state.signIn);
+ const entry = open ? actions.signInEntry?.() : null;
+ if (!entry) return null;
+ return (0, import_react22.createElement)(SignInHost, { key: open, entry, actions });
}
-function BotSettingsForm({ bot, roster, actions, run }) {
- const [editing, setEditing] = (0, import_react29.useState)(false);
- const [confirming, setConfirming] = (0, import_react29.useState)(false);
- const closeEditor = (0, import_react29.useCallback)(() => setEditing(false), []);
- (0, import_react29.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 4e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- if (bot.remote) return null;
- const isMainBot = isMainOf(roster, bot.id);
- const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1;
- const isTile = roster.mainBotId === bot.id;
- const save = (patch) => run(actions.updateBot(bot.id, patch));
- const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id)));
- const remove = () => {
- if (confirming) void run(actions.deleteBot(bot.id));
- else setConfirming(true);
+function SignInHost({ entry, actions }) {
+ const [ops] = (0, import_react22.useState)(() => entry.inject?.() ?? {});
+ const close = () => actions.closeSignIn();
+ const useHook = (source) => (selector) => (0, import_react22.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
+ const [hooks] = (0, import_react22.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
+ return (0, import_react22.createElement)(entry.component, {
+ ...ops,
+ ...hooks,
+ t: actions.accountT(),
+ complete: close,
+ useApiKey: () => {
+ close();
+ openHarnessOnboarding("deepseek-official");
+ }
+ });
+}
+var MODEL_CSS = `
+.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px}
+.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135}
+.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere}
+.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135}
+.bt-turn-error-message{color:var(--bt-ink-2)}
+.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
+.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
+.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px}
+.bt-model-help:hover{color:var(--bt-ink)}
+.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px}
+.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease}
+.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)}
+.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
+.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e}
+.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)}
+.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)}
+.bt-model-copy{display:flex;flex-direction:column;min-width:0}
+.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}}
+.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)}
+.bt-model-menu{overflow-y:auto;scrollbar-width:thin}
+.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none}
+.bt-model-group:first-child{padding-top:2px}
+.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)}
+.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards}
+.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
+.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin}
+.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0}
+.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere}
+.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px}
+.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}
+.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)}
+.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
+.bt-model-chip-quiet{color:var(--bt-ink-2)}
+.bt-model-chip svg{width:14px;height:14px}
+.bt-model-chip:disabled{cursor:default}
+.bt-model-chip:disabled:not([aria-busy]){opacity:.5}
+.bt-model-error{margin-left:30px}
+@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}}
+.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite}
+@keyframes bt-model-spin{to{transform:rotate(360deg)}}
+@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}}
+`;
+
+// src/client/model-test.js
+var import_react23 = require("react");
+var FAILURE_COPY = {
+ NO_MODEL: "Pick a model first.",
+ NO_KEY: "This model has no API key yet. Set one up in the model settings.",
+ AUTH: "The API key was rejected. Check it in the model settings.",
+ QUOTA: "The account is out of quota.",
+ RATE_LIMIT: "Too many requests right now. Try again in a moment.",
+ TIMEOUT: "The model did not answer within 30 seconds.",
+ NOT_SERVED: "This model is not available now. Check its provider in the model settings.",
+ MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.",
+ TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.",
+ SERVER: "The model service failed. Try again later.",
+ INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.",
+ UNKNOWN: "The test failed."
+};
+function testOutcome(result, modelName) {
+ if (result?.ok === true) {
+ const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0;
+ return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) };
+ }
+ const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN";
+ return { ok: false, code, text: t(FAILURE_COPY[code]) };
+}
+var testModel = (id) => call("test-model", { id });
+function ModelTestRow({ bot, modelName, test = testModel }) {
+ const [state, setState] = (0, import_react23.useState)({ busy: false, outcome: void 0 });
+ const live = (0, import_react23.useRef)(true);
+ (0, import_react23.useEffect)(() => () => {
+ live.current = false;
+ }, []);
+ (0, import_react23.useEffect)(() => {
+ setState({ busy: false, outcome: void 0 });
+ }, [bot.id, bot.model]);
+ const start = () => {
+ if (state.busy) return;
+ setState({ busy: true, outcome: void 0 });
+ Promise.resolve().then(() => test(bot.id)).then(
+ (result) => testOutcome(result, modelName ?? result?.ref ?? ""),
+ (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) })
+ ).then((outcome2) => {
+ if (live.current) setState({ busy: false, outcome: outcome2 });
+ });
};
- (0, import_react29.useEffect)(() => {
- void actions.refreshModels?.();
- }, [bot.id]);
- return (0, import_react29.createElement)(
+ const { busy, outcome } = state;
+ return (0, import_react23.createElement)(
"div",
- { className: "bt-bs" },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-bs-hero" },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-avatar-anchor" },
- (0, import_react29.createElement)(
- "button",
- { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) },
- (0, import_react29.createElement)(BotMark, { bot, size: 64, live: true }),
- (0, import_react29.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react29.createElement)(PencilIcon))
- ),
- editing ? (0, import_react29.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
- ),
- (0, import_react29.createElement)(
- "div",
- { className: "bt-bs-who" },
- (0, import_react29.createElement)("span", { className: "bt-bs-name" }, bot.name),
- (0, import_react29.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
- )
- ),
- (0, import_react29.createElement)(
- Card,
- { title: t("Profile") },
- (0, import_react29.createElement)(Row, { label: t("Name") }, (0, import_react29.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
- (0, import_react29.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react29.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
- ),
- (0, import_react29.createElement)(
- Card,
- { title: t("Model") },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
- (0, import_react29.createElement)(ModelMenu, { bot, roster, actions, run })
- ),
- bot.model ? (0, import_react29.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
- ),
- (0, import_react29.createElement)(
- Card,
- { title: t("Working folder") },
- (0, import_react29.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
- (0, import_react29.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
- )
- ),
- (0, import_react29.createElement)(
- Card,
- { title: t("Instructions") },
- (0, import_react29.createElement)(
- Row,
- { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true },
- (0, import_react29.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
+ { className: "bt-set-row bt-bs-row bt-model-test" },
+ (0, import_react23.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
+ (0, import_react23.createElement)(
+ "span",
+ { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) },
+ busy ? t("Testing…") : outcome === void 0 ? null : outcome.text,
+ outcome?.code ? (0, import_react23.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
)
),
- // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406).
- (0, import_react29.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react29.createElement)(Card, { title: t("Sharing") }, children) }),
- (0, import_react29.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
- (0, import_react29.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
- roster.memory === false ? null : (0, import_react29.createElement)(MemoryCard, { bot, actions }),
- (0, import_react29.createElement)(BotSchedules, { bot, roster, actions }),
- (0, import_react29.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
- (0, import_react29.createElement)(
- Card,
- { title: t("Main Bot") },
- onlyMain ? (0, import_react29.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react29.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
- ),
- (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
- ) : (0, import_react29.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
- ),
- // The Main Bot tile is always first and always shown, like in the context menu.
- isTile ? null : (0, import_react29.createElement)(
- Card,
- { title: t("Sidebar") },
- (0, import_react29.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
- isMainBot ? null : (0, import_react29.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
- ),
- (0, import_react29.createElement)(
- Card,
- { title: t("More") },
- (0, import_react29.createElement)(
- "button",
- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate },
- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
- (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon))
- ),
- isMainBot ? null : (0, import_react29.createElement)(
- "button",
- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
- (0, import_react29.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react29.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
- (0, import_react29.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
- ),
- (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon))
- )
- )
+ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
);
}
-var BOT_SETTINGS_CSS = `
-.bt-bs{display:flex;flex-direction:column;gap:18px}
-.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px}
-.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px}
-.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
-.bt-bs .bt-avedit{left:0;transform:none}
-.bt-bs-row{min-height:48px}
-label.bt-bs-row{cursor:text}
-.bt-bs-label{font-size:13px;line-height:18px;font-weight:500}
-/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to
- break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test
- connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under
- a label is prose, so it wraps and is never cut: the row grows instead. */
-.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch}
-.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
-.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap}
-.bt-bs-row .bt-model-trigger{flex:none;max-width:58%}
-.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
-${MODEL_TEST_CSS}
-.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease}
-.bt-bs-input:hover{background:var(--bt-hover)}
-.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left}
-.bt-bs-input::placeholder{color:var(--bt-ink-3)}
-.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px}
-.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)}
-.bt-bs-area:hover{background:var(--bt-active)}
-.bt-bs-danger .bt-bs-label{color:#c21d2e}
-.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)}
-.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)}
-.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease}
-.bt-switch[aria-checked=true]{background:var(--bt-accent)}
-.bt-switch:disabled{opacity:.45;cursor:default}
-.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
-.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
-.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
-.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
-.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
-.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
-@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
-/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
- 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
- keeps its own words. Nothing is clipped to fit a width that is not there. */
-@container bt-panel (max-width:300px){
- .bt-bs-row{flex-wrap:wrap;row-gap:6px}
- .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
- .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
-}
-`;
-
-// src/client/group-settings.js
-var import_react30 = require("react");
-var ROOM_MODES = [
- ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
- ["everyone", "Everyone", "Every member answers in turn, admin first."]
-];
-var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
-function faceSeatBot(seat, roster) {
- if (!seat) return void 0;
- const record = seat.record && typeof seat.record === "object" ? seat.record : {};
- const extra = {};
- if (typeof record.color === "string" && record.color) extra.color = record.color;
- if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar;
- if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local;
- const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : "";
- return lookBotForRemote({
- handle,
- displayName: record.displayName || seat.bot,
- name: seat.bot,
- label: record.label,
- id: record.id || seat.key,
- ...extra
- }, roster);
-}
-function faceMemberLook(bot) {
- if (!bot) return void 0;
- return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot);
-}
-function faceMarks(person, roster, size) {
- const marks = [];
- for (const seat of person?.seats ?? []) {
- const bot = faceSeatBot(seat, roster);
- if (!bot) continue;
- marks.push((0, import_react30.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
- }
- if (marks.length === 0) return [(0, import_react30.createElement)(PersonMark, { key: "person", size, name: person?.name })];
- return marks;
-}
-function faceRoomCount(room2, roster) {
- const people2 = facePeople(room2, roster).length;
- const seats = faceSeats(room2, roster).length;
- const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
- const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
- return `${persons} · ${bots}`;
-}
-var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
-var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
-function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
- const bots = person.seats.flatMap((seat, index) => {
- const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
- const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
- return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
- });
- return (0, import_react30.createElement)(
- "div",
- { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who bt-face-person" },
- (0, import_react30.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
- (0, import_react30.createElement)(
- "span",
- { className: "bt-face-person-text" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
- (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots)
- )
- )
- );
+var MODEL_TEST_CSS = `
+.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+.bt-model-test-result:empty{display:none}
+.bt-model-test-result[data-ok=true]{color:#1a7f37}
+.bt-model-test-result[data-ok=false]{color:#c21d2e}
+.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
+/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own
+ words ("Test connection" on one line) and takes the room it needs. */
+.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch}
+.bt-model-test>.bt-soft{flex:none;white-space:nowrap}
+@container bt-panel (max-width:300px){
+ .bt-model-test{flex-wrap:wrap;row-gap:6px}
+ .bt-model-test>.bt-set-copy{flex:1 1 100%}
}
-function FaceLead({ room: room2, roster, pick: pick2 }) {
- const lead = room2.face?.lead;
- const [open, setOpen] = (0, import_react30.useState)(false);
- const [busy, setBusy] = (0, import_react30.useState)(false);
- const [error, setError] = (0, import_react30.useState)("");
- const seats = faceSeats(room2, roster);
- const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
- if (!current) return null;
- const canPick = lead.canPick === true && typeof pick2 === "function";
- const choose = async (handle) => {
- setBusy(true);
- setError("");
- try {
- await pick2(handle);
- setOpen(false);
- } catch (err) {
- setError(err?.message || t("Could not change the room lead."));
- } finally {
- setBusy(false);
+`;
+
+// src/client/folder-menu.js
+var import_react24 = require("react");
+var import_react_dom9 = require("react-dom");
+var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path);
+var Tick = ({ on }) => (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react24.createElement)(CheckIcon) : null);
+function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) {
+ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
+ const [info, setInfo] = (0, import_react24.useState)(null);
+ const [view, setView] = (0, import_react24.useState)("list");
+ const [draft, setDraft] = (0, import_react24.useState)("");
+ const [listing, setListing] = (0, import_react24.useState)(null);
+ const [problem, setProblem] = (0, import_react24.useState)("");
+ const fallback = group ? t("Each Bot's own folder") : t("Team workspace");
+ const toggle = () => {
+ if (isOpen) {
+ close();
+ return;
}
+ setView("list");
+ setProblem("");
+ void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] }));
+ open();
};
- return (0, import_react30.createElement)(
+ const choose = (path) => {
+ close();
+ if (path !== (value ?? "")) void onPick(path);
+ };
+ const browse = (path) => {
+ setProblem("");
+ void Promise.resolve(actions.folderList(path)).then((next) => {
+ setListing(next);
+ setView("browse");
+ }).catch((failure) => setProblem(failure?.message ?? String(failure)));
+ };
+ const pickNative = () => {
+ close();
+ void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0));
+ };
+ const typed = () => {
+ const path = draft.trim();
+ if (path !== "") choose(path);
+ };
+ const known = info?.folders ?? [];
+ const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known;
+ const listView = (0, import_react24.createElement)(
"div",
- { className: "bt-face-lead", "data-flybot": "face-lead" },
- (0, import_react30.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row" },
- (0, import_react30.createElement)(
+ { key: "list", className: "bt-folder-view" },
+ (0, import_react24.createElement)(
+ "button",
+ { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") },
+ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, fallback),
+ (0, import_react24.createElement)(Tick, { on: !value })
+ ),
+ folders.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
+ folders.map((folder) => (0, import_react24.createElement)(
+ "button",
+ { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) },
+ (0, import_react24.createElement)("span", { className: "bt-folder-copy" }, (0, import_react24.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react24.createElement)("span", { className: "bt-folder-path" }, folder.path)),
+ (0, import_react24.createElement)(Tick, { on: folder.path === value })
+ )),
+ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ info?.picker === "native" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
+ info?.picker === "browse" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
+ (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+ setDraft(value ?? "");
+ setView("type");
+ } }, (0, import_react24.createElement)(PencilIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
+ );
+ const typeView = (0, import_react24.createElement)(
+ "div",
+ { key: "type", className: "bt-folder-view" },
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+ (0, import_react24.createElement)("input", {
+ className: "bt-folder-input",
+ autoFocus: true,
+ value: draft,
+ spellCheck: false,
+ "aria-label": t("Folder path"),
+ placeholder: t("Full path of a folder"),
+ onChange: (event) => setDraft(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
+ }
+ }),
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
+ );
+ const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
+ const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
+ const browseView = listing ? (0, import_react24.createElement)(
+ "div",
+ { key: `browse:${listing.path}`, className: "bt-folder-view" },
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+ (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
+ parent ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
+ inside.map((entry) => (0, import_react24.createElement)(
+ "button",
+ { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
+ (0, import_react24.createElement)(FolderIcon),
+ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, entry.name),
+ (0, import_react24.createElement)(ChevronRightIcon)
+ )),
+ inside.length === 0 ? (0, import_react24.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
+ listing.truncated ? (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
+ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
+ ) : null;
+ const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
+ return (0, import_react24.createElement)(
+ import_react24.Fragment,
+ null,
+ (0, import_react24.createElement)(
+ "button",
+ {
+ ref: button,
+ type: "button",
+ className: "bt-model-trigger",
+ "data-wide": wide || void 0,
+ "aria-haspopup": "menu",
+ "aria-expanded": isOpen,
+ "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
+ title: value,
+ onClick: toggle
+ },
+ (0, import_react24.createElement)(
"span",
- { className: "bt-set-copy" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
+ { className: "bt-model-copy" },
+ (0, import_react24.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
+ value ? (0, import_react24.createElement)("span", { className: "bt-model-provider" }, value) : null
),
- canPick ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
+ (0, import_react24.createElement)(Caret)
),
- open && canPick ? seats.map((seat) => (0, import_react30.createElement)(
+ shownPlace ? (0, import_react_dom9.createPortal)((0, import_react24.createElement)(
"div",
- { key: seat.key, className: "bt-set-row" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
- seat === current ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
- )) : null,
- open && canPick && lead.picked ? (0, import_react30.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
- canPick || !lead.supported ? null : (0, import_react30.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
- error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
+ { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
+ shownView,
+ problem ? (0, import_react24.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
+ ), document.body) : null
);
}
-function seatedCount(room2) {
- const live = room2?.presentedParticipants ?? room2?.participants ?? [];
- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length;
- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length;
- return humans + remotes + (room2?.members?.length ?? 0);
+var FOLDER_CSS = `
+.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
+.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
+.bt-folder-copy .bt-menu-label{flex:none}
+.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
+.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
+.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
+.bt-folder-input:focus{border-color:var(--bt-accent)}
+.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
+.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
+.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
+@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
+`;
+
+// src/client/schedules.js
+var import_react25 = require("react");
+var POLL_MS4 = 3e4;
+var shared = null;
+var listeners = /* @__PURE__ */ new Set();
+function publish(view) {
+ shared = view;
+ for (const listener of listeners) listener(view);
}
-function roomSubtitle(room2, roster) {
- if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
- const admin = roster.byId[room2.admin];
- const count = seatedCount(room2);
- return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
+function useSchedules(actions) {
+ const [view, setView] = (0, import_react25.useState)(shared);
+ (0, import_react25.useEffect)(() => {
+ listeners.add(setView);
+ let stopped = false;
+ let timer;
+ const load = () => actions.schedules?.().then((next) => {
+ if (!stopped) publish(next);
+ }).catch(() => {
+ }).finally(() => {
+ if (!stopped) timer = setTimeout(load, POLL_MS4);
+ });
+ load();
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ listeners.delete(setView);
+ };
+ }, []);
+ return view;
}
-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
- const [adding, setAdding] = (0, import_react30.useState)(false);
- const [confirming, setConfirming] = (0, import_react30.useState)(false);
- (0, import_react30.useEffect)(() => {
- if (!confirming) return void 0;
- const timer = setTimeout(() => setConfirming(false), 4e3);
- return () => clearTimeout(timer);
- }, [confirming]);
- const live = room2.presentedParticipants ?? room2.participants ?? [];
- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
- const face = room2.origin === "face-to-face";
- const people2 = face ? facePeople(room2, roster) : [];
- const shared2 = humans.length > 1;
- const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
- const outside = roster.bots.filter((bot) => {
- if (bot.remote) return false;
- if (room2.members.includes(bot.id)) return false;
- if (shared2) return chiefs.has(bot.id);
- return true;
- });
- const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
- const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
- const admin = roster.byId[room2.admin];
- const mode = shownMode(room2, roster);
- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
- const update = (patch) => run(actions.updateRoom(room2.id, patch));
- const openBot = (id) => {
- actions.closeSettings();
- actions.openSession(id);
- };
+var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
+var clock2 = (time) => String(time ?? "").slice(0, 5);
+var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
+function dayAndTime(ms) {
+ const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
+ return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
+}
+function weekdayNames(days) {
+ const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
+ return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
+}
+function everyText(seconds) {
+ if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
+ if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
+ if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 });
+ return t("Every {count} seconds", { count: seconds });
+}
+function timingText(task) {
+ const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
+ switch (task.kind) {
+ case "after":
+ case "at":
+ return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
+ case "every":
+ return everyText(task.everySeconds);
+ case "daily":
+ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
+ case "weekly": {
+ const days = (task.weekdays ?? []).join();
+ const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
+ return `${text2}${zone}`;
+ }
+ case "cron":
+ return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
+ default:
+ return "";
+ }
+}
+var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
+function lastRunText(run) {
+ if (run === void 0 || !Number.isFinite(run.at)) return "";
+ const time = dayAndTime(run.at);
+ switch (run.result) {
+ case "running":
+ return t("Running since {time}", { time });
+ case "stopped":
+ return t("Last run {time} · Stopped", { time });
+ case "failed":
+ return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") });
+ default:
+ return t("Last run {time}", { time });
+ }
+}
+function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) {
+ const [confirming, setConfirming] = (0, import_react25.useState)(false);
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ (0, import_react25.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(false), 3e3);
+ return () => clearTimeout(timer);
+ }, [confirming]);
const remove = () => {
if (!confirming) {
setConfirming(true);
return;
}
- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
- if (done) onDeleted?.();
+ setBusy(true);
+ void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
+ };
+ const toggle = () => {
+ setBusy(true);
+ const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause;
+ void step(task.sessionId, task.id).then((next) => {
+ setBusy(false);
+ onView(next);
+ }, () => setBusy(false));
+ };
+ const [retryError, setRetryError] = (0, import_react25.useState)("");
+ const retry = () => {
+ setBusy(true);
+ setRetryError("");
+ void actions.scheduleRetry(task.sessionId, task.id).then((next) => {
+ setBusy(false);
+ onView(next);
+ }, (error) => {
+ setBusy(false);
+ setRetryError(hostText(error?.message ?? String(error)));
});
};
- return (0, import_react30.createElement)(
+ const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
+ const stoppable = task.status === "active" || task.status === "paused";
+ const failed = task.lastRun?.result === "failed";
+ const last = lastRunText(task.lastRun);
+ return (0, import_react25.createElement)(
"div",
- { className: "bt-bs" },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-bs-hero" },
- (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-bs-who" },
- (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name),
- (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
- )
+ { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 },
+ showBot ? (0, import_react25.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
+ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react25.createElement)("span", { className: "bt-task-title" }, task.title),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
+ task.prompt && task.prompt !== task.title ? (0, import_react25.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
+ last ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
+ retryError ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
),
- (0, import_react30.createElement)(
- Card,
- { title: t("Profile") },
- (0, import_react30.createElement)(
- Row,
- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
- (0, import_react30.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
- ),
- (0, import_react30.createElement)(
- Row,
- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
- (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
+ (0, import_react25.createElement)("span", { className: "bt-task-next" }, nextText(task)),
+ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
+ onEdit ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react25.createElement)(PencilIcon)) : null,
+ onCopy ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react25.createElement)(DuplicateIcon)) : null,
+ stoppable ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
+ (0, import_react25.createElement)("button", {
+ type: "button",
+ className: "bt-mini bt-task-remove",
+ "data-confirm": confirming || void 0,
+ disabled: busy,
+ "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
+ title: confirming ? void 0 : t("Delete task"),
+ onClick: remove
+ }, confirming ? t("Delete") : (0, import_react25.createElement)(TrashIcon))
+ );
+}
+var pad = (number) => String(number).padStart(2, "0");
+function inAnHour() {
+ const date = new Date(Date.now() + 36e5);
+ date.setMinutes(0, 0, 0);
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
+}
+var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
+var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]];
+function localInput(ms) {
+ const date = new Date(ms);
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+function formStart(task) {
+ const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600;
+ const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1];
+ const once = task?.kind === "at" || task?.kind === "after";
+ return {
+ title: task?.title ?? "",
+ prompt: task?.prompt ?? "",
+ kind: once ? "at" : task?.kind ?? "daily",
+ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(),
+ time: task?.time ? clock2(task.time) : "09:00",
+ weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5],
+ count: Math.max(1, Math.round(seconds / size)),
+ unit,
+ timeZone: task?.timeZone || localZone()
+ };
+}
+function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
+ const first = formStart(task);
+ const [target, setTarget] = (0, import_react25.useState)(task ? task.botId ?? task.roomId : start ?? "");
+ const [title, setTitle] = (0, import_react25.useState)(first.title);
+ const [prompt, setPrompt] = (0, import_react25.useState)(first.prompt);
+ const [kind, setKind] = (0, import_react25.useState)(first.kind);
+ const [at, setAt] = (0, import_react25.useState)(first.at);
+ const [time, setTime] = (0, import_react25.useState)(first.time);
+ const [weekdays, setWeekdays] = (0, import_react25.useState)(first.weekdays);
+ const [count, setCount] = (0, import_react25.useState)(first.count);
+ const [unit, setUnit] = (0, import_react25.useState)(first.unit);
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ const [error, setError] = (0, import_react25.useState)("");
+ const timing = () => {
+ if (kind === "cron") return { kind: "keep" };
+ if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone };
+ if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
+ return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} };
+ };
+ const submit = (event) => {
+ event.preventDefault();
+ if (busy || target === "") return;
+ setBusy(true);
+ setError("");
+ let when3;
+ try {
+ when3 = timing();
+ } catch {
+ setError(t("Pick a date and time"));
+ setBusy(false);
+ return;
+ }
+ const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 });
+ void saved.then(onDone, (failure) => {
+ setError(hostText(failure?.message ?? String(failure)));
+ setBusy(false);
+ });
+ };
+ const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
+ return (0, import_react25.createElement)(
+ "form",
+ { className: "bt-set-card bt-task-form", onSubmit: submit },
+ (0, import_react25.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
+ (0, import_react25.createElement)(
+ "select",
+ { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
+ (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
+ (0, import_react25.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react25.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
+ roster.rooms.length > 0 ? (0, import_react25.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react25.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
)
),
- (0, import_react30.createElement)(
- Card,
- { title: t("Who answers") },
- (0, import_react30.createElement)(
+ (0, import_react25.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+ (0, import_react25.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
+ ),
+ (0, import_react25.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
+ (0, import_react25.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
+ ),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("When"))),
+ (0, import_react25.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react25.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
+ (0, import_react25.createElement)(
"div",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", {
- key: id,
+ { key: kind, className: "bt-task-when bt-veil" },
+ kind === "cron" ? (0, import_react25.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
+ kind === "at" ? (0, import_react25.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
+ kind === "weekly" ? (0, import_react25.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react25.createElement)("button", {
+ key: day,
type: "button",
- "aria-pressed": mode === id,
- disabled: id === "admin" && !admin,
- title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
- onClick: () => {
- if (mode !== id) void update({ mode: id });
- }
- }, t(label)))),
- (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
+ className: "bt-task-day",
+ "aria-pressed": weekdays.includes(day),
+ onClick: () => toggleDay(day)
+ }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
+ kind === "daily" || kind === "weekly" ? (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
+ kind === "every" ? (0, import_react25.createElement)(
+ import_react25.Fragment,
+ null,
+ (0, import_react25.createElement)("span", { className: "bt-task-every" }, t("Every")),
+ (0, import_react25.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
+ (0, import_react25.createElement)(
+ "select",
+ { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
+ UNITS.map(([id, , label]) => (0, import_react25.createElement)("option", { key: id, value: id }, t(label)))
+ )
+ ) : null
)
),
- (0, import_react30.createElement)(
- Card,
- { title: t("Working folder") },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
- (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
+ error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-task-foot" },
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+ (0, import_react25.createElement)(
+ "button",
+ { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" },
+ task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task")
)
- ),
- face ? (0, import_react30.createElement)(
- Card,
- { title: t("Members · {count}", { count: people2.length }) },
- people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
- ) : null,
- face ? null : (0, import_react30.createElement)(
- Card,
- { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
- humans.map((person) => (0, import_react30.createElement)(
- "div",
- { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who" },
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
- (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")),
- person.role === "admin" ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
- ),
- humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
- )),
- members.map((member) => {
- const leads = room2.admin === member.id;
- const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share";
- const taskLead = room2.taskLead === member.id;
- return (0, import_react30.createElement)(
- "div",
- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "button",
- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
- (0, import_react30.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-row-title" }, member.name),
- leads ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
- taskLead ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null,
- (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
- ),
- leads ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
- boardHere ? taskLead ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null,
- members.length > 2 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
- );
- }),
- remotes.map((person) => (0, import_react30.createElement)(
+ )
+ );
+}
+function openPlugins(actions) {
+ actions.closeSettings();
+ actions.selectPanel("plugins");
+}
+function AutomationOff({ actions }) {
+ return (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-card bt-task-off" },
+ (0, import_react25.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react25.createElement)(ClockIcon)),
+ (0, import_react25.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+ );
+}
+function QuietHoursCard({ view, actions }) {
+ const quiet = view.quietHours ?? { on: false };
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ const [error, setError] = (0, import_react25.useState)("");
+ const [from, setFrom] = (0, import_react25.useState)(quiet.from ?? "22:00");
+ const [to, setTo] = (0, import_react25.useState)(quiet.to ?? "07:00");
+ const unchecked = view.quietUnchecked === true;
+ const refresh = () => {
+ void Promise.resolve(actions.schedules?.()).then((next) => {
+ if (next) publish(next);
+ }).catch(() => {
+ });
+ };
+ const save = (next) => {
+ if (busy) return;
+ setBusy(true);
+ setError("");
+ const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false };
+ void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => {
+ publish({ ...view, quietHours: shown });
+ refresh();
+ }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
+ };
+ return (0, import_react25.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react25.createElement)("h3", null, t("Quiet hours")),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-card" },
+ (0, import_react25.createElement)(
"div",
- { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
- (0, import_react30.createElement)(
- "span",
- { className: "bt-gs-who" },
- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
- // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
- // labelled as such; a Bot with no Bot Social identity can never be written to at all.
- room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
- ),
- // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
- room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
- )),
- room2.origin === "face-to-face" ? null : adding ? [
- ...outsidePeople.map((person) => (0, import_react30.createElement)(
- "button",
- { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
- } },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
- )),
- ...outside.map((bot) => (0, import_react30.createElement)(
- "button",
- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ add: [bot.id] });
- } },
- (0, import_react30.createElement)(BotMark, { bot, size: 22 }),
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, bot.name)),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add"))
- )),
- ...outsideRemotes.map((bot) => (0, import_react30.createElement)(
- "button",
- { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
- setAdding(false);
- void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
- } },
- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
- )),
- (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
- ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null,
- (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)(
- "span",
- { className: "bt-set-hint" },
- // The host's two-Bot floor guards membership changes; this hint warns before one.
- members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
- )),
- // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
- // the note on who leads, so that note stays with the members it is about (#892).
- room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
- ),
- (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
- (0, import_react30.createElement)(
- Card,
- { title: t("Sidebar") },
- (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
- (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
- ),
- (0, import_react30.createElement)(
- Card,
- { title: t("More") },
- (0, import_react30.createElement)(
- "button",
- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
- (0, import_react30.createElement)(
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)(
"span",
{ className: "bt-set-copy" },
- (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
+ (0, import_react25.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")),
+ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists."))
),
- (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
- )
+ (0, import_react25.createElement)("button", {
+ type: "button",
+ className: "bt-soft bt-soft-sm",
+ disabled: busy,
+ "aria-pressed": quiet.on,
+ onClick: () => save(quiet.on ? { on: false } : { on: true, from, to })
+ }, quiet.on ? t("Turn off") : t("Turn on"))
+ ),
+ quiet.on ? (0, import_react25.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Window"))),
+ (0, import_react25.createElement)(
+ "div",
+ { className: "bt-task-when" },
+ (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }),
+ (0, import_react25.createElement)("span", { className: "bt-task-every" }, "–"),
+ (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }),
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window"))
+ )
+ ) : null,
+ error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+ quiet.on && unchecked ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null
)
);
}
-function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
- const [admin, setAdmin] = (0, import_react30.useState)(null);
- const [members, setMembers] = (0, import_react30.useState)([]);
- const [name, setName] = (0, import_react30.useState)("");
- const [busy, setBusy] = (0, import_react30.useState)(false);
- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
- const pickAdmin = (id) => {
- setAdmin(id);
- setMembers((list) => list.filter((item) => item !== id));
+function SchedulesPage({ roster, actions, start }) {
+ const view = useSchedules(actions);
+ const [adding, setAdding] = (0, import_react25.useState)(Boolean(start));
+ const [editing, setEditing] = (0, import_react25.useState)(null);
+ if (view === null) return (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
+ if (!view.available) return (0, import_react25.createElement)(AutomationOff, { actions });
+ const shown = (next) => {
+ publish(next);
+ setAdding(false);
+ setEditing(null);
};
- const create = async () => {
- if (busy || admin === null || members.length === 0) return;
+ const edit = (task) => {
+ setAdding(false);
+ setEditing(task);
+ };
+ const byChat = [
+ ...roster.bots.map((bot) => [bot.id, (0, import_react25.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
+ ...roster.rooms.map((room2) => [room2.id, (0, import_react25.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
+ ].filter(([, , , tasks]) => tasks.length > 0);
+ return [
+ (0, import_react25.createElement)(QuietHoursCard, { key: "quiet", view, actions }),
+ adding ? (0, import_react25.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react25.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
+ setEditing(null);
+ setAdding(true);
+ } }, (0, import_react25.createElement)(PlusIcon), t("New task"))),
+ byChat.length === 0 && !adding ? (0, import_react25.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react25.createElement)(
+ "section",
+ { key: id, className: "bt-set-section" },
+ (0, import_react25.createElement)("h3", { className: "bt-task-head" }, avatar, name),
+ (0, import_react25.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
+ ))
+ ];
+}
+function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) {
+ const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0;
+ const [copying, setCopying] = (0, import_react25.useState)(null);
+ const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0;
+ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react25.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 }));
+ return (0, import_react25.createElement)(
+ import_react25.Fragment,
+ null,
+ open ? (0, import_react25.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
+ source ? (0, import_react25.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => {
+ setCopying(null);
+ onDone(next);
+ }, onCancel: () => setCopying(null) }) : null,
+ rows.length + extra.length > 0 ? (0, import_react25.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
+ );
+}
+function CopyRow({ task, roster, actions, onView, onCancel }) {
+ const others = [
+ ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]),
+ ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name])
+ ];
+ const [target, setTarget] = (0, import_react25.useState)("");
+ const [busy, setBusy] = (0, import_react25.useState)(false);
+ const [error, setError] = (0, import_react25.useState)("");
+ const submit = (event) => {
+ event.preventDefault();
+ if (busy || target === "") return;
setBusy(true);
- let room2;
- const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
- room2 = value;
- }));
- if (done && room2) onCreated(room2);
- else setBusy(false);
+ setError("");
+ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => {
+ setBusy(false);
+ setError(hostText(failure?.message ?? String(failure)));
+ });
};
- const chip = (bot, on, onClick, role) => (0, import_react30.createElement)("button", {
- key: bot.id,
- type: "button",
- role,
- className: "bt-ng-chip",
- "aria-checked": on,
- onClick
- }, (0, import_react30.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react30.createElement)(AdminStar) : null);
- return (0, import_react30.createElement)(
- "div",
- { className: "bt-set-card bt-ng" },
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-bs-row bt-bs-stack" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react30.createElement)(AdminStar), t("Choose the group admin"))),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
- )
+ return (0, import_react25.createElement)(
+ "form",
+ { className: "bt-set-row bt-task-copy", onSubmit: submit },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))),
+ (0, import_react25.createElement)(
+ "select",
+ { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) },
+ (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
+ others.map(([id, name]) => (0, import_react25.createElement)("option", { key: id, value: id }, name))
),
- admin === null ? null : (0, import_react30.createElement)(
+ (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")),
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+ error ? (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null
+ );
+}
+function BotSchedules({ bot, roster, actions }) {
+ const view = useSchedules(actions);
+ const [editing, setEditing] = (0, import_react25.useState)(null);
+ if (view === null) return null;
+ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
+ return (0, import_react25.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react25.createElement)("h3", null, t("Scheduled tasks")),
+ !view.available ? (0, import_react25.createElement)(
"div",
- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
- (0, import_react30.createElement)(
+ { className: "bt-set-card" },
+ (0, import_react25.createElement)(
"div",
- { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
- roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
)
- ),
- admin === null || members.length === 0 ? null : (0, import_react30.createElement)(
- "label",
- { className: "bt-set-row bt-bs-row bt-ng-step" },
- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
- (0, import_react30.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
- ),
- (0, import_react30.createElement)(
- "div",
- { className: "bt-set-row bt-task-foot" },
- (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
- (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
- )
+ ) : (0, import_react25.createElement)(TaskList, {
+ tasks,
+ editing,
+ roster,
+ actions,
+ onEdit: setEditing,
+ onDone: (next) => {
+ publish(next);
+ setEditing(null);
+ },
+ onCancel: () => setEditing(null),
+ extra: [(0, import_react25.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react25.createElement)(PlusIcon), t("New task"))]
+ })
);
}
-var GROUP_SETTINGS_CSS = `
-.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
-.bt-ng-step{animation:bt-veil-in .22s ease-out}
-.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
-.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
-.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
-.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
-.bt-ng-chip:active{scale:.96}
-.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
-.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
-.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
-.bt-gs-member{gap:8px}
-.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
-.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
-.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
-.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
-.bt-face-marks>.bt-mark{flex:none}
-.bt-face-person{cursor:default}
-.bt-face-person:hover .bt-row-title{text-decoration:none}
-.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
-.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
-.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
-button.bt-face-bot{cursor:pointer}
-button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
-.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
-.bt-bs-stack .bt-seg{align-self:stretch}
-@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
+function ScheduleSummary({ bot, actions }) {
+ const view = useSchedules(actions);
+ const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
+ return (0, import_react25.createElement)(
+ "div",
+ null,
+ (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
+ tasks.length === 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react25.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react25.createElement)(
+ "button",
+ { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") },
+ (0, import_react25.createElement)(ClockIcon),
+ (0, import_react25.createElement)("span", { className: "bt-task-line-title" }, task.title),
+ (0, import_react25.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
+ )))
+ );
+}
+var SCHEDULE_CSS = `
+.bt-task{gap:10px}
+.bt-task-copy{flex-wrap:wrap;gap:8px}
+.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%}
+.bt-task[data-done]{opacity:.6}
+.bt-task[data-busy]{opacity:.4}
+.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere}
+.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
+.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
+.bt-task-remove svg{width:14px;height:14px}
+.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
+.bt-task-tool svg{width:14px;height:14px}
+.bt-task-pause{height:26px;color:var(--bt-ink-2)}
+.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere}
+.bt-task-last[data-failed]{color:#c21d2e}
+.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)}
+.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135}
+.bt-task-head{display:flex;align-items:center;gap:6px}
+.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
+.bt-task-bot{width:auto;min-width:160px;max-width:58%}
+.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
+.bt-task-when .bt-input{width:auto;height:32px}
+.bt-task-time{min-width:110px}
+.bt-task-count{width:72px!important}
+.bt-task-unit{width:auto;height:32px}
+.bt-task-every{font-size:13px;color:var(--bt-ink-2)}
+.bt-task-days{display:flex;gap:4px}
+.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease}
+.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)}
+.bt-task-foot{justify-content:flex-end;gap:8px}
+.bt-task-foot .bt-send{height:28px;font-size:12px}
+.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center}
+.bt-task-off>*+*{border-top:0}
+.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
+.bt-task-off-mark svg{width:20px;height:20px}
+.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600}
+.bt-task-off .bt-set-hint{max-width:380px}
+.bt-task-list{display:flex;flex-direction:column;gap:2px}
+.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease}
+.bt-task-line:hover{background:var(--bt-hover)}
+.bt-task-line svg{flex:none;color:var(--bt-ink-3)}
+.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)}
+@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}}
`;
-// src/client/palette.js
-var import_react31 = require("react");
-var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
-var paletteText = (raw, names) => readableLine(String(raw ?? ""), names);
-var PALETTE_EXIT_MS = 160;
-function CommandPalette({ start, token, roster, actions, useSessions }) {
- const [query, setQuery] = (0, import_react31.useState)(start ?? "");
- const [cursor, setCursor] = (0, import_react31.useState)(0);
- const [leaving, setLeaving] = (0, import_react31.useState)(false);
- const wanted = query.trim().toLowerCase();
- const close = () => {
- if (leaving) return;
- setLeaving(true);
- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
- };
- useEscape(close);
- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
- const names = botNames(roster);
- const found = useSessions((list) => {
- if (wanted.length < 2) return "[]";
- const hits = [];
- for (const id of ids) {
- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
- if (!Array.isArray(outline)) continue;
- const hidden = roster.exchangeTurns[id] ?? [];
- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
- const entry = outline[index2];
- if (hidden.includes(entry.turn)) continue;
- for (const raw of [entry.response, entry.prompt]) {
- const text2 = paletteText(raw, names);
- const at = text2.toLowerCase().indexOf(wanted);
- if (at < 0) continue;
- const from = Math.max(0, at - 24);
- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]);
- break;
- }
- }
+// src/client/capability-settings.js
+var import_react26 = require("react");
+var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] });
+var parseMap = (text2, label) => {
+ let value;
+ try {
+ value = JSON.parse(text2 || "{}");
+ } catch {
+ throw new Error(t("{label} must be valid JSON.", { label }));
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label }));
+ return value;
+};
+var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…");
+function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) {
+ return (0, import_react26.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t(label))),
+ (0, import_react26.createElement)(multiline ? "textarea" : "input", {
+ className: multiline ? "bt-input bt-input-area" : "bt-input",
+ value,
+ disabled,
+ maxLength: label === "Name" ? 32 : void 0,
+ placeholder,
+ spellCheck: false,
+ onChange: (event) => onChange(event.target.value)
+ })
+ );
+}
+function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
+ const [error, setError] = (0, import_react26.useState)("");
+ const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
+ const submit = async (event) => {
+ event.preventDefault();
+ setError("");
+ let payload;
+ try {
+ payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description };
+ if (draft.kind === "mcp" && draft.transport === "stdio") {
+ payload.transport = "stdio";
+ payload.command = draft.command;
+ payload.args = draft.argsText.split("\n").filter((line2) => line2 !== "");
+ payload.env = parseMap(draft.envText, t("Environment"));
+ } else if (draft.kind === "mcp") {
+ payload.transport = "streamable-http";
+ payload.url = draft.url;
+ payload.headers = parseMap(draft.headersText, t("Headers"));
+ } else if (draft.kind === "skill") payload.content = draft.content;
+ else payload.items = draft.items;
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
+ return;
+ }
+ try {
+ await actions.saveCapability(payload);
+ onClose();
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
}
- return JSON.stringify(hits);
- });
- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
- const sections = [];
- const people2 = [
- ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
- key: bot.id,
- avatar: (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
- title: pickerBotLabel(bot),
- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
- badge: bot.hidden ? t("Hidden") : void 0,
- run: async () => {
- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
- actions.openSession(bot.id);
- }
- })),
- ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
- key: room2.id,
- avatar: (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
- title: room2.name,
- subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }),
- badge: room2.hidden ? t("Hidden") : void 0,
- run: async () => {
- if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
- actions.openSession(room2.id);
- }
- }))
- ];
- if (people2.length) sections.push([t("Bots"), people2]);
- const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
- key: agent.id,
- avatar: (0, import_react31.createElement)("span", { className: "bt-palette-agent" }, (0, import_react31.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
- title: "DSH Agent",
- subtitle: t("Plain dsh session"),
- run: () => actions.openAgent(agent.id)
- }));
- if (agents.length) sections.push(["DSH Agent", agents]);
- const messages = JSON.parse(found).map(([id, turn, snippet]) => {
- const bot = roster.byId[id];
- const room2 = roster.roomsById[id];
- return {
- key: `${id}:${turn}`,
- title: bot?.name ?? room2?.name ?? t("Bot"),
- subtitle: snippet,
- avatar: bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
- run: () => actions.openSession(id)
- };
- });
- if (messages.length) sections.push([t("Messages"), messages]);
- const commands = [
- { key: "cmd:new", icon: (0, import_react31.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
- { key: "cmd:bot", icon: (0, import_react31.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
- { key: "cmd:group", icon: (0, import_react31.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
- { key: "cmd:settings", icon: (0, import_react31.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
- ].filter((command) => matchesName(command.title));
- if (commands.length) sections.push([t("Commands"), commands]);
- const rows = sections.flatMap(([, items]) => items);
- const active = Math.min(cursor, Math.max(rows.length - 1, 0));
- const shown = rows.map((row) => row.key).join("\n");
- const glide = useGlide(`${active}
-${shown}`, { axis: "y", ...QUICK_EDGES });
- const listRef = glide.box;
- useFlip(listRef, shown, "[data-flip]");
- (0, import_react31.useEffect)(() => {
- listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
- }, [active, query]);
- const choose = (row) => {
- if (!row || leaving) return;
- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
- close();
};
- let index = -1;
- return (0, import_react31.createElement)(
- "div",
- { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
- (0, import_react31.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
- (0, import_react31.createElement)(
+ const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
+ return (0, import_react26.createElement)(
+ "form",
+ { className: "bt-connector-form", onSubmit: submit },
+ (0, import_react26.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
+ (0, import_react26.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
+ (0, import_react26.createElement)(
+ "select",
+ { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
+ ["mcp", "skill", "pack"].map((kind) => (0, import_react26.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
+ )
+ ),
+ (0, import_react26.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
+ draft.kind === "mcp" ? (0, import_react26.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
+ (0, import_react26.createElement)(
+ "select",
+ { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) },
+ (0, import_react26.createElement)("option", { value: "stdio" }, t("Local command")),
+ (0, import_react26.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
+ )
+ ) : null,
+ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react26.createElement)(
"div",
- { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
- (0, import_react31.createElement)(
- "div",
- { className: "bt-palette-head" },
- (0, import_react31.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)),
- (0, import_react31.createElement)("input", {
- autoFocus: true,
- value: query,
- placeholder: t("Search Bots, chats and messages"),
- "aria-label": t("Search"),
- role: "combobox",
- "aria-expanded": true,
- "aria-controls": "bt-palette-list",
- onChange: (event) => {
- setQuery(event.target.value);
- setCursor(0);
- },
- onKeyDown: (event) => {
- if (event.nativeEvent.isComposing) return;
- if (event.key === "ArrowDown") {
- event.preventDefault();
- setCursor(Math.min(active + 1, rows.length - 1));
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- setCursor(Math.max(active - 1, 0));
- } else if (event.key === "Enter") {
- event.preventDefault();
- choose(rows[active]);
- }
- }
- }),
- query ? (0, import_react31.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
- setQuery("");
- setCursor(0);
- } }, (0, import_react31.createElement)(CloseIcon)) : null
- ),
- (0, import_react31.createElement)(
- "div",
- { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
- (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- rows.length === 0 ? (0, import_react31.createElement)(
- "div",
- { className: "bt-palette-empty" },
- (0, import_react31.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)),
- (0, import_react31.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
- (0, import_react31.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
- ) : sections.map(([title, items]) => (0, import_react31.createElement)(
- import_react31.Fragment,
- { key: title },
- (0, import_react31.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
- items.map((row) => {
- index += 1;
- const at = index;
- return (0, import_react31.createElement)(
- "button",
- {
- key: row.key,
- type: "button",
- role: "option",
- className: "bt-palette-row",
- "aria-selected": at === active,
- "data-flip": row.key,
- onMouseMove: () => {
- if (cursor !== at) setCursor(at);
- },
- onClick: () => choose(row)
- },
- (0, import_react31.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react31.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
- (0, import_react31.createElement)(
- "span",
- { className: "bt-palette-text" },
- (0, import_react31.createElement)("span", { className: "bt-palette-title" }, row.title),
- row.subtitle ? (0, import_react31.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
- ),
- row.badge ? (0, import_react31.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
- );
- })
- ))
- )
+ null,
+ (0, import_react26.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
+ (0, import_react26.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
+ (0, import_react26.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+ ) : null,
+ draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react26.createElement)(
+ "div",
+ null,
+ (0, import_react26.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
+ (0, import_react26.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+ ) : null,
+ draft.kind === "skill" ? (0, import_react26.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
+ draft.kind === "pack" ? (0, import_react26.createElement)(
+ "div",
+ { className: "bt-set-card" },
+ available.length === 0 ? (0, import_react26.createElement)("div", { className: "bt-set-row" }, (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react26.createElement)(
+ "label",
+ { key: item.name, className: "bt-set-row bt-bs-row" },
+ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, item.name)),
+ (0, import_react26.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
+ ))
+ ) : null,
+ error ? (0, import_react26.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
+ (0, import_react26.createElement)(
+ "div",
+ { className: "bt-connector-form-row" },
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
+ (0, import_react26.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
)
);
}
-
-// src/client/bot-draft.js
-function freshBotName(roster, base = "New Bot") {
- const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
- if (!taken.has(base.toLowerCase())) return base;
- let index = 2;
- while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
- return `${base} ${index}`;
-}
-function startModel(roster) {
- const models = roster.models ?? [];
- const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
- const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
- return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
-}
-function createPayload({ name, look, image, model, effort }) {
- const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
- if (model) payload.model = model;
- if (effort) payload.effort = effort;
- return payload;
-}
-async function afterCreate({ id, text: text2, actions }) {
- actions.openSession(id);
- actions.showDetails(id, "settings");
- const line2 = String(text2 ?? "").trim();
- if (line2 === "") return;
- try {
- await actions.send(id, line2);
- } catch (failure) {
- actions.notify?.(failure?.message ?? String(failure));
- }
-}
-
-// src/client/create-bot.js
-var import_react32 = require("react");
-var pick = (list) => list[Math.floor(Math.random() * list.length)];
-var randomLook = (avoid = {}) => {
- const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
- const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
- return { shape: pick(shapes), color: pick(colors) };
-};
-function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
- const [look, setLook] = (0, import_react32.useState)(() => randomLook());
- const [image, setImage] = (0, import_react32.useState)(null);
- const [name, setName] = (0, import_react32.useState)(start?.name ?? "");
- const [model, setModel] = (0, import_react32.useState)(() => startModel(roster));
- const [effort, setEffort] = (0, import_react32.useState)(void 0);
- const [busy, setBusy] = (0, import_react32.useState)(false);
- const [error, setError] = (0, import_react32.useState)("");
- const fileRef = (0, import_react32.useRef)(null);
- const frame = (0, import_react32.useRef)(null);
- useEscape(onBack);
- (0, import_react32.useEffect)(() => {
- if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
- }, []);
- const fallbackName = freshBotName(roster, t("New Bot"));
- const finalName = name.trim() || fallbackName;
- const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
- const problem = nameProblem(finalName);
- const create = async () => {
- if (busy || clash || problem) return;
- setError("");
- setBusy(true);
- try {
- const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
- await onCreated(bot);
- } catch (failure) {
- setError(failure?.message ?? String(failure));
- setBusy(false);
- }
- };
- const upload = (file) => {
- if (!file) return;
- setError("");
- avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
- };
- const previewLook = image ? { ...look, image } : look;
- const card = (0, import_react32.createElement)(
+function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
+ const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
+ const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
+ return (0, import_react26.createElement)(
"div",
- { className: "bt-create-card" },
- (0, import_react32.createElement)(
+ { className: "bt-connector" },
+ (0, import_react26.createElement)(
"div",
- { className: "bt-create-preview" },
- // The key remounts the mark, so every new pick plays the pop.
- (0, import_react32.createElement)(
+ { className: "bt-connector-head" },
+ (0, import_react26.createElement)(
"span",
- { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
- (0, import_react32.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
+ { className: "bt-connector-text" },
+ (0, import_react26.createElement)("span", { className: "bt-connector-name" }, item.name),
+ (0, import_react26.createElement)("span", { className: "bt-connector-status" }, secondary)
),
- (0, import_react32.createElement)("span", { className: "bt-create-name" }, finalName)
- ),
- (0, import_react32.createElement)("div", { className: "bt-create-step" }, t("Look")),
- (0, import_react32.createElement)(
- "div",
- { className: "bt-create-looks" },
- (0, import_react32.createElement)(CharacterGrid, {
- look,
- hasPhoto: Boolean(image),
- onShape: (shape) => {
- setImage(null);
- setLook((current) => ({ ...current, shape }));
- },
- onColor: (color) => {
- setImage(null);
- setLook((current) => ({ ...current, color }));
- }
- }),
- (0, import_react32.createElement)(
- "div",
- { className: "bt-actions bt-create-lookbar" },
- (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
- setImage(null);
- setLook((current) => randomLook(current));
- } }, t("Shuffle")),
- (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
- image ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
- (0, import_react32.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
- const file = event.target.files?.[0];
- event.target.value = "";
- upload(file);
- } })
+ (0, import_react26.createElement)(
+ "span",
+ { className: "bt-connector-actions" },
+ item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
)
- ),
- (0, import_react32.createElement)(
- "label",
- { className: "bt-field" },
- (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Name")),
- (0, import_react32.createElement)("input", {
- className: "bt-input",
- autoFocus: !inline,
- value: name,
- placeholder: fallbackName,
- maxLength: 40,
- "aria-label": t("Name"),
- onChange: (event) => setName(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Enter" && !event.nativeEvent.isComposing) {
- event.preventDefault();
- void create();
- }
- }
- }),
- problem ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
- ),
- (0, import_react32.createElement)(
+ )
+ );
+}
+function CapabilitiesPage({ roster, actions, run }) {
+ const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name));
+ const [draft, setDraft] = (0, import_react26.useState)(null);
+ const [confirming, setConfirming] = (0, import_react26.useState)("");
+ (0, import_react26.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(""), 4e3);
+ return () => clearTimeout(timer);
+ }, [confirming]);
+ const startNew = () => setDraft(blank());
+ const edit = (item) => setDraft({
+ ...blank(),
+ ...item,
+ originalName: item.name,
+ argsText: (item.args ?? []).join("\n"),
+ envText: JSON.stringify(item.env ?? {}, null, 2),
+ headersText: JSON.stringify(item.headers ?? {}, null, 2),
+ items: [...item.items ?? []]
+ });
+ const remove = (item) => {
+ if (confirming !== item.name) {
+ setConfirming(item.name);
+ return;
+ }
+ setConfirming("");
+ void run(actions.deleteCapability(item.name));
+ };
+ return (0, import_react26.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react26.createElement)("h3", null, t("Shared capabilities")),
+ (0, import_react26.createElement)(
"div",
- { className: "bt-field" },
- (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Model")),
- (0, import_react32.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
- setModel(ref);
- setEffort(void 0);
- }, onPickEffort: setEffort }),
- (0, import_react32.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
+ { className: "bt-actions" },
+ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
),
- error ? (0, import_react32.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
- (0, import_react32.createElement)(
+ draft ? (0, import_react26.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
+ (0, import_react26.createElement)(
"div",
- { className: "bt-create-foot" },
- // The dialog's header already has Back and Close (#230); only the inline form needs its own.
- inline ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
- (0, import_react32.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
+ { className: "bt-set-card bt-connectors-card" },
+ items.length === 0 ? (0, import_react26.createElement)("div", { className: "bt-set-row" }, (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react26.createElement)(CapabilityRow, {
+ key: item.name,
+ item,
+ confirming: confirming === item.name,
+ onEdit: () => edit(item),
+ onDelete: () => remove(item),
+ onApprove: () => void run(actions.approveCapability(item.name))
+ }))
)
);
- if (inline) return (0, import_react32.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
- return (0, import_react32.createElement)(
- "div",
- { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") },
- (0, import_react32.createElement)(
- "div",
- { className: "bt-create-head" },
- canGoBack ? (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react32.createElement)(ChevronLeftIcon)) : (0, import_react32.createElement)("span", { className: "bt-create-gap" }),
- (0, import_react32.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
- (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react32.createElement)(CloseIcon))
- ),
- (0, import_react32.createElement)("div", { className: "bt-create-body" }, card)
+}
+function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
+ const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name));
+ const attached = new Set(bot.capabilities ?? []);
+ const statuses = roster.capabilityStatuses?.[bot.id] ?? {};
+ if (items.length === 0) return null;
+ return (0, import_react26.createElement)(
+ Card2,
+ { title: t("Capabilities") },
+ items.map((item) => (0, import_react26.createElement)(SwitchRow2, {
+ key: item.name,
+ label: item.name,
+ hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0,
+ on: attached.has(item.name),
+ onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled))
+ }))
);
}
-// src/client/new-chat.js
-var import_react33 = require("react");
-function NewChat({ mode, roster, actions }) {
- const [query, setQuery] = (0, import_react33.useState)("");
- const [picked, setPicked] = (0, import_react33.useState)([]);
- const [group, setGroup] = (0, import_react33.useState)(mode === "group");
- const [compose, setCompose] = (0, import_react33.useState)(mode !== "new");
- const [admin, setAdmin] = (0, import_react33.useState)(null);
- const [message, setMessage] = (0, import_react33.useState)("");
- const [busy, setBusy] = (0, import_react33.useState)(false);
- const [error, setError] = (0, import_react33.useState)("");
- const [fix, setFix] = (0, import_react33.useState)(null);
- const [fixNote, setFixNote] = (0, import_react33.useState)("");
- const [inviteLink, setInviteLink] = (0, import_react33.useState)("");
- const [cursor, setCursor] = (0, import_react33.useState)(0);
- const [draft, setDraft] = (0, import_react33.useState)(mode === "create" ? { name: "", fromList: false } : null);
- const inputRef = (0, import_react33.useRef)(null);
- const close = () => actions.closeOverlay();
- useEscape(close);
- const paneLeft = usePaneLeft();
- const wanted = query.trim().toLowerCase();
- const bots = listedChatBots(roster);
- const choosingAdmin = group && admin === null;
- const pool = choosingAdmin ? bots.filter((bot) => !bot.remote) : bots;
- const matches = pool.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted)));
- const exact = bots.some((bot) => bot.name.toLowerCase() === wanted);
- const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean);
- const run = async (task) => {
- if (busy) return;
- setError("");
- setFix(null);
- setFixNote("");
- setInviteLink("");
- setBusy(true);
- try {
- await task();
- } catch (failure) {
- setError(failure?.message ?? String(failure));
- setFix(ungrantedLocalFix(failure?.details) ?? null);
- } finally {
- setBusy(false);
+// src/client/connector-bots.js
+var import_react27 = require("react");
+function ownBots(roster) {
+ const me = roster?.self?.memberId;
+ return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me));
+}
+var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender" && !(connector.kind === "channel" && connector.mode !== "app"));
+var isOn = (bot, id) => bot.connectors?.[id] !== false;
+function ownHint(bot, id) {
+ return isOn(bot, id) ? t("On. This Bot can use it on every turn.") : t("Off. This Bot will not use it.");
+}
+function usageText(roster, id) {
+ const bots = ownBots(roster);
+ const own = bots.filter((bot) => isOn(bot, id)).length;
+ return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length });
+}
+function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) {
+ return (0, import_react27.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
+}
+function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) {
+ const bots = ownBots(roster);
+ return (0, import_react27.createElement)(
+ "div",
+ { className: "bt-connector-bots" },
+ bots.length === 0 ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react27.createElement)(
+ "div",
+ { key: bot.id, className: "bt-connector-bot" },
+ (0, import_react27.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
+ )),
+ (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
+ );
+}
+function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
+ const connected = connectedConnectors(roster);
+ if (connected.length === 0) return null;
+ return (0, import_react27.createElement)(
+ Card2,
+ { title: t("Connectors") },
+ connected.map((connector) => (0, import_react27.createElement)(
+ "div",
+ { key: connector.id, className: "bt-connector-bot" },
+ (0, import_react27.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
+ ))
+ );
+}
+
+// src/client/memory-panel.js
+var import_react28 = require("react");
+var POLL_MS5 = 1500;
+var MENU_EXIT_MS2 = 120;
+var counts = /* @__PURE__ */ new Map();
+var countListeners = /* @__PURE__ */ new Set();
+function noteCount(botId, count) {
+ if (counts.get(botId) === count) return;
+ counts.set(botId, count);
+ for (const listener of countListeners) listener();
+}
+function useMemoryCount(botId, actions) {
+ const [count, setCount] = (0, import_react28.useState)(counts.get(botId) ?? null);
+ (0, import_react28.useEffect)(() => {
+ const listener = () => setCount(counts.get(botId) ?? null);
+ countListeners.add(listener);
+ listener();
+ let stop = false;
+ void actions.memoryView?.(botId).then((view) => {
+ if (!stop) noteCount(botId, view.entries.length);
+ }).catch(() => {
+ });
+ return () => {
+ stop = true;
+ countListeners.delete(listener);
+ };
+ }, [botId]);
+ return count;
+}
+function MemoryRow({ bot, actions }) {
+ const count = useMemoryCount(bot.id, actions);
+ const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
+ return (0, import_react28.createElement)(
+ "div",
+ null,
+ (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Memory")),
+ (0, import_react28.createElement)(
+ "button",
+ { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
+ (0, import_react28.createElement)(MemoryIcon),
+ (0, import_react28.createElement)("span", { className: "bt-mem-row-copy" }, label),
+ (0, import_react28.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react28.createElement)(ChevronRightIcon))
+ )
+ );
+}
+function ago(at, now) {
+ const seconds = Math.max(0, Math.round((now - at) / 1e3));
+ if (seconds < 60) return null;
+ const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" });
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return format.format(-minutes, "minute");
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return format.format(-hours, "hour");
+ return format.format(-Math.round(hours / 24), "day");
+}
+function Skeleton({ label }) {
+ return (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-skel", "aria-busy": true },
+ [0, 1].map((block) => (0, import_react28.createElement)(
+ "div",
+ { key: block, className: "bt-mem-skel-block" },
+ (0, import_react28.createElement)("span", { className: "bt-mem-skel-head" }),
+ (0, import_react28.createElement)("span", null),
+ (0, import_react28.createElement)("span", null),
+ (0, import_react28.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
+ )),
+ label ? (0, import_react28.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
+ );
+}
+function Summary({ bot, view, regenerate }) {
+ if (view.entries.length === 0) {
+ return (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-empty" },
+ (0, import_react28.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react28.createElement)(MemoryIcon)),
+ (0, import_react28.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
+ (0, import_react28.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
+ );
+ }
+ const failed = view.error && !view.summarizing ? (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-failed", role: "alert" },
+ (0, import_react28.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
+ (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
+ ) : null;
+ if (!view.summary) return (0, import_react28.createElement)(import_react28.Fragment, null, failed, failed ? null : (0, import_react28.createElement)(Skeleton, { label: t("Writing the summary…") }));
+ return (0, import_react28.createElement)(
+ import_react28.Fragment,
+ null,
+ failed,
+ (0, import_react28.createElement)(
+ "div",
+ { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
+ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react28.createElement)("h3", { key: index }, block.text) : (0, import_react28.createElement)("p", { key: index }, block.text))
+ )
+ );
+}
+var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
+ const key = `${entry.scope}:${entry.topic ?? ""}`;
+ const group = groups.find((item) => item.key === key);
+ if (group) group.entries.push(entry);
+ else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
+ return groups;
+}, []);
+function EntryRow({ entry, remove }) {
+ const [confirming, setConfirming] = (0, import_react28.useState)(false);
+ const [busy, setBusy] = (0, import_react28.useState)(false);
+ (0, import_react28.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(false), 3e3);
+ return () => clearTimeout(timer);
+ }, [confirming]);
+ const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
+ const meta = [entry.added, by].filter(Boolean).join(" · ");
+ const click = async () => {
+ if (!confirming) {
+ setConfirming(true);
+ return;
}
+ setBusy(true);
+ if (!await remove(entry)) setBusy(false);
};
- const leaveOut = (id) => {
- if (id === admin) setAdmin(null);
- setPicked((current) => current.filter((other) => other !== id));
+ return (0, import_react28.createElement)(
+ "li",
+ { className: "bt-mem-entry", "data-busy": busy || void 0 },
+ (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-entry-copy" },
+ (0, import_react28.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
+ meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
+ ),
+ (0, import_react28.createElement)("button", {
+ type: "button",
+ className: "bt-mem-remove",
+ "data-confirm": confirming || void 0,
+ disabled: busy,
+ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
+ title: confirming ? void 0 : t("Remove from memory"),
+ onClick: click
+ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon))
+ );
+}
+function Entries({ bot, view, remove }) {
+ const groups = groupEntries(view.entries);
+ const sections = [
+ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
+ { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
+ ];
+ return (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-entries" },
+ sections.map((section) => (0, import_react28.createElement)(
+ "section",
+ { key: section.scope, className: "bt-mem-section" },
+ (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-section-head" },
+ (0, import_react28.createElement)("h3", null, section.title),
+ section.hint ? (0, import_react28.createElement)("span", null, section.hint) : null,
+ (0, import_react28.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
+ ),
+ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react28.createElement)(
+ import_react28.Fragment,
+ { key: group.key },
+ group.topic ? (0, import_react28.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
+ (0, import_react28.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react28.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
+ )) : (0, import_react28.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
+ ))
+ );
+}
+function changeLine(change) {
+ const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
+ if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
+ if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
+ if (change.op === "edit") return t("Updated: {text}", { text: text2 });
+ return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
+}
+function Reply({ answer, dismiss }) {
+ return (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-reply", role: "status" },
+ (0, import_react28.createElement)(
+ "div",
+ { className: "bt-mem-reply-head" },
+ (0, import_react28.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
+ answer.pending ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react28.createElement)(CloseIcon))
+ ),
+ answer.pending ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react28.createElement)(
+ import_react28.Fragment,
+ null,
+ answer.reply ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
+ answer.changes.length > 0 ? (0, import_react28.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react28.createElement)(
+ "li",
+ { key: index, "data-ok": change.ok || void 0 },
+ change.ok ? (0, import_react28.createElement)(CheckIcon) : (0, import_react28.createElement)(CloseIcon),
+ (0, import_react28.createElement)("span", null, changeLine(change))
+ ))) : null
+ )
+ );
+}
+function useMemoryView(botId, actions) {
+ const [view, setView] = (0, import_react28.useState)(null);
+ const [error, setError] = (0, import_react28.useState)("");
+ const timer = (0, import_react28.useRef)(0);
+ const latest = (0, import_react28.useRef)(0);
+ const alive = (0, import_react28.useRef)(true);
+ const show = (0, import_react28.useCallback)((next) => {
+ setView(next);
setError("");
- setFix(null);
- setFixNote("");
- setInviteLink("");
- };
- const copyInvite = async (bot) => {
- const social = globalThis.__flybot__?.social;
- let link;
+ noteCount(botId, next.entries.length);
+ }, [botId]);
+ const load = (0, import_react28.useCallback)(async (flags = {}) => {
+ clearTimeout(timer.current);
+ const ticket = ++latest.current;
try {
- const invite = await social.createInvite(bot.id);
- link = invite?.link || invite?.url;
- if (!link) throw new Error("no link");
- } catch {
- setInviteLink("");
- setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot."));
- return;
- }
- if (await copiedToClipboard(link)) {
- setInviteLink("");
- setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name }));
- } else {
- setInviteLink(link);
- setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name }));
+ const next = await actions.memoryView(botId, flags);
+ if (!alive.current || ticket !== latest.current) return;
+ show(next);
+ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
+ } catch (failure) {
+ if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
}
+ }, [botId]);
+ (0, import_react28.useEffect)(() => {
+ alive.current = true;
+ void load({ summarize: true });
+ return () => {
+ alive.current = false;
+ clearTimeout(timer.current);
+ };
+ }, [botId]);
+ return { view, error, load, show };
+}
+function useNow3(every) {
+ const [now, setNow] = (0, import_react28.useState)(Date.now());
+ (0, import_react28.useEffect)(() => {
+ const id = setInterval(() => setNow(Date.now()), every);
+ return () => clearInterval(id);
+ }, [every]);
+ return now;
+}
+function MemoryDialog({ bot, actions, leaving = false }) {
+ const { view, error, load, show } = useMemoryView(bot.id, actions);
+ const [page, setPage] = (0, import_react28.useState)("summary");
+ const [menu, setMenu] = (0, import_react28.useState)(false);
+ const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2);
+ const [text2, setText] = (0, import_react28.useState)("");
+ const [answer, setAnswer] = (0, import_react28.useState)(null);
+ const menuRef = (0, import_react28.useRef)(null);
+ const dotsRef = (0, import_react28.useRef)(null);
+ const inputRef = (0, import_react28.useRef)(null);
+ const now = useNow3(3e4);
+ const close = (0, import_react28.useCallback)(() => actions.closeMemory(), []);
+ useEscape(close);
+ (0, import_react28.useEffect)(() => {
+ if (!menu) return void 0;
+ const onDown = (event) => {
+ if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return;
+ setMenu(false);
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setMenu(false);
+ dotsRef.current?.focus();
+ }
+ };
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
+ return () => {
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
+ };
+ }, [menu]);
+ const regenerate = () => {
+ setMenu(false);
+ setPage("summary");
+ void load({ regenerate: true });
};
- const pick2 = (bot) => {
- setPicked((current) => [...current, bot.id]);
- setQuery("");
- setCursor(0);
- inputRef.current?.focus();
- };
- const chooseAdmin = (bot) => {
- setAdmin(bot.id);
- setQuery("");
- setCursor(0);
- inputRef.current?.focus();
- };
- const openBot = (bot) => run(async () => {
- if (choosingAdmin) {
- chooseAdmin(bot);
- return;
- }
- if (group || picked.length > 0) {
- pick2(bot);
- return;
- }
- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
- if (message.trim()) await actions.send(bot.id, message.trim());
- actions.openSession(bot.id);
- close();
- });
- const createBot = () => {
- if (busy) return;
- setError("");
- setDraft({ name: query.trim() !== "" && !exact ? query.trim() : "", fromList: true });
- };
- const created = (bot) => {
- close();
- return afterCreate({ id: bot.id, text: message, actions });
+ const openEntries = () => {
+ setMenu(false);
+ setPage("entries");
};
- const makeGroup = async () => {
- if (admin === null || picked.length === 0) return;
- const text2 = message.trim();
- const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]);
- const participants = remotes.length === 0 ? void 0 : [
- ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })),
- ...remotes
- ];
- const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} });
- if (text2) await actions.send(room2.id, text2);
- actions.openSession(room2.id);
- close();
+ const backToSummary = () => {
+ setPage("summary");
+ void load({ summarize: true });
};
- const createGroup = () => run(makeGroup);
- const sendMessage = () => run(async () => {
- const text2 = message.trim();
- if (group && admin !== null) {
- await makeGroup();
- return;
- }
- if (text2 === "" || picked.length === 0) return;
- if (picked.length === 1) {
- await actions.send(picked[0], text2);
- actions.openSession(picked[0]);
- close();
- return;
- }
- const { members, remotes } = splitRoomPicks(roster, picked);
- const participants = remotes.length === 0 ? void 0 : [
- ...members.map((id) => ({ kind: "bot", id })),
- ...remotes
- ];
- const room2 = await actions.createRoom(members, participants ? { participants } : {});
- await actions.send(room2.id, text2);
- actions.openSession(room2.id);
- close();
- });
- const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === "";
- const options = [];
- if (!group && picked.length === 0 && mode !== "search" && wanted === "") {
- if (menuOnly) {
- options.push({ key: "chat", icon: (0, import_react33.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
- setCompose(true);
- setCursor(0);
- setTimeout(() => inputRef.current?.focus());
- } });
- }
- options.push({ key: "new", icon: (0, import_react33.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
- options.push({ key: "group", icon: (0, import_react33.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
- setGroup(true);
- setCompose(true);
- setCursor(0);
- inputRef.current?.focus();
- } });
- for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
- options.push({
- key: action.key,
- icon: action.icon ? (0, import_react33.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react33.createElement)(PeopleIcon) }) : (0, import_react33.createElement)(PeopleIcon),
- label: t(action.label),
- hint: action.hint ? t(action.hint) : void 0,
- run: () => {
- const done = Promise.resolve().then(() => action.run({ close, actions, roster }));
- done.then((note) => {
- if (typeof note === "string" && note) actions.notify?.(note);
- }).catch((failure) => setError(failure?.message ?? String(failure)));
- }
- });
- }
- if ((roster.agents ?? []).length === 0) {
- options.push({ key: "agent", icon: (0, import_react33.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
- await actions.addAgent();
- close();
- }) });
+ const remove = async (entry) => {
+ try {
+ show(await actions.memoryForget(bot.id, entry));
+ return true;
+ } catch (failure) {
+ setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) });
+ return false;
}
- }
- if (!menuOnly) {
- for (const bot of matches) {
- options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) });
+ };
+ const ask = async (event) => {
+ event.preventDefault();
+ const asked = text2.trim();
+ if (asked === "" || answer?.pending) return;
+ setText("");
+ setAnswer({ asked, pending: true });
+ try {
+ const result = await actions.memoryAsk(bot.id, asked);
+ setAnswer({ asked, reply: result.reply, changes: result.changes });
+ show(result.view);
+ if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" });
+ } catch (failure) {
+ setAnswer({ asked, error: failure?.message ?? String(failure) });
}
- }
- if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react33.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
- const active = Math.min(cursor, Math.max(options.length - 1, 0));
- const listed = options.map((option) => option.key).join("\n");
- const glide = useGlide(`${active}
-${listed}`, { axis: "y", ...QUICK_EDGES });
- useFlip(glide.box, listed, "[data-flip]");
- const drop = (0, import_react33.useRef)(null);
- useMorph(drop, listed);
- if (draft) {
- return (0, import_react33.createElement)(
- "div",
- { className: "bt-pane", style: { left: paneLeft } },
- (0, import_react33.createElement)(CreateBot, {
- roster,
- actions,
- start: draft,
- canGoBack: draft.fromList,
- onBack: draft.fromList ? () => {
- setDraft(null);
- setTimeout(() => inputRef.current?.focus());
- } : close,
- onCreated: created
- })
- );
- }
- const adminBot = admin === null ? void 0 : roster.byId[admin];
- const recipients = adminBot ? [adminBot, ...pickedBots] : pickedBots;
- const placeholder = recipients.length > 0 ? t("Message {name}", { name: recipients.map((bot) => bot.name).join(t(" and ")) }) : t("Message Bot");
- const unpick = () => {
- if (picked.length > 0) setPicked((current) => current.slice(0, -1));
- else if (admin !== null) setAdmin(null);
+ inputRef.current?.focus();
};
- return (0, import_react33.createElement)(
+ const count = view?.entries.length ?? 0;
+ const when3 = view?.summary ? ago(view.summary.at, now) : null;
+ const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : "";
+ const busy = answer?.pending === true;
+ return (0, import_react28.createElement)(
"div",
- {
- className: "bt-pane",
- style: { left: paneLeft },
- "data-menu": menuOnly || void 0,
- onMouseDown: menuOnly ? (event) => {
- if (event.target === event.currentTarget) close();
- } : void 0
- },
- (0, import_react33.createElement)(
+ { className: "bt-mem-layer", "data-leaving": leaving || void 0 },
+ (0, import_react28.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
+ (0, import_react28.createElement)(
"div",
- { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 },
- // The popover needs no title row: Escape or a press outside closes it.
- menuOnly ? null : (0, import_react33.createElement)(
+ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) },
+ (0, import_react28.createElement)(
"div",
- { className: "bt-to" },
- (0, import_react33.createElement)("span", { className: "bt-to-label" }, t("To:")),
- adminBot ? (0, import_react33.createElement)(
- "span",
- { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") },
- (0, import_react33.createElement)(BotMark, { bot: adminBot, size: 16 }),
- adminBot.name,
- (0, import_react33.createElement)(AdminStar),
- (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
- ) : null,
- choosingAdmin ? (0, import_react33.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react33.createElement)(AdminStar)) : null,
- pickedBots.map((bot) => (0, import_react33.createElement)(
- "span",
- { key: bot.id, className: "bt-token" },
- (0, import_react33.createElement)(BotMark, { bot, size: 16 }),
- bot.name,
- (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
- )),
- (0, import_react33.createElement)("input", {
- ref: inputRef,
- autoFocus: true,
- value: query,
- "aria-label": t("Recipient"),
- placeholder: mode === "search" ? t("Search Bots…") : choosingAdmin ? t("Choose the group admin") : group ? t("Who to chat with…") : t("Start a chat with…"),
- onChange: (event) => {
- setQuery(event.target.value);
- setCursor(0);
- },
- onKeyDown: (event) => {
- if (event.nativeEvent.isComposing) return;
- if (event.key === "ArrowDown") {
- event.preventDefault();
- setCursor(Math.min(active + 1, options.length - 1));
- } else if (event.key === "ArrowUp") {
- event.preventDefault();
- setCursor(Math.max(active - 1, 0));
- } else if (event.key === "Enter" && options[active]) {
- event.preventDefault();
- options[active].run();
- } else if (event.key === "Backspace" && query === "") unpick();
- }
- }),
- group && admin !== null ? (0, import_react33.createElement)("button", {
- type: "button",
- className: "bt-send bt-to-create",
- disabled: busy || picked.length === 0,
- title: picked.length === 0 ? t("Add at least one more Bot") : void 0,
- onClick: createGroup
- }, t("Create group chat")) : null,
- (0, import_react33.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
+ { className: "bt-mem-head" },
+ page === "entries" ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react28.createElement)(ChevronLeftIcon)) : null,
+ (0, import_react28.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
+ (0, import_react28.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
+ (0, import_react28.createElement)("span", { className: "bt-mem-head-gap" }),
+ (0, import_react28.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react28.createElement)(DotsIcon)),
+ (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react28.createElement)(CloseIcon)),
+ menuShown ? (0, import_react28.createElement)(
+ "div",
+ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 },
+ (0, import_react28.createElement)(
+ "button",
+ { type: "button", role: "menuitem", onClick: openEntries },
+ (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
+ (0, import_react28.createElement)("span", { className: "bt-menu-hint" }, String(count))
+ ),
+ (0, import_react28.createElement)(
+ "button",
+ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate },
+ (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
+ )
+ ) : null
),
- (0, import_react33.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react33.createElement)(
- "div",
- { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" },
- (0, import_react33.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- options.length === 0 ? (0, import_react33.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react33.createElement)(
- "button",
- {
- key: option.key,
- type: "button",
- role: "option",
- className: "bt-option",
- "aria-selected": index === active,
- "data-flip": option.key,
- onMouseEnter: () => setCursor(index),
- onClick: option.run
- },
- option.bot ? (0, import_react33.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react33.createElement)("span", { className: "bt-option-icon" }, option.icon),
- // An action row's description sits under its title and is always there (#283): a long line
- // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows.
- // A Bot row's short hint stays beside the name and shows on the active row only.
- option.bot || !option.hint ? (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react33.createElement)("span", { className: "bt-option-copy" }, (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react33.createElement)("span", { className: "bt-option-sub" }, option.hint)),
- option.bot && option.hint && index === active ? (0, import_react33.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
- ))
- )),
- menuOnly ? null : (0, import_react33.createElement)("div", { className: "bt-nc-spacer" }),
- error && fix ? (0, import_react33.createElement)(
+ (0, import_react28.createElement)(
"div",
- { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" },
- (0, import_react33.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
- (0, import_react33.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
- typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react33.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
- void copyInvite(bot);
- } }, t("Copy invite link for {name}", { name: bot.name })) : null,
- (0, import_react33.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
- ])),
- fixNote ? (0, import_react33.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
- inviteLink ? (0, import_react33.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
- ) : error ? (0, import_react33.createElement)("div", { className: "bt-error" }, error) : null,
- menuOnly ? null : (0, import_react33.createElement)(
+ { className: "bt-mem-body" },
+ error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
+ view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate })
+ ),
+ (0, import_react28.createElement)(
"div",
- { className: "bt-nc-compose" },
- (0, import_react33.createElement)(
- "div",
- { className: "bt-nc-card" },
- (0, import_react33.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react33.createElement)(PlusIcon)),
- (0, import_react33.createElement)("textarea", {
- rows: 1,
- value: message,
- placeholder,
- "aria-label": placeholder,
- onChange: (event) => setMessage(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
- event.preventDefault();
- sendMessage();
- }
- }
+ { className: "bt-mem-foot" },
+ answer ? (0, import_react28.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
+ (0, import_react28.createElement)(
+ "form",
+ { className: "bt-mem-ask", onSubmit: ask },
+ (0, import_react28.createElement)("input", {
+ ref: inputRef,
+ className: "bt-mem-input",
+ value: text2,
+ maxLength: 2e3,
+ disabled: view === null,
+ placeholder: t("Ask or update"),
+ "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }),
+ onChange: (event) => setText(event.target.value)
}),
- (0, import_react33.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react33.createElement)(ArrowUpIcon))
+ (0, import_react28.createElement)(
+ "button",
+ { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") },
+ busy ? (0, import_react28.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react28.createElement)(ArrowUpIcon)
+ )
)
)
)
);
}
-// src/client/exchange.js
-var import_react34 = require("react");
-var SHEET_EXIT_MS = 220;
-function exchangeText(text2) {
- if (typeof text2 !== "string") return "";
- const CLOSE = "";
- let body = text2;
- for (let previous; previous !== body; ) {
- previous = body;
- const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body;
- body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : ""));
+// src/client/standing-approvals.js
+var import_react29 = require("react");
+var standingOf = (target, roster) => {
+ const me = roster?.self?.memberId;
+ return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me);
+};
+function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card2 }) {
+ const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? [];
+ if (grants.length === 0) return null;
+ const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking.");
+ return (0, import_react29.createElement)(
+ Card2,
+ { title: t("Always allowed") },
+ grants.map((grant) => (0, import_react29.createElement)(
+ "div",
+ { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind },
+ (0, import_react29.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react29.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
+ (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint)
+ ),
+ (0, import_react29.createElement)("button", {
+ type: "button",
+ className: "bt-soft bt-soft-sm",
+ "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }),
+ onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind))
+ }, t("Remove"))
+ ))
+ );
+}
+
+// src/client/bot-settings.js
+var import_react30 = require("react");
+var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
+async function avatarImage(file) {
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
+ if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
+ const bitmap = await createImageBitmap(file);
+ const side = 160;
+ const canvas = document.createElement("canvas");
+ canvas.width = side;
+ canvas.height = side;
+ const context = canvas.getContext("2d");
+ context.imageSmoothingQuality = "high";
+ const crop = Math.min(bitmap.width, bitmap.height);
+ context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
+ bitmap.close?.();
+ for (const quality of [0.9, 0.75, 0.6]) {
+ const url = canvas.toDataURL("image/webp", quality);
+ if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
}
- return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim();
+ const png = canvas.toDataURL("image/png");
+ if (png.length < 15e4) return png;
+ throw new Error(t("That image is too large"));
}
-function ExchangeLine({ entry, roster, actions }) {
- const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" };
- return (0, import_react34.createElement)(
+function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) {
+ const [draft, setDraft] = (0, import_react30.useState)(null);
+ const reverting = (0, import_react30.useRef)(false);
+ const commit = () => {
+ const next = draft?.trim();
+ setDraft(null);
+ if (reverting.current) {
+ reverting.current = false;
+ return;
+ }
+ if (next === void 0 || next === (value ?? "") || required && next === "") return;
+ void onSave(next);
+ };
+ return (0, import_react30.createElement)(multiline ? "textarea" : "input", {
+ className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
+ value: draft ?? value ?? "",
+ placeholder,
+ maxLength,
+ required,
+ "aria-label": label,
+ spellCheck: false,
+ onChange: (event) => setDraft(event.target.value),
+ onBlur: commit,
+ onKeyDown: (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ reverting.current = true;
+ event.currentTarget.blur();
+ }
+ if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur();
+ }
+ });
+}
+function Row({ label, hint, stack = false, children }) {
+ return (0, import_react30.createElement)(
+ "label",
+ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ children
+ );
+}
+function Card({ title, children }) {
+ return (0, import_react30.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react30.createElement)("h3", null, title) : null, (0, import_react30.createElement)("div", { className: "bt-set-card" }, children));
+}
+function Switch({ on, label, disabled, onChange }) {
+ return (0, import_react30.createElement)(
+ "button",
+ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
+ (0, import_react30.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
+ );
+}
+function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) {
+ return (0, import_react30.createElement)(
"div",
- { className: "bt-group-msg" },
- (0, import_react34.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
- (0, import_react34.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react34.createElement)("span", { className: "bt-lead" }, (0, import_react34.createElement)(BotMark, { bot, size: 22 })) })
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ (0, import_react30.createElement)(Switch, { on, label, disabled, onChange })
);
}
-function ExchangeDialog({ pair, roster, actions }) {
- const [entries2, setEntries] = (0, import_react34.useState)(null);
- const [leaving, setLeaving] = (0, import_react34.useState)(false);
- const close = () => {
- if (leaving) return;
- setLeaving(true);
- setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS);
+function modelHint(bot, roster) {
+ const shown = modelOf(bot, roster);
+ if (shown === void 0) return t("Pick the model this Bot runs on.");
+ if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one.");
+ if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it.");
+ return t("Applies from the next message.");
+}
+function ShapeRing({ shape, size }) {
+ const maskId = (0, import_react30.useRef)("");
+ if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`;
+ const def = shapeOf(shape);
+ const unit = def.size / size;
+ const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`;
+ return (0, import_react30.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
+}
+function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
+ const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))];
+ if (!shapes.includes(look.shape)) shapes.push(look.shape);
+ const rows = [];
+ for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS));
+ return (0, import_react30.createElement)(
+ import_react30.Fragment,
+ null,
+ (0, import_react30.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react30.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react30.createElement)("button", {
+ key: id,
+ type: "button",
+ className: "bt-shape",
+ "aria-label": t(shapeOf(id).label),
+ title: t(shapeOf(id).label),
+ "aria-pressed": !hasPhoto && look.shape === id,
+ onClick: () => onShape(id)
+ }, (0, import_react30.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react30.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
+ (0, import_react30.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react30.createElement)("button", {
+ key: color,
+ type: "button",
+ className: "bt-color",
+ "aria-label": t(capitalize(color)),
+ title: t(capitalize(color)),
+ "aria-pressed": look.color === color,
+ onClick: () => onColor(color)
+ }, (0, import_react30.createElement)("span", { style: { background: paintCss(color) } }))))
+ );
+}
+function AvatarEditor({ bot, actions, run, onClose }) {
+ const look = lookOf(bot);
+ const [tab, setTab] = (0, import_react30.useState)(look.image ? "upload" : "bot");
+ const [over, setOver] = (0, import_react30.useState)(false);
+ const ref = (0, import_react30.useRef)(null);
+ const fileRef = (0, import_react30.useRef)(null);
+ const upload = (file) => {
+ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
};
- useEscape(close);
- (0, import_react34.useEffect)(() => {
- let stop = false;
- let timer;
- let seen = null;
- const load = async () => {
- const result = await actions.exchange(pair[0], pair[1]);
- if (stop) return;
- const key = JSON.stringify(result ?? []);
- if (key !== seen) {
- seen = key;
- setEntries(result ?? []);
+ (0, import_react30.useEffect)(() => {
+ const onDown = (event) => {
+ if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
+ onClose();
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ onClose();
}
- timer = setTimeout(load, document.hidden ? 8e3 : 2e3);
};
- void load();
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
return () => {
- stop = true;
- clearTimeout(timer);
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
};
- }, [pair[0], pair[1]]);
- const left = roster.byId[pair[0]];
- const right = roster.byId[pair[1]];
- let lastDay = 0;
- const paneLeft = usePaneLeft();
- return (0, import_react34.createElement)(
+ }, [onClose]);
+ (0, import_react30.useEffect)(() => {
+ if (tab !== "upload") return void 0;
+ const onPaste = (event) => {
+ const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/"));
+ if (file) {
+ event.preventDefault();
+ upload(file);
+ }
+ };
+ window.addEventListener("paste", onPaste);
+ return () => window.removeEventListener("paste", onPaste);
+ }, [tab, bot]);
+ const hasPhoto = Boolean(look.image);
+ const glide = useGlide(tab);
+ return (0, import_react30.createElement)(
"div",
- { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 },
- (0, import_react34.createElement)(
+ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
+ (0, import_react30.createElement)(
"div",
- { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") },
- (0, import_react34.createElement)("div", { className: "bt-head" }, (0, import_react34.createElement)(
- "span",
- { className: "bt-pill", style: { cursor: "default" } },
- left ? (0, import_react34.createElement)(BotMark, { bot: left, size: 22 }) : null,
- left?.name ?? "?",
- (0, import_react34.createElement)("span", { className: "bt-swap" }, "⇄"),
- right ? (0, import_react34.createElement)(BotMark, { bot: right, size: 22 }) : null,
- right?.name ?? "?"
- )),
- (0, import_react34.createElement)(
+ { className: "bt-avedit-top" },
+ (0, import_react30.createElement)(
"div",
- { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") },
- entries2 === null ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
- const showDay = entry.time - lastDay > 30 * 60 * 1e3;
- lastDay = entry.time;
- return (0, import_react34.createElement)(
- import_react34.Fragment,
- { key: entry.id },
- showDay ? (0, import_react34.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
- (0, import_react34.createElement)(ExchangeLine, { entry, roster, actions })
- );
- })
+ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
+ (0, import_react30.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react30.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
),
- (0, import_react34.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react34.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
- )
+ (0, import_react30.createElement)("button", {
+ type: "button",
+ className: "bt-avedit-tab bt-avedit-reset",
+ title: t("Reset character to default"),
+ onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
+ }, t("Reset"))
+ ),
+ tab === "bot" ? (0, import_react30.createElement)(CharacterGrid, {
+ look,
+ hasPhoto,
+ onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })),
+ onColor: (color) => run(actions.updateBot(bot.id, { color }))
+ }) : null,
+ tab === "upload" ? (0, import_react30.createElement)(
+ "div",
+ {
+ className: "bt-dropzone",
+ "data-over": over || void 0,
+ onDragOver: (event) => {
+ event.preventDefault();
+ setOver(true);
+ },
+ onDragLeave: () => setOver(false),
+ onDrop: (event) => {
+ event.preventDefault();
+ setOver(false);
+ upload(event.dataTransfer.files?.[0]);
+ }
+ },
+ hasPhoto ? (0, import_react30.createElement)("img", { src: look.image, alt: "" }) : null,
+ (0, import_react30.createElement)("div", null, t("Drag, drop, or paste an image")),
+ (0, import_react30.createElement)(
+ "div",
+ { className: "bt-actions", style: { justifyContent: "center" } },
+ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
+ hasPhoto ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
+ ),
+ (0, import_react30.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ upload(file);
+ } })
+ ) : null
);
}
-
-// src/client/voice.js
-var import_react35 = require("react");
-var VOICE_LABELS = {
- listening: "Listening",
- thinking: "Thinking",
- speaking: "Speaking",
- paused: "Paused",
- setup: "Voice input needs its speech model",
- unsupported: "Voice chat is not available in this browser"
-};
-var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" };
-function useMicLevel(enabled) {
- const [level, setLevel] = (0, import_react35.useState)(0);
- (0, import_react35.useEffect)(() => {
- if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0;
- let stream = null;
- let audio = null;
- let frame = 0;
- let stopped = false;
- void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => {
- if (stopped) {
- media.getTracks().forEach((track) => track.stop());
- return;
- }
- stream = media;
- audio = new AudioContext();
- const analyser = audio.createAnalyser();
- analyser.fftSize = 256;
- audio.createMediaStreamSource(media).connect(analyser);
- const data = new Uint8Array(analyser.frequencyBinCount);
- let smooth = 0;
- const tick = () => {
- analyser.getByteTimeDomainData(data);
- let sum = 0;
- for (const value of data) sum += ((value - 128) / 128) ** 2;
- smooth = smooth * 0.8 + Math.min(1, Math.sqrt(sum / data.length) * 4) * 0.2;
- setLevel(Math.round(smooth * 100) / 100);
- frame = requestAnimationFrame(tick);
- };
- tick();
- }).catch(() => {
- });
- return () => {
- stopped = true;
- cancelAnimationFrame(frame);
- stream?.getTracks().forEach((track) => track.stop());
- void audio?.close().catch(() => {
- });
- setLevel(0);
- };
- }, [enabled]);
- return level;
+function MemoryCard({ bot, actions }) {
+ const count = useMemoryCount(bot.id, actions);
+ const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
+ return (0, import_react30.createElement)(
+ Card,
+ { title: t("Memory") },
+ (0, import_react30.createElement)(
+ "button",
+ { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label)),
+ (0, import_react30.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react30.createElement)(ChevronRightIcon))
+ )
+ );
}
-function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }) {
- const bot = roster.byId[sessionId];
- const room2 = roster.roomsById[sessionId];
- const name = bot?.name ?? room2?.name ?? t("Bot");
- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
- const lastReply = useSessions((list) => {
- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
- const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0;
- return entry ? `${outline.length}|${entry.response ?? ""}` : "0|";
- });
- const [speech] = (0, import_react35.useState)(() => actions.speech());
- const [phase, setPhase] = (0, import_react35.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
- const [catalog, setCatalog] = (0, import_react35.useState)(null);
- const [heard, setHeard] = (0, import_react35.useState)("");
- const [said, setSaid] = (0, import_react35.useState)("");
- const [note, setNote] = (0, import_react35.useState)("");
- const [serviceLevel, setServiceLevel] = (0, import_react35.useState)(0);
- const [cycle, setCycle] = (0, import_react35.useState)(0);
- const waiting = (0, import_react35.useRef)(null);
- const reply = (0, import_react35.useRef)(lastReply);
- reply.current = lastReply;
- const turn = (0, import_react35.useRef)(0);
- const paneLeft = usePaneLeft();
- const close = () => {
- turn.current += 1;
- window.speechSynthesis?.cancel();
- actions.closeVoice();
- };
- useEscape(close);
- const submit = (text2) => {
- setHeard(text2);
- setPhase("thinking");
- waiting.current = reply.current.split("|")[0];
- void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening"));
- };
- const dictation = useDictation((text2, final) => {
- setHeard(text2);
- if (!final) return;
- dictation.stop();
- submit(text2);
- });
- (0, import_react35.useEffect)(() => {
- if (!note) return void 0;
- const timer = setTimeout(() => setNote(""), 2600);
- return () => clearTimeout(timer);
- }, [note]);
- (0, import_react35.useEffect)(() => {
- if (!speech) return void 0;
- let live = true;
- speechCatalog(speech).then(({ catalog: next, usable }) => {
- if (!live) return;
- setCatalog(next);
- setPhase(usable ? "listening" : "setup");
- }, () => {
- if (live) setPhase("setup");
- });
- return () => {
- live = false;
- };
- }, [speech]);
- const listen = phase === "listening";
- (0, import_react35.useEffect)(() => {
- if (speech || !listen || dictation.listening) return void 0;
- const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => {
- } }), 250);
+function BotSettingsForm({ bot, roster, actions, run }) {
+ const [editing, setEditing] = (0, import_react30.useState)(false);
+ const [confirming, setConfirming] = (0, import_react30.useState)(false);
+ const closeEditor = (0, import_react30.useCallback)(() => setEditing(false), []);
+ (0, import_react30.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(false), 4e3);
return () => clearTimeout(timer);
- }, [listen, dictation.listening]);
- (0, import_react35.useEffect)(() => {
- if (phase !== "listening") dictation.stop();
- }, [phase]);
- (0, import_react35.useEffect)(() => {
- if (!speech || !catalog || !listen) return void 0;
- const own = turn.current;
- let capture = null;
- let frame = 0;
- let stopped = false;
- let smooth = 0;
- const detect = turnDetector();
- const limit = Math.max(1, catalog.maxDurationSeconds - 1) * 1e3;
- const finish = async (current) => {
- setPhase("transcribing");
- try {
- const wave = await current.finish(catalog.maxDurationSeconds);
- const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : "";
- if (turn.current !== own) return;
- if (text2) submit(text2);
- else setPhase("listening");
- } catch (error) {
- if (turn.current !== own) return;
- setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
- setPhase("listening");
- }
- };
- openCapture().then((opened) => {
- if (stopped) {
- opened.cancel();
- return;
- }
- capture = opened;
- const began = performance.now();
- const tick = () => {
- const now = performance.now();
- const level2 = capture.level();
- smooth = smooth * 0.8 + level2 * 0.2;
- setServiceLevel(Math.round(smooth * 100) / 100);
- const state = detect(level2, now);
- if (state === "done" || state === "speech" && now - began >= limit) {
- const current = capture;
- capture = null;
- setServiceLevel(0);
- void finish(current);
- return;
- }
- if (state === "waiting" && now - began >= limit) {
- setCycle((value) => value + 1);
- return;
- }
- frame = requestAnimationFrame(tick);
- };
- tick();
- }, (error) => {
- if (stopped) return;
- setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
- setPhase("paused");
- });
- return () => {
- stopped = true;
- cancelAnimationFrame(frame);
- capture?.cancel();
- setServiceLevel(0);
- };
- }, [speech, catalog, listen, cycle]);
- (0, import_react35.useEffect)(() => {
- if (phase !== "thinking" || running || waiting.current === null) return;
- const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
- if (count === waiting.current || !text2.trim()) return;
- waiting.current = null;
- const spoken = speechOf(text2, botNames(roster));
- setSaid(spoken);
- setHeard("");
- if (!window.speechSynthesis || !spoken) {
- setPhase("listening");
- return;
- }
- const utterance = new SpeechSynthesisUtterance(spoken);
- utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
- utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
- utterance.onerror = utterance.onend;
- setPhase("speaking");
- window.speechSynthesis.cancel();
- window.speechSynthesis.speak(utterance);
- }, [phase, running, lastReply]);
- (0, import_react35.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
- const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
- const level = speech ? serviceLevel : micLevel;
- const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
- const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
- const togglePause = () => {
- if (phase === "paused") {
- setPhase("listening");
- return;
- }
- turn.current += 1;
- window.speechSynthesis?.cancel();
- setPhase("paused");
+ }, [confirming]);
+ if (bot.remote) return null;
+ const isMainBot = isMainOf(roster, bot.id);
+ const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1;
+ const isTile = roster.mainBotId === bot.id;
+ const save = (patch) => run(actions.updateBot(bot.id, patch));
+ const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id)));
+ const remove = () => {
+ if (confirming) void run(actions.deleteBot(bot.id));
+ else setConfirming(true);
};
- const openSetup = phase === "setup" ? actions.voiceSetup() : null;
- const caption = phase === "speaking" ? said : heard;
- const shownNote = note || dictation.note;
- return (0, import_react35.createElement)(
+ (0, import_react30.useEffect)(() => {
+ void actions.refreshModels?.();
+ }, [bot.id]);
+ return (0, import_react30.createElement)(
"div",
- { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
- (0, import_react35.createElement)(
+ { className: "bt-bs" },
+ (0, import_react30.createElement)(
"div",
- { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
- (0, import_react35.createElement)(
+ { className: "bt-bs-hero" },
+ (0, import_react30.createElement)(
"div",
- { className: "bt-voice-orb", style: { "--bt-level": level } },
- (0, import_react35.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
- (0, import_react35.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
- bot ? (0, import_react35.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
+ { className: "bt-avatar-anchor" },
+ (0, import_react30.createElement)(
+ "button",
+ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) },
+ (0, import_react30.createElement)(BotMark, { bot, size: 64, live: true }),
+ (0, import_react30.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react30.createElement)(PencilIcon))
+ ),
+ editing ? (0, import_react30.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
),
- (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name),
- (0, import_react35.createElement)(
+ (0, import_react30.createElement)(
"div",
- { className: "bt-voice-status", role: "status" },
- phase === "thinking" ? (0, import_react35.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react35.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
+ { className: "bt-bs-who" },
+ (0, import_react30.createElement)("span", { className: "bt-bs-name" }, bot.name),
+ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
+ )
+ ),
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("Profile") },
+ (0, import_react30.createElement)(Row, { label: t("Name") }, (0, import_react30.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
+ (0, import_react30.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react30.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
+ ),
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("Model") },
+ (0, import_react30.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
+ (0, import_react30.createElement)(ModelMenu, { bot, roster, actions, run })
),
- (0, import_react35.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
- openSetup ? (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
- close();
- openSetup();
- } }, t("Set up voice input")) : null,
- shownNote ? (0, import_react35.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
+ bot.model ? (0, import_react30.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
),
- (0, import_react35.createElement)(
- "div",
- { className: "bt-voice-bar" },
- (0, import_react35.createElement)("button", {
- type: "button",
- className: "bt-voice-btn",
- "aria-pressed": phase === "paused",
- disabled: blocked,
- "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
- onClick: togglePause
- }, (0, import_react35.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react35.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
- (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react35.createElement)(CloseIcon))
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("Working folder") },
+ (0, import_react30.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+ (0, import_react30.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
+ )
+ ),
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("Instructions") },
+ (0, import_react30.createElement)(
+ Row,
+ { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true },
+ (0, import_react30.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
+ )
+ ),
+ // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406).
+ (0, import_react30.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react30.createElement)(Card, { title: t("Sharing") }, children) }),
+ (0, import_react30.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
+ (0, import_react30.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
+ roster.memory === false ? null : (0, import_react30.createElement)(MemoryCard, { bot, actions }),
+ (0, import_react30.createElement)(BotSchedules, { bot, roster, actions }),
+ (0, import_react30.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("Main Bot") },
+ onlyMain ? (0, import_react30.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react30.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
+ ),
+ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
+ ) : (0, import_react30.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
+ ),
+ // The Main Bot tile is always first and always shown, like in the context menu.
+ isTile ? null : (0, import_react30.createElement)(
+ Card,
+ { title: t("Sidebar") },
+ (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
+ isMainBot ? null : (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
+ ),
+ (0, import_react30.createElement)(
+ Card,
+ { title: t("More") },
+ (0, import_react30.createElement)(
+ "button",
+ { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate },
+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
+ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
+ ),
+ isMainBot ? null : (0, import_react30.createElement)(
+ "button",
+ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
+ (0, import_react30.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
+ ),
+ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
+ )
)
);
}
+var BOT_SETTINGS_CSS = `
+.bt-bs{display:flex;flex-direction:column;gap:18px}
+.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px}
+.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px}
+.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+.bt-bs .bt-avedit{left:0;transform:none}
+.bt-bs-row{min-height:48px}
+label.bt-bs-row{cursor:text}
+.bt-bs-label{font-size:13px;line-height:18px;font-weight:500}
+/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to
+ break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test
+ connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under
+ a label is prose, so it wraps and is never cut: the row grows instead. */
+.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch}
+.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap}
+.bt-bs-row .bt-model-trigger{flex:none;max-width:58%}
+.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
+${MODEL_TEST_CSS}
+.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease}
+.bt-bs-input:hover{background:var(--bt-hover)}
+.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left}
+.bt-bs-input::placeholder{color:var(--bt-ink-3)}
+.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px}
+.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)}
+.bt-bs-area:hover{background:var(--bt-active)}
+.bt-bs-danger .bt-bs-label{color:#c21d2e}
+.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)}
+.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)}
+.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease}
+.bt-switch[aria-checked=true]{background:var(--bt-accent)}
+.bt-switch:disabled{opacity:.45;cursor:default}
+.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
+.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
+.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
+.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
+.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
+.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
+@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
+/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
+ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
+ keeps its own words. Nothing is clipped to fit a width that is not there. */
+@container bt-panel (max-width:300px){
+ .bt-bs-row{flex-wrap:wrap;row-gap:6px}
+ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
+ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
+}
+`;
-// src/client/workflows.js
-var import_react36 = require("react");
-var READ_MS = 8e3;
-var POLL_MS6 = 2e3;
-var WORKFLOW_PLUGIN_HINT = "The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.";
-var COLORS = { Planner: "#8b5cf6", Writer: "#f0509a", Coder: "#1fbf6a", Reviewer: "#f5a524", Tester: "#14b8a6", Deployer: "#2f7cf6" };
-function workflowCallMissing(failure) {
- const code = failure && typeof failure === "object" ? String(failure.code ?? "") : "";
- const message = typeof failure === "string" ? failure : String(failure?.message ?? "");
- return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message);
+// src/client/group-settings.js
+var import_react31 = require("react");
+var ROOM_MODES = [
+ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
+ ["everyone", "Everyone", "Every member answers in turn, admin first."]
+];
+var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
+function faceSeatBot(seat, roster) {
+ if (!seat) return void 0;
+ const record = seat.record && typeof seat.record === "object" ? seat.record : {};
+ const extra = {};
+ if (typeof record.color === "string" && record.color) extra.color = record.color;
+ if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar;
+ if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local;
+ const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : "";
+ return lookBotForRemote({
+ handle,
+ displayName: record.displayName || seat.bot,
+ name: seat.bot,
+ label: record.label,
+ id: record.id || seat.key,
+ ...extra
+ }, roster);
}
-function workflowCallError(failure, fallback = "Could not read workflows") {
- if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT;
- if (typeof failure === "string" && failure.trim()) return failure.trim();
- const message = typeof failure?.message === "string" ? failure.message.trim() : "";
- return message || fallback;
+function faceMemberLook(bot) {
+ if (!bot) return void 0;
+ return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot);
}
-function settleWorkflowRead(value, fallback = "Could not read workflows") {
- if (!value || value.ok === false || !Array.isArray(value.workflows)) {
- const raw = typeof value?.error === "string" && value.error.trim() ? value.error : "";
- const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : raw || fallback;
- return { phase: "error", error, pack: null };
+function faceMarks(person, roster, size) {
+ const marks = [];
+ for (const seat of person?.seats ?? []) {
+ const bot = faceSeatBot(seat, roster);
+ if (!bot) continue;
+ marks.push((0, import_react31.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
}
- const templates = Array.isArray(value.templates) ? value.templates : [];
- const workflows = value.workflows;
- const pack = { ...value, templates, workflows };
- if (templates.length === 0 && workflows.length === 0) return { phase: "empty", error: "", pack };
- return { phase: "ready", error: "", pack };
+ if (marks.length === 0) return [(0, import_react31.createElement)(PersonMark, { key: "person", size, name: person?.name })];
+ return marks;
}
-function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
- return new Promise((resolve) => {
- let settled = false;
- const finish = (value) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- resolve(value);
- };
- const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
- Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
- });
-}
-function moveNode(nodes, id, x, y) {
- return nodes.map((node) => node.id === id ? { ...node, x, y } : node);
-}
-function connectNodes(edges, from, to) {
- if (!from || !to || from === to) return edges;
- if (edges.some((edge) => edge.from === from && edge.to === to)) return edges;
- return edges.concat([{ from, to }]);
+function faceRoomCount(room2, roster) {
+ const people2 = facePeople(room2, roster).length;
+ const seats = faceSeats(room2, roster).length;
+ const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
+ const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
+ return `${persons} · ${bots}`;
}
-function dropBot(nodes, bot, x, y) {
- const ids = new Set(nodes.map((node) => node.id));
- let n = nodes.length + 1;
- while (ids.has(`n${n}`)) n += 1;
- return nodes.concat([{ id: `n${n}`, name: bot.name, botId: bot.id, role: bot.role ?? "", x, y }]);
+var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
+var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
+function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
+ const bots = person.seats.flatMap((seat, index) => {
+ const name = seat.local && onOpenBot ? (0, import_react31.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react31.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
+ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react31.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
+ return index === 0 ? marked : [(0, import_react31.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
+ });
+ return (0, import_react31.createElement)(
+ "div",
+ { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-gs-who bt-face-person" },
+ (0, import_react31.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-face-person-text" },
+ (0, import_react31.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
+ (0, import_react31.createElement)("span", { className: "bt-face-bots" }, bots)
+ )
+ )
+ );
}
-function removeNode(nodes, edges, id) {
- return {
- nodes: nodes.filter((node) => node.id !== id),
- edges: edges.filter((edge) => edge.from !== id && edge.to !== id)
+function FaceLead({ room: room2, roster, pick: pick2 }) {
+ const lead = room2.face?.lead;
+ const [open, setOpen] = (0, import_react31.useState)(false);
+ const [busy, setBusy] = (0, import_react31.useState)(false);
+ const [error, setError] = (0, import_react31.useState)("");
+ const seats = faceSeats(room2, roster);
+ const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
+ if (!current) return null;
+ const canPick = lead.canPick === true && typeof pick2 === "function";
+ const choose = async (handle) => {
+ setBusy(true);
+ setError("");
+ try {
+ await pick2(handle);
+ setOpen(false);
+ } catch (err) {
+ setError(err?.message || t("Could not change the room lead."));
+ } finally {
+ setBusy(false);
+ }
};
+ return (0, import_react31.createElement)(
+ "div",
+ { className: "bt-face-lead", "data-flybot": "face-lead" },
+ (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
+ (0, import_react31.createElement)(
+ "div",
+ { className: "bt-set-row" },
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react31.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
+ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
+ ),
+ canPick ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
+ ),
+ open && canPick ? seats.map((seat) => (0, import_react31.createElement)(
+ "div",
+ { key: seat.key, className: "bt-set-row" },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
+ seat === current ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
+ )) : null,
+ open && canPick && lead.picked ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
+ canPick || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
+ error ? (0, import_react31.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
+ );
}
-function chainOf(item) {
- if (typeof item?.chain === "string" && item.chain) return item.chain;
- if (!Array.isArray(item?.layers) || !Array.isArray(item?.nodes)) return "";
- const name = (id) => item.nodes.find((node) => node.id === id)?.name ?? id;
- return item.layers.map((layer) => layer.map(name).join(" ‖ ")).join(" → ");
-}
-function openedGraph(pack, opened) {
- if (!pack || !opened) return null;
- if (opened.source === "own") return (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
- return (pack.templates ?? []).find((item) => item.id === opened.id) ?? (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
-}
-function statusLabel2(status) {
- if (status === "running") return t("Running");
- if (status === "done") return t("Done");
- if (status === "failed") return t("Failed");
- if (status === "idle") return t("Idle");
- if (status === "skipped") return t("Skipped");
- return t("Queued");
-}
-function hitNode(event) {
- const direct = event.target?.dataset?.node ? event.target : null;
- const nested = event.target?.closest?.("[data-node]");
- return (nested ?? direct)?.dataset?.node || "";
-}
-var WIRE_SPAN = 480;
-var NODE_WIRE_OUT = 56;
-var CARD_HALF = 84;
-var CARD_H = 72;
-var FRAME_SIDE = 108;
-var FRAME_LABEL = 18;
-var FRAME_FOOT = 10;
-var MIN_FIT = 0.5;
-function wirePath(from, to) {
- const x1 = from.x + WIRE_SPAN;
- const y1 = from.y + NODE_WIRE_OUT;
- const x2 = to.x + WIRE_SPAN;
- const y2 = to.y;
- const mid = (y1 + y2) / 2;
- return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`;
+function seatedCount(room2) {
+ const live = room2?.presentedParticipants ?? room2?.participants ?? [];
+ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length;
+ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length;
+ return humans + remotes + (room2?.members?.length ?? 0);
}
-function fitScale(nodes, width) {
- const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
- if (!(width > 0) || reach === 0) return 1;
- return Math.min(1, Math.max(MIN_FIT, width / 2 / reach));
+function roomSubtitle(room2, roster) {
+ if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
+ const admin = roster.byId[room2.admin];
+ const count = seatedCount(room2);
+ return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
}
-function fitLayout(nodes, width) {
- const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
- const scale = fitScale(nodes, width);
- const spare = width > 0 && reach > 0 ? reach * scale - width / 2 : 0;
- const pad2 = spare > 1e-6 ? Math.ceil(spare) : 0;
- const centre = width > 0 ? pad2 + width / 2 : 0;
- return {
- reach,
- scale,
- pad: pad2,
- left: centre - reach * scale,
- right: centre + reach * scale,
- scrollWidth: Math.max(width + pad2, centre + reach * scale)
+function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
+ const [adding, setAdding] = (0, import_react31.useState)(false);
+ const [confirming, setConfirming] = (0, import_react31.useState)(false);
+ (0, import_react31.useEffect)(() => {
+ if (!confirming) return void 0;
+ const timer = setTimeout(() => setConfirming(false), 4e3);
+ return () => clearTimeout(timer);
+ }, [confirming]);
+ const live = room2.presentedParticipants ?? room2.participants ?? [];
+ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
+ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
+ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+ const face = room2.origin === "face-to-face";
+ const people2 = face ? facePeople(room2, roster) : [];
+ const shared2 = humans.length > 1;
+ const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
+ const outside = roster.bots.filter((bot) => {
+ if (bot.remote) return false;
+ if (room2.members.includes(bot.id)) return false;
+ if (shared2) return chiefs.has(bot.id);
+ return true;
+ });
+ const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
+ const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
+ const admin = roster.byId[room2.admin];
+ const mode = shownMode(room2, roster);
+ const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
+ const update = (patch) => run(actions.updateRoom(room2.id, patch));
+ const openBot = (id) => {
+ actions.closeSettings();
+ actions.openSession(id);
};
-}
-function measureCanvasWidth(el) {
- const width = el?.clientWidth;
- return Number.isFinite(width) && width > 0 ? width : 0;
-}
-function GraphCanvas({ graph, editing, roster, onCommit, width: knownWidth = 0 }) {
- const gesture = (0, import_react36.useRef)(null);
- const canvasRef = (0, import_react36.useRef)(null);
- const [measuredWidth, setCanvasWidth] = (0, import_react36.useState)(0);
- const nodes = graph.nodes ?? [];
- const edges = graph.edges ?? [];
- const status = graph.run?.nodeStatus ?? null;
- const locals = (roster?.bots ?? []).filter((bot) => bot && bot.remote !== true && !nodes.some((node) => node.botId === bot.id));
- const height = Math.max(420, ...nodes.map((node) => (node.y ?? 0) + 150), 0);
- const canvasWidth = measuredWidth || knownWidth;
- const { scale, pad: pad2 } = fitLayout(nodes, canvasWidth);
- const drawnHeight = Math.round(height * scale);
- (0, import_react36.useEffect)(() => {
- const el = canvasRef.current;
- const measure = () => {
- const width = measureCanvasWidth(el);
- if (width > 0 && Math.abs(width - canvasWidth) > 0.5) setCanvasWidth(width);
- };
- measure();
- if (typeof ResizeObserver !== "function" || !el) return void 0;
- const observer = new ResizeObserver(measure);
- observer.observe(el);
- return () => observer.disconnect();
- }, [canvasWidth]);
- const end = (event) => {
- const current = gesture.current;
- gesture.current = null;
- if (!current || !editing) return;
- if (current.kind === "move") {
- const x = Math.round(current.x + (event.clientX - current.ox) / scale);
- const y = Math.round(current.y + (event.clientY - current.oy) / scale);
- onCommit?.({ nodes: moveNode(nodes, current.id, x, y), edges });
- } else if (current.kind === "wire") {
- const next = connectNodes(edges, current.from, hitNode(event));
- if (next !== edges) onCommit?.({ nodes, edges: next });
- } else if (current.kind === "add" && event.target?.dataset?.plane) {
- const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX / scale) : 24;
- const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY / scale) : 24;
- onCommit?.({ nodes: dropBot(nodes, current.bot, x, y), edges });
+ const remove = () => {
+ if (!confirming) {
+ setConfirming(true);
+ return;
}
+ void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
+ if (done) onDeleted?.();
+ });
};
- return (0, import_react36.createElement)(
+ return (0, import_react31.createElement)(
"div",
- { className: "bt-wf-canvas", ref: canvasRef, onPointerUp: end },
- editing ? (0, import_react36.createElement)(
+ { className: "bt-bs" },
+ (0, import_react31.createElement)(
"div",
- { className: "bt-wf-palette" },
- (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")),
- locals.map((bot) => (0, import_react36.createElement)("button", {
- key: bot.id,
- type: "button",
- "data-bot": bot.id,
- onPointerDown: (event) => {
- event.preventDefault?.();
- gesture.current = { kind: "add", bot };
- }
- }, bot.name))
- ) : null,
- // A node's system prompt is the run Bot's prompt (plugins/workflow
- // nodeConfig). The sidebar panel has this field, so the Settings editor
- // writes the same graph instead of only drawing it (#1393).
- editing ? (0, import_react36.createElement)("div", { className: "bt-wf-prompts" }, nodes.map((node) => (0, import_react36.createElement)(
- "label",
- { key: node.id, className: "bt-wf-prompt" },
- (0, import_react36.createElement)("span", null, `${node.name} · ${t("System prompt")}`),
- (0, import_react36.createElement)("textarea", {
- "data-prompt": node.id,
- rows: 3,
- defaultValue: node.prompt || "",
- onPointerDown: (event) => event.stopPropagation?.(),
- onBlur: (event) => {
- const prompt = event.target?.value ?? "";
- if (prompt === (node.prompt || "")) return;
- onCommit?.({ nodes: nodes.map((item) => item.id === node.id ? { ...item, prompt } : item), edges });
- }
- })
- ))) : null,
- (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-stage", style: pad2 > 0 ? { height: drawnHeight, paddingLeft: pad2 } : { height: drawnHeight } },
- (0, import_react36.createElement)(
+ { className: "bt-bs-hero" },
+ (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+ (0, import_react31.createElement)(
"div",
- { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } },
- (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } },
- (0, import_react36.createElement)(
- "svg",
- {
- className: "bt-wf-wires",
- "aria-hidden": true,
- width: WIRE_SPAN * 2,
- height,
- viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`
- },
- (0, import_react36.createElement)(
- "defs",
- null,
- (0, import_react36.createElement)("marker", {
- id: "bt-wf-arrow",
- viewBox: "0 0 10 10",
- refX: "8",
- refY: "5",
- markerWidth: "6",
- markerHeight: "6",
- orient: "auto"
- }, (0, import_react36.createElement)("path", { d: "M0 0 10 5 0 10z" }))
- ),
- edges.map((edge) => {
- const from = nodes.find((node) => node.id === edge.from);
- const to = nodes.find((node) => node.id === edge.to);
- if (!from || !to) return null;
- return (0, import_react36.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" });
- })
- ),
- (graph.layers ?? []).filter((layer) => layer.length > 1).map((layer) => {
- const members = layer.map((id) => nodes.find((node) => node.id === id)).filter(Boolean);
- if (members.length < 2) return null;
- const left = Math.min(...members.map((node) => node.x ?? 0)) - FRAME_SIDE;
- const right = Math.max(...members.map((node) => node.x ?? 0)) + FRAME_SIDE;
- const top = Math.min(...members.map((node) => node.y ?? 0)) - FRAME_LABEL;
- const bottom = Math.max(...members.map((node) => node.y ?? 0)) + CARD_H + FRAME_FOOT;
- return (0, import_react36.createElement)("div", {
- key: layer.join("+"),
- className: "bt-wf-par",
- style: { left, top, width: right - left, height: bottom - top }
- }, (0, import_react36.createElement)("div", { className: "bt-wf-plabel" }, t("Parallel branch, then join")));
- }),
- nodes.map((node) => {
- const state = status ? status[node.id] ?? "queued" : "idle";
- return (0, import_react36.createElement)(
- "div",
- {
- key: node.id,
- "data-node": node.id,
- className: `bt-wf-node ${state}${editing ? " editing" : ""}`,
- style: { left: (node.x ?? 0) - CARD_HALF, top: node.y ?? 0 },
- onPointerDown: (event) => {
- if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return;
- gesture.current = { kind: "move", id: node.id, ox: event.clientX, oy: event.clientY, x: node.x ?? 0, y: node.y ?? 0 };
- }
- },
- editing ? (0, import_react36.createElement)("button", {
- type: "button",
- className: "bt-wf-port",
- "data-port": "out",
- "aria-label": node.name,
- onPointerDown: (event) => {
- event.preventDefault?.();
- event.stopPropagation?.();
- gesture.current = { kind: "wire", from: node.id };
- }
- }) : null,
- editing ? (0, import_react36.createElement)("button", {
- type: "button",
- className: "bt-wf-remove",
- "data-remove": node.id,
- "aria-label": t("Remove"),
- onClick: (event) => {
- event.stopPropagation?.();
- const next = removeNode(nodes, edges, node.id);
- onCommit?.(next);
- }
- }, "×") : null,
- (0, import_react36.createElement)("span", { className: `bt-wf-chip ${state}` }, statusLabel2(state)),
- (0, import_react36.createElement)("span", { className: "bt-wf-av", style: { background: COLORS[node.name] ?? "#4d6bfe" } }, (node.name ?? "?").slice(0, 1)),
- (0, import_react36.createElement)(
- "span",
- { className: "bt-wf-copy" },
- (0, import_react36.createElement)("span", { className: "bt-wf-name" }, node.name),
- (0, import_react36.createElement)("span", { className: "bt-wf-role" }, node.role ?? "")
- )
- );
- })
- )
+ { className: "bt-bs-who" },
+ (0, import_react31.createElement)("span", { className: "bt-bs-name" }, room2.name),
+ (0, import_react31.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
)
- )
- );
-}
-function Row2({ item, source, onOpen, onRename, onDelete }) {
- return (0, import_react36.createElement)(
- "button",
- {
- type: "button",
- className: "bt-set-row",
- "data-workflow": item.id,
- "data-source": source,
- onClick: () => onOpen?.(item)
- },
- (0, import_react36.createElement)(
- "span",
- { className: "bt-set-copy" },
- (0, import_react36.createElement)("span", null, item.name),
- (0, import_react36.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "")
),
- source === "own" ? (0, import_react36.createElement)(
- "span",
- { className: "bt-set-actions" },
- (0, import_react36.createElement)("button", {
- type: "button",
- className: "bt-set-action",
- "aria-label": t("Rename"),
- title: t("Rename"),
- onClick: (event) => {
- event.stopPropagation();
- onRename?.(item);
- }
- }, "✎"),
- (0, import_react36.createElement)("button", {
- type: "button",
- className: "bt-set-action",
- "aria-label": t("Delete"),
- title: t("Delete"),
- onClick: (event) => {
- event.stopPropagation();
- onDelete?.(item);
- }
- }, "×")
- ) : null,
- (0, import_react36.createElement)("span", { className: "bt-chevron", "aria-hidden": true }, "›")
- );
-}
-function alertCopy(error) {
- if (error === WORKFLOW_PLUGIN_HINT) return t("The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.");
- return error || t("Could not read workflows");
-}
-function WorkflowsView({ phase = "loading", pack = null, error = "", opened = null, editing = false, roster = null, canvasWidth = 0, onOpen, onBack, onEditing, onRetry, onUse, onCommit, onRename, onDelete }) {
- const graph = phase === "ready" ? openedGraph(pack, opened) : null;
- const templates = pack?.templates ?? [];
- const templateIds = new Set(templates.map((item) => item.id));
- const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id));
- const showUse = opened?.source === "factory" || opened?.fromFactory !== void 0;
- return (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-page" },
- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.")),
- error ? (0, import_react36.createElement)(
- "div",
- { className: "bt-error", role: "alert" },
- alertCopy(error),
- phase === "error" ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onRetry?.() }, t("Retry")) : null
- ) : null,
- phase === "loading" ? (0, import_react36.createElement)("p", { className: "bt-wf-status", role: "status" }, t("Reading workflows…")) : null,
- phase === "empty" ? (0, import_react36.createElement)("p", { className: "bt-wf-status" }, t("No workflows yet.")) : null,
- phase === "ready" && !graph ? (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-lists" },
- (0, import_react36.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react36.createElement)("h3", null, t("Factory presets")),
- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("Ready-made pipelines. Use one to start your own.")),
- (0, import_react36.createElement)(
- "div",
- { className: "bt-set-card" },
- templates.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No workflows yet."))) : templates.map((item) => {
- const live = (pack.workflows ?? []).find((workflow) => workflow.id === item.id);
- const row = live ? { ...item, name: live.name, preview: live.preview || chainOf(live) || chainOf(item) } : item;
- return (0, import_react36.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") });
- })
- )
+ (0, import_react31.createElement)(
+ Card,
+ { title: t("Profile") },
+ (0, import_react31.createElement)(
+ Row,
+ { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
+ (0, import_react31.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
),
- (0, import_react36.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react36.createElement)("h3", null, t("Yours")),
- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")),
- (0, import_react36.createElement)(
- "div",
- { className: "bt-set-card" },
- yours.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react36.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete }))
- )
+ (0, import_react31.createElement)(
+ Row,
+ { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
+ (0, import_react31.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
)
- ) : null,
- graph ? (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-editor" },
- (0, import_react36.createElement)(
- "div",
- { className: "bt-wf-actions" },
- (0, import_react36.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")),
- showUse ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null,
- opened?.source === "own" ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null
- ),
- (0, import_react36.createElement)("h3", { className: "bt-wf-title" }, graph.name),
- graph.description ? (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null,
- (graph.nodes ?? []).length === 0 ? (0, import_react36.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react36.createElement)(GraphCanvas, {
- graph,
- roster,
- onCommit,
- width: canvasWidth,
- editing: opened?.source === "own" && editing === true
- })
- ) : null
- );
-}
-function RenameModal({ workflow, onClose, onConfirm }) {
- const [name, setName] = (0, import_react36.useState)(workflow?.name ?? "");
- return (0, import_react36.createElement)(
- "div",
- { className: "bt-modal-mask", onClick: (event) => {
- if (event.target === event.currentTarget) onClose?.();
- } },
- (0, import_react36.createElement)(
- "div",
- { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") },
- (0, import_react36.createElement)("h3", null, t("Rename workflow")),
- (0, import_react36.createElement)(
- "label",
- { className: "bt-field" },
- t("Name"),
- (0, import_react36.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true })
- ),
- (0, import_react36.createElement)(
+ ),
+ (0, import_react31.createElement)(
+ Card,
+ { title: t("Who answers") },
+ (0, import_react31.createElement)(
"div",
- { className: "bt-modal-actions" },
- (0, import_react36.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
- (0, import_react36.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename"))
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react31.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react31.createElement)("button", {
+ key: id,
+ type: "button",
+ "aria-pressed": mode === id,
+ disabled: id === "admin" && !admin,
+ title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
+ onClick: () => {
+ if (mode !== id) void update({ mode: id });
+ }
+ }, t(label)))),
+ (0, import_react31.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
)
- )
- );
-}
-function DeleteModal({ workflow, onClose, onConfirm }) {
- return (0, import_react36.createElement)(
- "div",
- { className: "bt-modal-mask", onClick: (event) => {
- if (event.target === event.currentTarget) onClose?.();
- } },
- (0, import_react36.createElement)(
- "div",
- { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") },
- (0, import_react36.createElement)("h3", null, t("Delete workflow")),
- (0, import_react36.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })),
- (0, import_react36.createElement)(
+ ),
+ (0, import_react31.createElement)(
+ Card,
+ { title: t("Working folder") },
+ (0, import_react31.createElement)(
"div",
- { className: "bt-modal-actions" },
- (0, import_react36.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
- (0, import_react36.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete"))
+ { className: "bt-set-row bt-bs-row" },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+ (0, import_react31.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
)
- )
- );
-}
-function WorkflowsPage({ call: call4, roster }) {
- const [phase, setPhase] = (0, import_react36.useState)("loading");
- const [pack, setPack] = (0, import_react36.useState)(null);
- const [error, setError] = (0, import_react36.useState)("");
- const [opened, setOpened] = (0, import_react36.useState)(null);
- const [editing, setEditing] = (0, import_react36.useState)(false);
- const [renaming, setRenaming] = (0, import_react36.useState)(null);
- const [deleting, setDeleting] = (0, import_react36.useState)(null);
- const invoke = typeof call4 === "function" ? call4 : () => Promise.reject(new Error("Could not read workflows"));
- const apply2 = (settled) => {
- setPhase(settled.phase);
- setPack(settled.pack);
- setError(settled.error);
- };
- (0, import_react36.useEffect)(() => {
- let gone = false;
- readWorkflows(invoke).then((settled) => {
- if (!gone) apply2(settled);
- });
- return () => {
- gone = true;
- };
- }, []);
- const live = (pack?.workflows ?? []).some((item) => (item.activeCount ?? 0) > 0);
- (0, import_react36.useEffect)(() => {
- if (!live || editing) return void 0;
- let gone = false;
- const timer = setInterval(() => {
- readWorkflows(invoke).then((settled) => {
- if (!gone) apply2(settled);
- });
- }, POLL_MS6);
- return () => {
- gone = true;
- clearInterval(timer);
- };
- }, [live, editing]);
- const retry = () => {
- setPhase("loading");
- setError("");
- setOpened(null);
- readWorkflows(invoke).then(apply2);
- };
- const open = (item, source) => {
- if (source === "factory") {
- const live2 = (pack?.workflows ?? []).find((workflow) => workflow.id === item.id);
- setOpened(live2 ? { source: "own", id: live2.id, fromFactory: item.id } : { source: "factory", id: item.id });
- setEditing(false);
- return;
- }
- setOpened({ source: "own", id: item.id });
- setEditing(false);
- };
- const useTemplate = (id) => {
- const template = typeof id === "string" ? id : "";
- if (!template) return;
- Promise.resolve(invoke("workflow/create", { template })).then((value) => {
- const settled = settleWorkflowRead(value);
- if (settled.phase === "error") {
- setError(settled.error);
- return;
- }
- const before = new Set((pack?.workflows ?? []).map((item) => item.id));
- const made = settled.pack.workflows.find((item) => !before.has(item.id));
- setPack(settled.pack);
- setPhase(settled.phase);
- setError("");
- if (made) {
- setOpened({ source: "own", id: made.id });
- setEditing(true);
- }
- }).catch((failure) => {
- setError(workflowCallError(failure, t("Could not copy this workflow")));
- });
- };
- const rename = (item) => {
- setRenaming(item);
- };
- const confirmRename = (workflowId, name) => {
- if (!name.trim()) return;
- Promise.resolve(invoke("workflow/rename", { workflowId, name: name.trim() })).then((value) => {
- const settled = settleWorkflowRead(value);
- if (settled.phase === "error") {
- setError(settled.error);
- return;
- }
- setPack(settled.pack);
- setPhase("ready");
- setError("");
- setRenaming(null);
- }).catch((failure) => {
- setError(workflowCallError(failure, t("Could not rename this workflow")));
- });
- };
- const deleteWorkflow = (item) => {
- setDeleting(item);
- };
- const confirmDelete = (workflowId) => {
- Promise.resolve(invoke("workflow/delete", { workflowId })).then((value) => {
- const settled = settleWorkflowRead(value);
- if (settled.phase === "error") {
- setError(settled.error);
- return;
- }
- setPack(settled.pack);
- setPhase(settled.phase);
- setError("");
- setDeleting(null);
- if (opened?.id === workflowId) setOpened(null);
- }).catch((failure) => {
- setError(workflowCallError(failure, t("Could not delete this workflow")));
- });
+ ),
+ face ? (0, import_react31.createElement)(
+ Card,
+ { title: t("Members · {count}", { count: people2.length }) },
+ people2.map((person) => (0, import_react31.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
+ ) : null,
+ face ? null : (0, import_react31.createElement)(
+ Card,
+ { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
+ humans.map((person) => (0, import_react31.createElement)(
+ "div",
+ { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-gs-who" },
+ (0, import_react31.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
+ (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Human")),
+ person.role === "admin" ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
+ ),
+ humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
+ )),
+ members.map((member) => {
+ const leads = room2.admin === member.id;
+ const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share";
+ const taskLead = room2.taskLead === member.id;
+ return (0, import_react31.createElement)(
+ "div",
+ { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react31.createElement)(
+ "button",
+ { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
+ (0, import_react31.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
+ (0, import_react31.createElement)("span", { className: "bt-row-title" }, member.name),
+ leads ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
+ taskLead ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null,
+ (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
+ ),
+ leads ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
+ boardHere ? taskLead ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null,
+ members.length > 2 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
+ );
+ }),
+ remotes.map((person) => (0, import_react31.createElement)(
+ "div",
+ { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-gs-who" },
+ (0, import_react31.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
+ (0, import_react31.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
+ // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
+ // labelled as such; a Bot with no Bot Social identity can never be written to at all.
+ room2.origin === "face-to-face" ? null : (0, import_react31.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
+ ),
+ // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
+ room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
+ )),
+ room2.origin === "face-to-face" ? null : adding ? [
+ ...outsidePeople.map((person) => (0, import_react31.createElement)(
+ "button",
+ { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
+ } },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
+ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
+ )),
+ ...outside.map((bot) => (0, import_react31.createElement)(
+ "button",
+ { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ add: [bot.id] });
+ } },
+ (0, import_react31.createElement)(BotMark, { bot, size: 22 }),
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, bot.name)),
+ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add"))
+ )),
+ ...outsideRemotes.map((bot) => (0, import_react31.createElement)(
+ "button",
+ { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+ setAdding(false);
+ void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
+ } },
+ (0, import_react31.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
+ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
+ )),
+ (0, import_react31.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
+ ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react31.createElement)(PlusIcon), t("Add member")) : null,
+ (0, import_react31.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react31.createElement)(
+ "span",
+ { className: "bt-set-hint" },
+ // The host's two-Bot floor guards membership changes; this hint warns before one.
+ members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
+ )),
+ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
+ // the note on who leads, so that note stays with the members it is about (#892).
+ room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react31.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
+ ),
+ (0, import_react31.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
+ (0, import_react31.createElement)(
+ Card,
+ { title: t("Sidebar") },
+ (0, import_react31.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
+ (0, import_react31.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
+ ),
+ (0, import_react31.createElement)(
+ Card,
+ { title: t("More") },
+ (0, import_react31.createElement)(
+ "button",
+ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
+ (0, import_react31.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react31.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
+ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
+ ),
+ (0, import_react31.createElement)("span", { className: "bt-chevron" }, (0, import_react31.createElement)(ChevronRightIcon))
+ )
+ )
+ );
+}
+function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
+ const [admin, setAdmin] = (0, import_react31.useState)(null);
+ const [members, setMembers] = (0, import_react31.useState)([]);
+ const [name, setName] = (0, import_react31.useState)("");
+ const [busy, setBusy] = (0, import_react31.useState)(false);
+ const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
+ const pickAdmin = (id) => {
+ setAdmin(id);
+ setMembers((list) => list.filter((item) => item !== id));
};
- const commit = (graph) => {
- if (!opened || opened.source !== "own") return;
- Promise.resolve(invoke("workflow/graph", { workflowId: opened.id, nodes: graph.nodes, edges: graph.edges })).then((value) => {
- const settled = settleWorkflowRead(value);
- if (settled.phase === "error") {
- setError(settled.error);
- return;
- }
- setPack(settled.pack);
- setPhase("ready");
- setError("");
- }).catch((failure) => {
- setError(workflowCallError(failure, t("Could not save this workflow")));
- });
+ const create = async () => {
+ if (busy || admin === null || members.length === 0) return;
+ setBusy(true);
+ let room2;
+ const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
+ room2 = value;
+ }));
+ if (done && room2) onCreated(room2);
+ else setBusy(false);
};
- return (0, import_react36.createElement)(
+ const chip = (bot, on, onClick, role) => (0, import_react31.createElement)("button", {
+ key: bot.id,
+ type: "button",
+ role,
+ className: "bt-ng-chip",
+ "aria-checked": on,
+ onClick
+ }, (0, import_react31.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react31.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react31.createElement)(AdminStar) : null);
+ return (0, import_react31.createElement)(
"div",
- null,
- (0, import_react36.createElement)(WorkflowsView, {
- phase,
- pack,
- error,
- opened,
- editing,
- roster,
- onOpen: open,
- onBack: () => {
- setOpened(null);
- setEditing(false);
- },
- onEditing: setEditing,
- onRetry: retry,
- onUse: useTemplate,
- onCommit: commit,
- onRename: rename,
- onDelete: deleteWorkflow
- }),
- renaming ? (0, import_react36.createElement)(RenameModal, {
- workflow: renaming,
- onClose: () => setRenaming(null),
- onConfirm: (name) => confirmRename(renaming.id, name)
- }) : null,
- deleting ? (0, import_react36.createElement)(DeleteModal, {
- workflow: deleting,
- onClose: () => setDeleting(null),
- onConfirm: () => confirmDelete(deleting.id)
- }) : null
+ { className: "bt-set-card bt-ng" },
+ (0, import_react31.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack" },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react31.createElement)(AdminStar), t("Choose the group admin"))),
+ (0, import_react31.createElement)(
+ "div",
+ { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
+ roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
+ )
+ ),
+ admin === null ? null : (0, import_react31.createElement)(
+ "div",
+ { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
+ (0, import_react31.createElement)(
+ "div",
+ { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
+ roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
+ )
+ ),
+ admin === null || members.length === 0 ? null : (0, import_react31.createElement)(
+ "label",
+ { className: "bt-set-row bt-bs-row bt-ng-step" },
+ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+ (0, import_react31.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
+ ),
+ (0, import_react31.createElement)(
+ "div",
+ { className: "bt-set-row bt-task-foot" },
+ (0, import_react31.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+ (0, import_react31.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
+ )
);
}
-
-// src/client/usage-data.js
-var HOUR = 0;
-var OWNER = 1;
-var MODEL = 2;
-var KIND = 3;
-var HOUR_MS = 36e5;
-var DAY_MS = 864e5;
-var RANGES = [
- { id: "24h", unit: "hour", count: 24, stride: 6 },
- { id: "7d", unit: "day", count: 7, stride: 1 },
- { id: "30d", unit: "day", count: 30, stride: 5 },
- { id: "12w", unit: "week", count: 12, stride: 2 },
- { id: "12m", unit: "month", count: 12, stride: 1 }
-];
-var hourMs = (key) => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, Number(key.slice(8, 10)), Number(key.slice(11, 13)) || 0);
-var dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
-var hourKey = (ms) => new Date(ms).toISOString().slice(0, 13).replace("T", " ");
-var mondayOf = (ms) => ms - (new Date(ms).getUTCDay() + 6) % 7 * DAY_MS;
-function spanRange(from, to) {
- if (to < from) [from, to] = [to, from];
- const days = Math.round((hourMs(to) - hourMs(from)) / DAY_MS) + 1;
- const unit = days <= 2 ? "hour" : days <= 62 ? "day" : days <= 182 ? "week" : "month";
- const range = { id: "custom", unit, from, to, days };
- const count = spanBuckets(range).length;
- const stride = unit === "hour" ? 6 : [1, 2, 3, 5, 7, 10, 14].find((step) => count / step <= 12) ?? Math.ceil(count / 12);
- return { ...range, count, stride };
-}
-function spanBuckets(range) {
- const first = hourMs(range.from);
- const end = hourMs(range.to) + DAY_MS;
- const buckets = [];
- if (range.unit === "hour" || range.unit === "day") {
- const step = range.unit === "hour" ? HOUR_MS : DAY_MS;
- for (let start = first; start < end; start += step) buckets.push({ start, end: start + step, key: range.unit === "hour" ? hourKey(start) : dayKey(start) });
- } else if (range.unit === "week") {
- for (let monday = mondayOf(first); monday < end; monday += 7 * DAY_MS) {
- buckets.push({ start: Math.max(monday, first), end: Math.min(monday + 7 * DAY_MS, end), key: dayKey(monday) });
- }
- } else {
- const year = new Date(first).getUTCFullYear();
- for (let month = new Date(first).getUTCMonth(); Date.UTC(year, month, 1) < end; month += 1) {
- const start = Date.UTC(year, month, 1);
- buckets.push({ start: Math.max(start, first), end: Math.min(Date.UTC(year, month + 1, 1), end), key: dayKey(start).slice(0, 7) });
+var GROUP_SETTINGS_CSS = `
+.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
+.bt-ng-step{animation:bt-veil-in .22s ease-out}
+.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
+.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
+.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
+.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
+.bt-ng-chip:active{scale:.96}
+.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
+.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
+.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
+.bt-gs-member{gap:8px}
+.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
+.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
+.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
+.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
+.bt-face-marks>.bt-mark{flex:none}
+.bt-face-person{cursor:default}
+.bt-face-person:hover .bt-row-title{text-decoration:none}
+.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
+.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
+button.bt-face-bot{cursor:pointer}
+button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
+.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
+.bt-bs-stack .bt-seg{align-self:stretch}
+@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
+`;
+
+// src/client/palette.js
+var import_react32 = require("react");
+var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
+var paletteText = (raw, names) => readableLine(String(raw ?? ""), names);
+var PALETTE_EXIT_MS = 160;
+function CommandPalette({ start, token, roster, actions, useSessions }) {
+ const [query, setQuery] = (0, import_react32.useState)(start ?? "");
+ const [cursor, setCursor] = (0, import_react32.useState)(0);
+ const [leaving, setLeaving] = (0, import_react32.useState)(false);
+ const wanted = query.trim().toLowerCase();
+ const close = () => {
+ if (leaving) return;
+ setLeaving(true);
+ setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
+ };
+ useEscape(close);
+ const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
+ const names = botNames(roster);
+ const found = useSessions((list) => {
+ if (wanted.length < 2) return "[]";
+ const hits = [];
+ for (const id of ids) {
+ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
+ if (!Array.isArray(outline)) continue;
+ const hidden = roster.exchangeTurns[id] ?? [];
+ for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
+ const entry = outline[index2];
+ if (hidden.includes(entry.turn)) continue;
+ for (const raw of [entry.response, entry.prompt]) {
+ const text2 = paletteText(raw, names);
+ const at = text2.toLowerCase().indexOf(wanted);
+ if (at < 0) continue;
+ const from = Math.max(0, at - 24);
+ hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]);
+ break;
+ }
+ }
}
- }
- return buckets;
+ return JSON.stringify(hits);
+ });
+ const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
+ const sections = [];
+ const people2 = [
+ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
+ key: bot.id,
+ avatar: (0, import_react32.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
+ title: pickerBotLabel(bot),
+ subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
+ badge: bot.hidden ? t("Hidden") : void 0,
+ run: async () => {
+ if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
+ actions.openSession(bot.id);
+ }
+ })),
+ ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
+ key: room2.id,
+ avatar: (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+ title: room2.name,
+ subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }),
+ badge: room2.hidden ? t("Hidden") : void 0,
+ run: async () => {
+ if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
+ actions.openSession(room2.id);
+ }
+ }))
+ ];
+ if (people2.length) sections.push([t("Bots"), people2]);
+ const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
+ key: agent.id,
+ avatar: (0, import_react32.createElement)("span", { className: "bt-palette-agent" }, (0, import_react32.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
+ title: "DSH Agent",
+ subtitle: t("Plain dsh session"),
+ run: () => actions.openAgent(agent.id)
+ }));
+ if (agents.length) sections.push(["DSH Agent", agents]);
+ const messages = JSON.parse(found).map(([id, turn, snippet]) => {
+ const bot = roster.byId[id];
+ const room2 = roster.roomsById[id];
+ return {
+ key: `${id}:${turn}`,
+ title: bot?.name ?? room2?.name ?? t("Bot"),
+ subtitle: snippet,
+ avatar: bot ? (0, import_react32.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+ run: () => actions.openSession(id)
+ };
+ });
+ if (messages.length) sections.push([t("Messages"), messages]);
+ const commands = [
+ { key: "cmd:new", icon: (0, import_react32.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
+ { key: "cmd:bot", icon: (0, import_react32.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
+ { key: "cmd:group", icon: (0, import_react32.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
+ { key: "cmd:settings", icon: (0, import_react32.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
+ ].filter((command) => matchesName(command.title));
+ if (commands.length) sections.push([t("Commands"), commands]);
+ const rows = sections.flatMap(([, items]) => items);
+ const active = Math.min(cursor, Math.max(rows.length - 1, 0));
+ const shown = rows.map((row) => row.key).join("\n");
+ const glide = useGlide(`${active}
+${shown}`, { axis: "y", ...QUICK_EDGES });
+ const listRef = glide.box;
+ useFlip(listRef, shown, "[data-flip]");
+ (0, import_react32.useEffect)(() => {
+ listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
+ }, [active, query]);
+ const choose = (row) => {
+ if (!row || leaving) return;
+ void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
+ close();
+ };
+ let index = -1;
+ return (0, import_react32.createElement)(
+ "div",
+ { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
+ (0, import_react32.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
+ (0, import_react32.createElement)(
+ "div",
+ { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
+ (0, import_react32.createElement)(
+ "div",
+ { className: "bt-palette-head" },
+ (0, import_react32.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react32.createElement)(SearchIcon)),
+ (0, import_react32.createElement)("input", {
+ autoFocus: true,
+ value: query,
+ placeholder: t("Search Bots, chats and messages"),
+ "aria-label": t("Search"),
+ role: "combobox",
+ "aria-expanded": true,
+ "aria-controls": "bt-palette-list",
+ onChange: (event) => {
+ setQuery(event.target.value);
+ setCursor(0);
+ },
+ onKeyDown: (event) => {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setCursor(Math.min(active + 1, rows.length - 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setCursor(Math.max(active - 1, 0));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ choose(rows[active]);
+ }
+ }
+ }),
+ query ? (0, import_react32.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
+ setQuery("");
+ setCursor(0);
+ } }, (0, import_react32.createElement)(CloseIcon)) : null
+ ),
+ (0, import_react32.createElement)(
+ "div",
+ { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
+ (0, import_react32.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ rows.length === 0 ? (0, import_react32.createElement)(
+ "div",
+ { className: "bt-palette-empty" },
+ (0, import_react32.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react32.createElement)(SearchIcon)),
+ (0, import_react32.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
+ (0, import_react32.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
+ ) : sections.map(([title, items]) => (0, import_react32.createElement)(
+ import_react32.Fragment,
+ { key: title },
+ (0, import_react32.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
+ items.map((row) => {
+ index += 1;
+ const at = index;
+ return (0, import_react32.createElement)(
+ "button",
+ {
+ key: row.key,
+ type: "button",
+ role: "option",
+ className: "bt-palette-row",
+ "aria-selected": at === active,
+ "data-flip": row.key,
+ onMouseMove: () => {
+ if (cursor !== at) setCursor(at);
+ },
+ onClick: () => choose(row)
+ },
+ (0, import_react32.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react32.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
+ (0, import_react32.createElement)(
+ "span",
+ { className: "bt-palette-text" },
+ (0, import_react32.createElement)("span", { className: "bt-palette-title" }, row.title),
+ row.subtitle ? (0, import_react32.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
+ ),
+ row.badge ? (0, import_react32.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
+ );
+ })
+ ))
+ )
+ )
+ );
}
-function spanOf(range, now) {
- if (range.from) {
- return { buckets: spanBuckets(range), from: `${range.from} 00`, to: `${range.to} 23`, before: hourKey(hourMs(range.from) - range.days * DAY_MS) };
- }
- const buckets = bucketsOf(range, now);
- return { buckets: buckets.slice(range.count), from: hourKey(buckets[range.count].start), to: now, before: hourKey(buckets[0].start) };
+
+// src/client/bot-draft.js
+function freshBotName(roster, base = "New Bot") {
+ const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
+ if (!taken.has(base.toLowerCase())) return base;
+ let index = 2;
+ while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
+ return `${base} ${index}`;
}
-function bucketsOf(range, now) {
- const end = hourMs(now);
- const today = end - end % DAY_MS;
- const buckets = [];
- for (let back = range.count * 2 - 1; back >= 0; back -= 1) {
- let start;
- if (range.unit === "hour") start = end - back * HOUR_MS;
- else if (range.unit === "day") start = today - back * DAY_MS;
- else if (range.unit === "week") start = mondayOf(today) - back * 7 * DAY_MS;
- else start = Date.UTC(new Date(end).getUTCFullYear(), new Date(end).getUTCMonth() - back, 1);
- buckets.push({ start, key: range.unit === "hour" ? hourKey(start) : range.unit === "month" ? dayKey(start).slice(0, 7) : dayKey(start) });
- }
- return buckets;
-}
-var tokensOf = (row) => row[4] + row[5] + row[6] + row[7];
-var blank2 = () => ({ input: 0, cacheRead: 0, output: 0, calls: 0, total: 0 });
-function addRow(sum, row) {
- sum.input += row[4] + row[6];
- sum.cacheRead += row[5];
- sum.output += row[7];
- sum.calls += row[8];
- sum.total += tokensOf(row);
- return sum;
-}
-function addTo(map, key, row) {
- map.set(key, addRow(map.get(key) ?? blank2(), row));
-}
-function summarize(view, range, keep = () => true, seriesOf = () => "all") {
- const { buckets, from, to, before } = spanOf(range, view.now);
- const index = new Map(buckets.map((bucket, at) => [bucket.key, at]));
- const weeks = /* @__PURE__ */ new Map();
- const keyOf = (hour) => {
- if (range.unit === "hour") return hour;
- if (range.unit === "day") return hour.slice(0, 10);
- if (range.unit === "month") return hour.slice(0, 7);
- const day = hour.slice(0, 10);
- let week = weeks.get(day);
- if (week === void 0) weeks.set(day, week = dayKey(mondayOf(hourMs(day))));
- return week;
- };
- const current = buckets.map((bucket) => ({ ...bucket, sum: blank2(), stacks: /* @__PURE__ */ new Map() }));
- const result = { buckets: current, total: blank2(), previous: 0, owners: /* @__PURE__ */ new Map(), models: /* @__PURE__ */ new Map(), kinds: /* @__PURE__ */ new Map() };
- for (const row of view.rows) {
- const hour = row[HOUR];
- if (hour < before || hour > to || !keep(row)) continue;
- if (hour < from) {
- result.previous += tokensOf(row);
- continue;
- }
- const at = index.get(keyOf(hour));
- if (at === void 0) continue;
- const bucket = current[at];
- addRow(bucket.sum, row);
- addRow(result.total, row);
- const series = seriesOf(row);
- bucket.stacks.set(series, (bucket.stacks.get(series) ?? 0) + tokensOf(row));
- addTo(result.owners, row[OWNER], row);
- addTo(result.models, row[MODEL], row);
- addTo(result.kinds, row[KIND], row);
- }
- return result;
+function startModel(roster) {
+ const models = roster.models ?? [];
+ const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
+ const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
+ return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
}
-function trimmed2(value) {
- const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
- return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2;
+function createPayload({ name, look, image, model, effort }) {
+ const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
+ if (model) payload.model = model;
+ if (effort) payload.effort = effort;
+ return payload;
}
-function compact(count) {
- for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) {
- if (count >= size * 0.9995) return `${trimmed2(count / size)}${unit}`;
+async function afterCreate({ id, text: text2, actions }) {
+ actions.openSession(id);
+ actions.showDetails(id, "settings");
+ const line2 = String(text2 ?? "").trim();
+ if (line2 === "") return;
+ try {
+ await actions.send(id, line2);
+ } catch (failure) {
+ actions.notify?.(failure?.message ?? String(failure));
}
- return String(Math.round(count));
-}
-function percent(part, whole) {
- if (!(whole > 0) || !(part > 0)) return "0";
- const value = part / whole * 100;
- return value < 1 ? "<1" : String(Math.round(value));
}
-// src/client/usage-page.js
-var import_react37 = require("react");
-var HEAT_WEEKS = 53;
-var TOP_SERIES = 7;
-var RING_R = 42;
-var RING2 = 2 * Math.PI * RING_R;
-var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id];
-var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit];
-var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"];
-var PARTS = [["output", 1], ["input", 0.6], ["cacheRead", 0.32]];
-var partLabel = (part) => ({ output: t("Output"), input: t("Input"), cacheRead: t("Cache hit") })[part];
-var soften = (paint, strength = 1) => {
- const mix = (color) => `color-mix(in srgb, ${color} ${Math.round(88 * strength)}%, var(--bt-card))`;
- return paint.startsWith("linear-gradient(") ? paint.replace(/#[0-9a-f]{6}/gi, mix) : mix(paint);
+// src/client/create-bot.js
+var import_react33 = require("react");
+var pick = (list) => list[Math.floor(Math.random() * list.length)];
+var randomLook = (avoid = {}) => {
+ const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
+ const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
+ return { shape: pick(shapes), color: pick(colors) };
};
-var full = (count) => new Intl.NumberFormat(dateLocale()).format(Math.round(count));
-var hitRate = (sum) => percent(sum.cacheRead, sum.input + sum.cacheRead);
-var dates = (options) => new Intl.DateTimeFormat(dateLocale(), { timeZone: "UTC", ...options });
-var hourText = (ms) => `${String(new Date(ms).getUTCHours()).padStart(2, "0")}:00`;
-var yearOf = (ms) => new Date(ms).getUTCFullYear();
-function spanText(from, to) {
- const start = hourMs(from);
- const end = hourMs(to);
- const year = (/* @__PURE__ */ new Date()).getFullYear();
- const format = dates(yearOf(start) !== year || yearOf(end) !== year ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
- return from === to ? format.format(start) : `${format.format(start)} – ${format.format(end)}`;
-}
-var daysText = (count) => count === 1 ? t("1 day") : t("{count} days", { count });
-var lastText = (range) => range.from ? spanText(range.from, range.to) : t("Last {range}", { range: rangeLabel(range.id) });
-function compareText(range) {
- if (!range.from) return t("Compared with the {range} before", { range: rangeLabel(range.id) });
- return range.days === 1 ? t("Compared with the day before") : t("Compared with the {count} days before", { count: range.days });
-}
-function tickOf(range, bucket) {
- if (range.unit === "hour") {
- return range.days > 1 && new Date(bucket.start).getUTCHours() === 0 ? dates({ month: "numeric", day: "numeric" }).format(bucket.start) : hourText(bucket.start);
- }
- if (range.unit === "month") return dates({ month: "short" }).format(bucket.start);
- return dates({ month: "numeric", day: "numeric" }).format(bucket.start);
-}
-function titleOf(range, bucket) {
- if (range.unit === "hour") return `${dates({ month: "short", day: "numeric" }).format(bucket.start)} ${hourText(bucket.start)}–${hourText(bucket.start + HOUR_MS)}`;
- if (range.unit === "day") return dates({ month: "long", day: "numeric", weekday: "short" }).format(bucket.start);
- if (range.unit === "week") return spanText(dayKey(bucket.start), dayKey((bucket.end ?? bucket.start + 7 * DAY_MS) - DAY_MS));
- if (bucket.end && (new Date(bucket.start).getUTCDate() !== 1 || new Date(bucket.end).getUTCDate() !== 1)) return spanText(dayKey(bucket.start), dayKey(bucket.end - DAY_MS));
- return dates({ year: "numeric", month: "long" }).format(bucket.start);
-}
-function useTween(value) {
- const [shown, setShown] = (0, import_react37.useState)(0);
- const from = (0, import_react37.useRef)(0);
- (0, import_react37.useEffect)(() => {
- if (reducedMotion() || typeof requestAnimationFrame !== "function") {
- from.current = value;
- setShown(value);
- return void 0;
+function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
+ const [look, setLook] = (0, import_react33.useState)(() => randomLook());
+ const [image, setImage] = (0, import_react33.useState)(null);
+ const [name, setName] = (0, import_react33.useState)(start?.name ?? "");
+ const [model, setModel] = (0, import_react33.useState)(() => startModel(roster));
+ const [effort, setEffort] = (0, import_react33.useState)(void 0);
+ const [busy, setBusy] = (0, import_react33.useState)(false);
+ const [error, setError] = (0, import_react33.useState)("");
+ const fileRef = (0, import_react33.useRef)(null);
+ const frame = (0, import_react33.useRef)(null);
+ useEscape(onBack);
+ (0, import_react33.useEffect)(() => {
+ if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
+ }, []);
+ const fallbackName = freshBotName(roster, t("New Bot"));
+ const finalName = name.trim() || fallbackName;
+ const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
+ const problem = nameProblem(finalName);
+ const create = async () => {
+ if (busy || clash || problem) return;
+ setError("");
+ setBusy(true);
+ try {
+ const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
+ await onCreated(bot);
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
+ setBusy(false);
}
- const begin = from.current;
- const started = performance.now();
- let frame;
- const step = (now) => {
- const progress = Math.min(1, (now - started) / 650);
- from.current = begin + (value - begin) * (1 - (1 - progress) ** 3);
- setShown(from.current);
- if (progress < 1) frame = requestAnimationFrame(step);
- };
- frame = requestAnimationFrame(step);
- return () => cancelAnimationFrame(frame);
- }, [value]);
- return shown;
-}
-var glyphBox = (size, icon3) => (0, import_react37.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react37.createElement)(icon3));
-function ModelGlyph({ name, paint, size }) {
- return (0, import_react37.createElement)("span", {
- className: "bt-us-model",
- "aria-hidden": true,
- style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` }
- }, (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase());
-}
-function ownerFace(owner, roster) {
- if (owner.kind === "bot") {
- const bot = roster.byId[owner.id] ?? { id: owner.id, name: owner.name };
- const name = bot.name || owner.name || t("Deleted Bot");
- const role = bot.role?.trim() ?? "";
- return {
- kind: "bot",
- name,
- sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role,
- paint: paintCss(colorOf(bot)),
- stops: GRADIENTS[colorOf(bot)],
- glyph: (size) => (0, import_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false })
- };
- }
- if (owner.kind === "room") {
- const room2 = roster.roomsById[owner.id];
- return {
- kind: "room",
- name: owner.name || t("Deleted group chat"),
- sub: owner.deleted ? t("Deleted") : t("Group chat"),
- paint: "var(--bt-us-rest)",
- glyph: (size) => room2 ? (0, import_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon)
- };
- }
- if (owner.kind === "agent") {
- return {
- kind: "agent",
- name: owner.name || "DSH Agent",
- sub: owner.deleted ? t("Deleted") : "",
- paint: "var(--bt-us-rest)",
- glyph: (size) => glyphBox(size, ChatsIcon)
- };
- }
- return { kind: "other", name: t("Other"), sub: "", paint: "var(--bt-us-rest)", glyph: (size) => glyphBox(size, ChatsIcon) };
-}
-function kindLabel(kind, botFocus) {
- const labels = { chat: botFocus ? t("Own chat") : t("Conversations"), group: t("Group chats"), compaction: t("Compaction and handoff"), image: t("Image reading"), memory: t("Memory"), "session-title": t("Titles"), other: t("Other") };
- return labels[kind] ?? kind;
-}
-function Tabs({ label, value, options, onChange, boxRef }) {
- const names = options.map(([, text2]) => text2).join("\n");
- const glide = useGlide(`${value}
-${names}`, { box: boxRef });
- return (0, import_react37.createElement)(
+ };
+ const upload = (file) => {
+ if (!file) return;
+ setError("");
+ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
+ };
+ const previewLook = image ? { ...look, image } : look;
+ const card = (0, import_react33.createElement)(
"div",
- { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label },
- (0, import_react37.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- options.map(([id, text2, icon3]) => (0, import_react37.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react37.createElement)(icon3) : null, text2))
- );
-}
-function Stats({ range, total, previous }) {
- const shown = useTween(total.total);
- const change = previous > 0 ? Math.round((total.total - previous) / previous * 100) : null;
- const cell = (label, value) => (0, import_react37.createElement)("div", { className: "bt-us-stat" }, (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react37.createElement)("span", { className: "bt-us-stat-value" }, value));
- return (0, import_react37.createElement)(
- "section",
- { className: "bt-us-stats", style: { "--i": 0 } },
- (0, import_react37.createElement)(
+ { className: "bt-create-card" },
+ (0, import_react33.createElement)(
"div",
- { className: "bt-us-stat bt-us-stat-main" },
- (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)),
- (0, import_react37.createElement)(
+ { className: "bt-create-preview" },
+ // The key remounts the mark, so every new pick plays the pop.
+ (0, import_react33.createElement)(
"span",
- { className: "bt-us-stat-value" },
- compact(shown),
- (0, import_react37.createElement)("small", null, t("tokens")),
- change === null ? null : (0, import_react37.createElement)(
- "span",
- { className: "bt-us-delta", title: compareText(range) },
- `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%`
- )
- )
- ),
- cell(t("Cache hit rate"), `${hitRate(total)}%`),
- cell(t("Output"), compact(total.output)),
- cell(t("Calls"), full(total.calls))
- );
-}
-function niceTop(max) {
- if (!(max > 0)) return 1;
- const step = 10 ** Math.floor(Math.log10(max));
- return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max);
-}
-function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) {
- const [hover, setHover] = (0, import_react37.useState)(null);
- const top = niceTop(Math.max(0, ...columns.map((column) => column.total)));
- const count = columns.length;
- const fillOf = new Map(series.map((entry) => [entry.key, entry.fill]));
- const tipOf = new Map(tipSeries.map((entry) => [entry.key, entry]));
- const tip = hover === null ? null : columns[hover];
- const tipSide = hover !== null && hover >= count / 2 ? { right: `calc(${(count - hover) / count * 100}% + 8px)` } : { left: `calc(${(hover + 1) / count * 100}% + 8px)` };
- return (0, import_react37.createElement)(
- "section",
- { className: "bt-us-card bt-us-chart", style: { "--i": 1 } },
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-card-head" },
- (0, import_react37.createElement)("h3", null, t("Tokens over time"), (0, import_react37.createElement)("span", null, unitLabel(range.unit))),
- onBy ? (0, import_react37.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null
+ { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
+ (0, import_react33.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
+ ),
+ (0, import_react33.createElement)("span", { className: "bt-create-name" }, finalName)
),
- (0, import_react37.createElement)(
+ (0, import_react33.createElement)("div", { className: "bt-create-step" }, t("Look")),
+ (0, import_react33.createElement)(
"div",
- { className: "bt-us-chart-body" },
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-plot", "data-hover": hover === null ? void 0 : "", onMouseLeave: () => setHover(null) },
- [1, 0.5].map((level) => (0, import_react37.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react37.createElement)("span", null, compact(top * level)))),
- (0, import_react37.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react37.createElement)("span", null, "0")),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-cols" },
- columns.map((column, at) => (0, import_react37.createElement)("div", {
- key: `${range.from ? `${range.from}~${range.to}` : range.id}:${column.key}`,
- className: "bt-us-col",
- "data-on": hover === at ? "" : void 0,
- onMouseEnter: () => setHover(at)
- }, column.total > 0 ? (0, import_react37.createElement)(
- "div",
- { className: "bt-us-stack", style: { height: `${column.total / top * 100}%`, "--i": at } },
- column.parts.map(([key, value]) => value > 0 ? (0, import_react37.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null)
- ) : (0, import_react37.createElement)("div", { className: "bt-us-stub" })))
- ),
- tip ? (0, import_react37.createElement)(
- "div",
- { className: "bt-us-tip", style: tipSide },
- (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, tip.title),
- (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(tip.total) })),
- tip.tip.filter(([, value]) => value > 0).sort((a, b) => b[1] - a[1]).map(([key, value]) => (0, import_react37.createElement)(
- "div",
- { key, className: "bt-us-tip-row" },
- (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }),
- (0, import_react37.createElement)("span", null, tipOf.get(key)?.name),
- (0, import_react37.createElement)("b", null, compact(value))
- ))
- ) : null
- ),
- (0, import_react37.createElement)(
+ { className: "bt-create-looks" },
+ (0, import_react33.createElement)(CharacterGrid, {
+ look,
+ hasPhoto: Boolean(image),
+ onShape: (shape) => {
+ setImage(null);
+ setLook((current) => ({ ...current, shape }));
+ },
+ onColor: (color) => {
+ setImage(null);
+ setLook((current) => ({ ...current, color }));
+ }
+ }),
+ (0, import_react33.createElement)(
"div",
- { className: "bt-us-ticks", "aria-hidden": true },
- // A preset counts its labels back from now; a picked span forward from its first day.
- columns.map((column, at) => (0, import_react37.createElement)(
- "span",
- { key: column.key, "data-now": at === count - 1 && range.ends !== false ? "" : void 0 },
- (range.from ? at : count - 1 - at) % range.stride === 0 ? column.tick : ""
- ))
+ { className: "bt-actions bt-create-lookbar" },
+ (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ setImage(null);
+ setLook((current) => randomLook(current));
+ } }, t("Shuffle")),
+ (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
+ image ? (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
+ (0, import_react33.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+ const file = event.target.files?.[0];
+ event.target.value = "";
+ upload(file);
+ } })
)
),
- series.length > 1 || series[0]?.pick ? (0, import_react37.createElement)(
- "div",
- { className: "bt-us-legend" },
- series.map((entry) => (0, import_react37.createElement)(entry.pick ? "button" : "span", {
- key: entry.key,
- className: "bt-us-key",
- ...entry.pick ? { type: "button", onClick: () => onPick(entry.pick) } : {}
- }, (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name))
- ) : null
- );
-}
-function Row3({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) {
- const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0;
- return (0, import_react37.createElement)(
- onClick ? "button" : "div",
- {
- className: "bt-us-row",
- "data-kind": kind,
- style: { "--i": index },
- title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }),
- ...onClick ? { type: "button", onClick } : {}
- },
- glyph ? (0, import_react37.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null,
- (0, import_react37.createElement)(
- "span",
- { className: "bt-us-row-body" },
- (0, import_react37.createElement)(
- "span",
- { className: "bt-us-row-line" },
- (0, import_react37.createElement)("span", { className: "bt-us-row-name" }, name),
- sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null
- ),
- (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill3 } }))
+ (0, import_react33.createElement)(
+ "label",
+ { className: "bt-field" },
+ (0, import_react33.createElement)("span", { className: "bt-field-label" }, t("Name")),
+ (0, import_react33.createElement)("input", {
+ className: "bt-input",
+ autoFocus: !inline,
+ value: name,
+ placeholder: fallbackName,
+ maxLength: 40,
+ "aria-label": t("Name"),
+ onChange: (event) => setName(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ void create();
+ }
+ }
+ }),
+ problem ? (0, import_react33.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react33.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
),
- (0, import_react37.createElement)("span", { className: "bt-us-row-num" }, (0, import_react37.createElement)("b", null, compact(sum.total)), (0, import_react37.createElement)("small", null, `${percent(sum.total, whole)}%`))
- );
-}
-function ListCard({ title, count, index, children }) {
- return (0, import_react37.createElement)(
- "section",
- { className: "bt-us-card", style: { "--i": index } },
- (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, title, count === void 0 ? null : (0, import_react37.createElement)("span", null, count))),
- (0, import_react37.createElement)("div", { className: "bt-us-rows" }, children)
- );
-}
-function Profile({ face, share, range }) {
- const arc = Math.max(0, Math.min(1, share)) * RING2;
- return (0, import_react37.createElement)(
- "section",
- { className: "bt-us-profile" },
- (0, import_react37.createElement)(
- "span",
- { className: "bt-us-ring" },
- (0, import_react37.createElement)(
- "svg",
- { viewBox: "0 0 96 96", "aria-hidden": true },
- face.stops ? (0, import_react37.createElement)("defs", null, (0, import_react37.createElement)(
- "linearGradient",
- { id: "bt-us-ring-paint", x1: 0, y1: 1, x2: 1, y2: 0 },
- face.stops.map(([offset, color]) => (0, import_react37.createElement)("stop", { key: offset, offset, stopColor: color }))
- )) : null,
- (0, import_react37.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }),
- arc > 0 ? (0, import_react37.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null
- ),
- (0, import_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64))
+ (0, import_react33.createElement)(
+ "div",
+ { className: "bt-field" },
+ (0, import_react33.createElement)("span", { className: "bt-field-label" }, t("Model")),
+ (0, import_react33.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
+ setModel(ref);
+ setEffort(void 0);
+ }, onPickEffort: setEffort }),
+ (0, import_react33.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
),
- (0, import_react37.createElement)(
+ error ? (0, import_react33.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
+ (0, import_react33.createElement)(
"div",
- { className: "bt-us-profile-copy" },
- (0, import_react37.createElement)("h3", null, face.name),
- face.sub ? (0, import_react37.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null,
- (0, import_react37.createElement)("span", { className: "bt-us-profile-share" }, range.from ? t("{share}% of all usage from {span}", { share: percent(share, 1), span: spanText(range.from, range.to) }) : t("{share}% of all usage in the last {range}", { share: percent(share, 1), range: rangeLabel(range.id) }))
+ { className: "bt-create-foot" },
+ // The dialog's header already has Back and Close (#230); only the inline form needs its own.
+ inline ? (0, import_react33.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
+ (0, import_react33.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
)
);
-}
-var monthOf = (ms) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth(), 1);
-var addMonths = (ms, count) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth() + count, 1);
-function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) {
- const box = (0, import_react37.useRef)(null);
- const todayMs = hourMs(today);
- const thisMonth = monthOf(todayMs);
- const [right, setRight] = (0, import_react37.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today))));
- const [anchor, setAnchor] = (0, import_react37.useState)(null);
- const [hover, setHover] = (0, import_react37.useState)(null);
- (0, import_react37.useEffect)(() => {
- const onDown = (event) => {
- if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose();
- };
- const onKey = (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- onClose();
- }
- };
- window.addEventListener("mousedown", onDown);
- window.addEventListener("keydown", onKey, true);
- return () => {
- window.removeEventListener("mousedown", onDown);
- window.removeEventListener("keydown", onKey, true);
- };
- }, []);
- const [low, high] = anchor ? [anchor, hover ?? anchor].sort() : span ? [span.from, span.to] : [null, null];
- const press = (key) => {
- if (!anchor) {
- setAnchor(key);
- return;
- }
- const [from, to] = [anchor, key].sort();
- onPick(from, to);
- };
- const weekdays = (0, import_react37.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []);
- const month = (start, side) => {
- const length = new Date(addMonths(start, 1) - DAY_MS).getUTCDate();
- const lead = (new Date(start).getUTCDay() + 6) % 7;
- const cells = Array.from({ length: lead }, (_, at) => (0, import_react37.createElement)("span", { key: `b${at}` }));
- for (let day = 1; day <= length; day += 1) {
- const ms = start + (day - 1) * DAY_MS;
- const key = dayKey(ms);
- const weekday = (lead + day - 1) % 7;
- const flag = (on) => on ? "" : void 0;
- cells.push((0, import_react37.createElement)("button", {
- key,
- type: "button",
- className: "bt-us-day",
- disabled: ms > todayMs,
- "aria-label": dates({ year: "numeric", month: "long", day: "numeric", weekday: "short" }).format(ms),
- "aria-pressed": key === low || key === high,
- "data-in": flag(low !== null && key >= low && key <= high),
- "data-start": flag(key === low),
- "data-end": flag(key === high),
- "data-l": flag(weekday === 0 || day === 1),
- "data-r": flag(weekday === 6 || day === length),
- "data-today": flag(key === today),
- "data-has": flag(active.has(key)),
- onMouseEnter: () => setHover(key),
- onClick: () => press(key)
- }, (0, import_react37.createElement)("b", null, day)));
- }
- const nav = side === "left" ? (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "left", "aria-label": t("Previous month"), disabled: start <= oldest, onClick: () => setRight(addMonths(right, -1)) }, (0, import_react37.createElement)(ChevronLeftIcon)) : (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "right", "aria-label": t("Next month"), disabled: start >= thisMonth, onClick: () => setRight(addMonths(right, 1)) }, (0, import_react37.createElement)(ChevronRightIcon));
- return (0, import_react37.createElement)(
- "div",
- { key: start, className: "bt-us-month" },
- (0, import_react37.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react37.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))),
- (0, import_react37.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react37.createElement)("span", { key: at }, name))),
- (0, import_react37.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells)
- );
- };
- const shortcuts = [
- ["month", t("This month"), dayKey(thisMonth), today],
- ["last", t("Last month"), dayKey(addMonths(thisMonth, -1)), dayKey(thisMonth - DAY_MS)],
- ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today]
- ];
- const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1;
- return (0, import_react37.createElement)(
+ if (inline) return (0, import_react33.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
+ return (0, import_react33.createElement)(
"div",
- { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") },
- (0, import_react37.createElement)(
+ { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") },
+ (0, import_react33.createElement)(
"div",
- { className: "bt-us-pick-quick" },
- shortcuts.map(([id, label, from, to]) => (0, import_react37.createElement)("button", {
- key: id,
- type: "button",
- "aria-pressed": span?.from === from && span?.to === to,
- onClick: () => onPick(from, to)
- }, label))
+ { className: "bt-create-head" },
+ canGoBack ? (0, import_react33.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react33.createElement)(ChevronLeftIcon)) : (0, import_react33.createElement)("span", { className: "bt-create-gap" }),
+ (0, import_react33.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
+ (0, import_react33.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react33.createElement)(CloseIcon))
),
- (0, import_react37.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-pick-foot", role: "status" },
- (0, import_react37.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")),
- low === null ? null : (0, import_react37.createElement)("span", null, (0, import_react37.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`)
- )
+ (0, import_react33.createElement)("div", { className: "bt-create-body" }, card)
);
}
-function Heat({ view, keep, paint, index }) {
- const [hover, setHover] = (0, import_react37.useState)(null);
- const today = hourMs(`${view.now.slice(0, 10)} 00`);
- const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS;
- const from = dayKey(first);
- const daily = /* @__PURE__ */ new Map();
- for (const row of view.rows) {
- const day = row[HOUR].slice(0, 10);
- if (day >= from && keep(row)) daily.set(day, (daily.get(day) ?? 0) + tokensOf(row));
- }
- const most = Math.max(0, ...daily.values());
- const levels = [0.3, 0.52, 0.76, 1];
- const months = [];
- const cells = [];
- for (let week = 0; week < HEAT_WEEKS; week += 1) {
- const monday = first + week * 7 * DAY_MS;
- if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) {
- months.push((0, import_react37.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday)));
- }
- for (let day = 0; day < 7; day += 1) {
- const ms = monday + day * DAY_MS;
- const value = daily.get(dayKey(ms)) ?? 0;
- const level = value > 0 ? Math.max(1, Math.ceil(4 * Math.sqrt(value / most))) : 0;
- cells.push((0, import_react37.createElement)("span", {
- key: ms,
- className: "bt-us-cell",
- style: { "--w": week },
- "data-on": hover?.ms === ms ? "" : void 0,
- "data-future": ms > today ? "" : void 0,
- "data-today": ms === today ? "" : void 0,
- onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day })
- }, level > 0 ? (0, import_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null));
+
+// src/client/new-chat.js
+var import_react34 = require("react");
+function NewChat({ mode, roster, actions }) {
+ const [query, setQuery] = (0, import_react34.useState)("");
+ const [picked, setPicked] = (0, import_react34.useState)([]);
+ const [group, setGroup] = (0, import_react34.useState)(mode === "group");
+ const [compose, setCompose] = (0, import_react34.useState)(mode !== "new");
+ const [admin, setAdmin] = (0, import_react34.useState)(null);
+ const [message, setMessage] = (0, import_react34.useState)("");
+ const [busy, setBusy] = (0, import_react34.useState)(false);
+ const [error, setError] = (0, import_react34.useState)("");
+ const [fix, setFix] = (0, import_react34.useState)(null);
+ const [fixNote, setFixNote] = (0, import_react34.useState)("");
+ const [inviteLink, setInviteLink] = (0, import_react34.useState)("");
+ const [cursor, setCursor] = (0, import_react34.useState)(0);
+ const [draft, setDraft] = (0, import_react34.useState)(mode === "create" ? { name: "", fromList: false } : null);
+ const inputRef = (0, import_react34.useRef)(null);
+ const close = () => actions.closeOverlay();
+ useEscape(close);
+ const paneLeft = usePaneLeft();
+ const wanted = query.trim().toLowerCase();
+ const bots = listedChatBots(roster);
+ const choosingAdmin = group && admin === null;
+ const pool = choosingAdmin ? bots.filter((bot) => !bot.remote) : bots;
+ const matches = pool.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted)));
+ const exact = bots.some((bot) => bot.name.toLowerCase() === wanted);
+ const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean);
+ const run = async (task) => {
+ if (busy) return;
+ setError("");
+ setFix(null);
+ setFixNote("");
+ setInviteLink("");
+ setBusy(true);
+ try {
+ await task();
+ } catch (failure) {
+ setError(failure?.message ?? String(failure));
+ setFix(ungrantedLocalFix(failure?.details) ?? null);
+ } finally {
+ setBusy(false);
}
- }
- const tipSide = hover ? {
- ...hover.week >= HEAT_WEEKS / 2 ? { right: `calc(${(HEAT_WEEKS - hover.week) / HEAT_WEEKS * 100}% + 8px)` } : { left: `calc(${(hover.week + 1) / HEAT_WEEKS * 100}% + 8px)` },
- ...hover.day < 4 ? { top: `calc(${hover.day / 7 * 100}% - 2px)` } : { top: "auto", bottom: `calc(${(6 - hover.day) / 7 * 100}% - 2px)` }
- } : null;
- const active = [...daily.values()].filter((value) => value > 0).length;
- const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active });
- return (0, import_react37.createElement)(
- "section",
- { className: "bt-us-card bt-us-heat", style: { "--i": index } },
- (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, t("Activity")), (0, import_react37.createElement)("span", { className: "bt-us-head-note" }, summary)),
- (0, import_react37.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-heat-plot", onMouseLeave: () => setHover(null) },
- (0, import_react37.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells),
- hover ? (0, import_react37.createElement)(
- "div",
- { className: "bt-us-tip bt-us-heat-tip", style: tipSide },
- (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)),
- (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) }))
- ) : null
- ),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-heat-scale", "aria-hidden": true },
- t("Light"),
- levels.map((level) => (0, import_react37.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react37.createElement)("i", { style: { background: soften(paint, level) } }))),
- t("Heavy")
- )
- );
-}
-function Skeleton2() {
- return (0, import_react37.createElement)(
- "div",
- { className: "bt-us", "aria-busy": true },
- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-sheet bt-us-skel-sheet" },
- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }),
- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }),
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-pair" },
- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }),
- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } })
- )
- )
- );
-}
-var focusOf = (id) => typeof id !== "string" ? null : id.startsWith("model:") ? { kind: "model", key: id.slice("model:".length) } : id.startsWith("owner:") ? { kind: "owner", key: id.slice("owner:".length) } : null;
-function UsagePage({ roster, actions, focusId }) {
- const [view, setView] = (0, import_react37.useState)(null);
- const [error, setError] = (0, import_react37.useState)("");
- const [rangeId, setRangeId] = (0, import_react37.useState)("7d");
- const [span, setSpan] = (0, import_react37.useState)(null);
- const [picking, setPicking] = (0, import_react37.useState)(false);
- const [by, setBy] = (0, import_react37.useState)("bot");
- const tabs = (0, import_react37.useRef)(null);
- const focus = focusOf(focusId);
- (0, import_react37.useEffect)(() => {
- let stopped = false;
- let timer;
- const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => {
- })));
- const load = async () => {
- try {
- const value = await actions.usage();
- await resized;
- if (stopped) return;
- (0, import_react37.startTransition)(() => setView(value));
- setError("");
- timer = setTimeout(load, value.scanning ? 3e3 : 2e4);
- } catch (failure) {
- if (stopped) return;
- setError(failure?.message ?? String(failure));
- timer = setTimeout(load, 1e4);
- }
- };
- void load();
- return () => {
- stopped = true;
- clearTimeout(timer);
- };
- }, []);
- const today = view?.now.slice(0, 10);
- const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId);
- const shaped = (0, import_react37.useMemo)(() => {
- if (!view) return null;
- const faces2 = view.owners.map((owner) => ownerFace(owner, roster));
- const lifetime = /* @__PURE__ */ new Map();
- for (const row of view.rows) lifetime.set(row[MODEL], (lifetime.get(row[MODEL]) ?? 0) + tokensOf(row));
- const modelPaint = new Map([...lifetime].sort((a, b) => b[1] - a[1]).map(([model], rank) => [model, `var(--bt-ink-${MODEL_INKS[rank % MODEL_INKS.length]})`]));
- const models2 = view.models.map((model, at) => {
- const name = model.name || model.model || t("Unknown model");
- const paint = modelPaint.get(at) ?? "var(--bt-us-rest)";
- return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react37.createElement)(ModelGlyph, { name, paint, size }) };
- });
- const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1;
- const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1;
- const isBot2 = (at) => ["bot", "agent"].includes(view.owners[at]?.kind);
- const all2 = summarize(view, range, void 0, (row) => by === "bot" ? isBot2(row[OWNER]) ? `o${row[OWNER]}` : "rest" : `m${row[MODEL]}`);
- const keep2 = ownerAt2 >= 0 ? (row) => row[OWNER] === ownerAt2 : modelAt >= 0 ? (row) => row[MODEL] === modelAt : () => true;
- const face2 = ownerAt2 >= 0 ? faces2[ownerAt2] : modelAt >= 0 ? models2[modelAt] : null;
- const part2 = face2 ? summarize(view, range, keep2) : all2;
- const activeDays2 = /* @__PURE__ */ new Set();
- let first = view.now;
- for (const row of view.rows) {
- if (row[HOUR] < first) first = row[HOUR];
- if (keep2(row) && tokensOf(row) > 0) activeDays2.add(row[HOUR].slice(0, 10));
+ };
+ const leaveOut = (id) => {
+ if (id === admin) setAdmin(null);
+ setPicked((current) => current.filter((other) => other !== id));
+ setError("");
+ setFix(null);
+ setFixNote("");
+ setInviteLink("");
+ };
+ const copyInvite = async (bot) => {
+ const social = globalThis.__flybot__?.social;
+ let link;
+ try {
+ const invite = await social.createInvite(bot.id);
+ link = invite?.link || invite?.url;
+ if (!link) throw new Error("no link");
+ } catch {
+ setInviteLink("");
+ setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot."));
+ return;
}
- const oldest2 = Math.min(monthOf(hourMs(first)), addMonths(monthOf(hourMs(view.now)), -12));
- const ranked2 = (map) => [...map].filter(([, sum]) => sum.total > 0).sort((a, b) => b[1].total - a[1].total);
- let series2;
- if (face2) {
- series2 = [{ key: "all", name: face2.name, fill: soften(face2.paint) }];
+ if (await copiedToClipboard(link)) {
+ setInviteLink("");
+ setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name }));
} else {
- if (by === "bot") {
- const bots = ranked2(all2.owners).filter(([at]) => isBot2(at));
- series2 = bots.slice(0, TOP_SERIES).map(([at]) => ({ key: `o${at}`, name: faces2[at].name, paint: faces2[at].paint, pick: { kind: "owner", key: view.owners[at].key } }));
- if (bots.length > TOP_SERIES) series2.push({ key: "more", name: t("More Bots"), paint: "var(--bt-us-more)" });
- if (ranked2(all2.owners).some(([at]) => !isBot2(at))) series2.push({ key: "rest", name: t("Other"), paint: "var(--bt-us-rest)" });
- } else {
- const used = ranked2(all2.models);
- series2 = used.slice(0, TOP_SERIES).map(([at]) => ({ key: `m${at}`, name: models2[at].name, paint: models2[at].paint, pick: { kind: "model", key: view.models[at].key } }));
- if (used.length > TOP_SERIES) series2.push({ key: "more", name: t("More models"), paint: "var(--bt-us-more)" });
- }
- const repeats = /* @__PURE__ */ new Map();
- for (const entry of series2) {
- const seen = repeats.get(entry.paint) ?? 0;
- repeats.set(entry.paint, seen + 1);
- entry.fill = soften(entry.paint, [1, 0.6, 0.36][seen % 3]);
- }
+ setInviteLink(link);
+ setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name }));
}
- const tipSeries2 = face2 ? PARTS.map(([key, strength]) => ({ key, name: partLabel(key), fill: soften(face2.paint, strength) })) : series2;
- const fillOf2 = new Map(series2.map((entry) => [entry.key, entry.fill]));
- const shownKeys = new Set(series2.map((entry) => entry.key));
- const columns2 = part2.buckets.map((bucket) => {
- const parts = face2 ? [["all", bucket.sum.total]] : series2.map((entry) => [entry.key, entry.key === "more" ? [...bucket.stacks].filter(([key]) => key !== "rest" && !shownKeys.has(key)).reduce((sum, [, value]) => sum + value, 0) : bucket.stacks.get(entry.key) ?? 0]);
- const tip = face2 ? PARTS.map(([key]) => [key, bucket.sum[key]]) : parts;
- return { key: bucket.key, tick: tickOf(range, bucket), title: titleOf(range, bucket), total: bucket.sum.total, parts, tip };
- });
- return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 };
- }, [view, rangeId, span, by, focusId, roster]);
- const root = (0, import_react37.useRef)(null);
- (0, import_react37.useEffect)(() => {
- root.current?.closest(".bt-settings-scroll")?.scrollTo?.({ top: 0 });
- }, [focusId]);
- if (!view) {
- return error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react37.createElement)(Skeleton2);
- }
- if (view.rows.length === 0) {
- return (0, import_react37.createElement)(
- "div",
- { className: "bt-us" },
- (0, import_react37.createElement)(
- "div",
- { className: "bt-us-empty" },
- (0, import_react37.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }),
- (0, import_react37.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")),
- (0, import_react37.createElement)("span", null, t("Token usage shows up here after a model is called."))
- )
- );
- }
- const { faces, models, ownerAt, face, keep, all, part, series, tipSeries, fillOf, columns, ranked, isBot, activeDays, oldest } = shaped;
- const pick2 = (next) => actions.openSettings("usage", `${next.kind}:${next.key}`);
- const chooseRange = (id) => {
- if (id !== "custom") {
- setRangeId(id);
- setPicking(false);
+ };
+ const pick2 = (bot) => {
+ setPicked((current) => [...current, bot.id]);
+ setQuery("");
+ setCursor(0);
+ inputRef.current?.focus();
+ };
+ const chooseAdmin = (bot) => {
+ setAdmin(bot.id);
+ setQuery("");
+ setCursor(0);
+ inputRef.current?.focus();
+ };
+ const openBot = (bot) => run(async () => {
+ if (choosingAdmin) {
+ chooseAdmin(bot);
return;
}
- if (span) setRangeId("custom");
- setPicking((open) => !open);
+ if (group || picked.length > 0) {
+ pick2(bot);
+ return;
+ }
+ if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
+ if (message.trim()) await actions.send(bot.id, message.trim());
+ actions.openSession(bot.id);
+ close();
+ });
+ const createBot = () => {
+ if (busy) return;
+ setError("");
+ setDraft({ name: query.trim() !== "" && !exact ? query.trim() : "", fromList: true });
};
- const pickSpan = (from, to) => {
- setSpan({ from, to });
- setRangeId("custom");
- setPicking(false);
+ const created = (bot) => {
+ close();
+ return afterCreate({ id: bot.id, text: message, actions });
};
- const rangeOptions = [
- ...RANGES.map((item) => [item.id, rangeLabel(item.id)]),
- ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon]
- ];
- const whole = part.total.total;
- const ownerRow = ([at, sum], index, total = whole) => (0, import_react37.createElement)(Row3, {
- key: `o${at}`,
- kind: faces[at].kind,
- glyph: faces[at].glyph(32),
- name: faces[at].name,
- sub: faces[at].sub,
- sum,
- whole: total,
- index,
- fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint),
- onClick: () => pick2({ kind: "owner", key: view.owners[at].key })
- });
- const modelRow = ([at, sum], index) => (0, import_react37.createElement)(Row3, {
- key: `m${at}`,
- kind: "model",
- glyph: models[at].glyph(32),
- name: models[at].name,
- sub: models[at].sub,
- sum,
- whole,
- index,
- fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint),
- onClick: () => pick2({ kind: "model", key: view.models[at].key })
- });
- const nothing = (0, import_react37.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range."));
- let body;
- if (face) {
- const kinds = ranked(part.kinds);
- const usedModels = ranked(part.models);
- const owners = ranked(part.owners);
- body = [
- (0, import_react37.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }),
- (0, import_react37.createElement)(
- "div",
- { key: "sheet", className: "bt-us-sheet" },
- (0, import_react37.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }),
- (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }),
- (0, import_react37.createElement)(
- "div",
- { key: "pair", className: "bt-us-pair" },
- ownerAt >= 0 ? (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react37.createElement)(ListCard, { title: t("Who used it"), count: owners.length, index: 2 }, owners.length > 0 ? owners.map((entry, index) => ownerRow(entry, index)) : nothing),
- (0, import_react37.createElement)(
- ListCard,
- { title: t("What for"), index: 3 },
- kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react37.createElement)(Row3, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing
- )
- ),
- (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 })
- )
+ const makeGroup = async () => {
+ if (admin === null || picked.length === 0) return;
+ const text2 = message.trim();
+ const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]);
+ const participants = remotes.length === 0 ? void 0 : [
+ ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })),
+ ...remotes
];
- } else {
- const owners = ranked(all.owners);
- const bots = owners.filter(([at]) => isBot(at));
- const rest = owners.filter(([at]) => !isBot(at));
- const usedModels = ranked(all.models);
- body = [
- (0, import_react37.createElement)(
- "div",
- { key: "sheet", className: "bt-us-sheet" },
- (0, import_react37.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }),
- (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }),
- (0, import_react37.createElement)(
- "div",
- { key: "pair", className: "bt-us-pair" },
- (0, import_react37.createElement)(
- ListCard,
- { title: t("Bots"), count: bots.length, index: 2 },
- owners.length > 0 ? null : nothing,
- bots.map((entry, index) => ownerRow(entry, index)),
- bots.length > 0 && rest.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-us-split", role: "separator" }) : null,
- rest.map((entry, index) => ownerRow(entry, bots.length + index))
- ),
- (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing)
- ),
- (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 })
- )
+ const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} });
+ if (text2) await actions.send(room2.id, text2);
+ actions.openSession(room2.id);
+ close();
+ };
+ const createGroup = () => run(makeGroup);
+ const sendMessage = () => run(async () => {
+ const text2 = message.trim();
+ if (group && admin !== null) {
+ await makeGroup();
+ return;
+ }
+ if (text2 === "" || picked.length === 0) return;
+ if (picked.length === 1) {
+ await actions.send(picked[0], text2);
+ actions.openSession(picked[0]);
+ close();
+ return;
+ }
+ const { members, remotes } = splitRoomPicks(roster, picked);
+ const participants = remotes.length === 0 ? void 0 : [
+ ...members.map((id) => ({ kind: "bot", id })),
+ ...remotes
];
+ const room2 = await actions.createRoom(members, participants ? { participants } : {});
+ await actions.send(room2.id, text2);
+ actions.openSession(room2.id);
+ close();
+ });
+ const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === "";
+ const options = [];
+ if (!group && picked.length === 0 && mode !== "search" && wanted === "") {
+ if (menuOnly) {
+ options.push({ key: "chat", icon: (0, import_react34.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
+ setCompose(true);
+ setCursor(0);
+ setTimeout(() => inputRef.current?.focus());
+ } });
+ }
+ options.push({ key: "new", icon: (0, import_react34.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
+ options.push({ key: "group", icon: (0, import_react34.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
+ setGroup(true);
+ setCompose(true);
+ setCursor(0);
+ inputRef.current?.focus();
+ } });
+ for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
+ options.push({
+ key: action.key,
+ icon: action.icon ? (0, import_react34.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react34.createElement)(PeopleIcon) }) : (0, import_react34.createElement)(PeopleIcon),
+ label: t(action.label),
+ hint: action.hint ? t(action.hint) : void 0,
+ run: () => {
+ const done = Promise.resolve().then(() => action.run({ close, actions, roster }));
+ done.then((note) => {
+ if (typeof note === "string" && note) actions.notify?.(note);
+ }).catch((failure) => setError(failure?.message ?? String(failure)));
+ }
+ });
+ }
+ if ((roster.agents ?? []).length === 0) {
+ options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
+ await actions.addAgent();
+ close();
+ }) });
+ }
}
- return (0, import_react37.createElement)(
+ if (!menuOnly) {
+ for (const bot of matches) {
+ options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) });
+ }
+ }
+ if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react34.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
+ const active = Math.min(cursor, Math.max(options.length - 1, 0));
+ const listed = options.map((option) => option.key).join("\n");
+ const glide = useGlide(`${active}
+${listed}`, { axis: "y", ...QUICK_EDGES });
+ useFlip(glide.box, listed, "[data-flip]");
+ const drop = (0, import_react34.useRef)(null);
+ useMorph(drop, listed);
+ if (draft) {
+ return (0, import_react34.createElement)(
+ "div",
+ { className: "bt-pane", style: { left: paneLeft } },
+ (0, import_react34.createElement)(CreateBot, {
+ roster,
+ actions,
+ start: draft,
+ canGoBack: draft.fromList,
+ onBack: draft.fromList ? () => {
+ setDraft(null);
+ setTimeout(() => inputRef.current?.focus());
+ } : close,
+ onCreated: created
+ })
+ );
+ }
+ const adminBot = admin === null ? void 0 : roster.byId[admin];
+ const recipients = adminBot ? [adminBot, ...pickedBots] : pickedBots;
+ const placeholder = recipients.length > 0 ? t("Message {name}", { name: recipients.map((bot) => bot.name).join(t(" and ")) }) : t("Message Bot");
+ const unpick = () => {
+ if (picked.length > 0) setPicked((current) => current.slice(0, -1));
+ else if (admin !== null) setAdmin(null);
+ };
+ return (0, import_react34.createElement)(
"div",
- { ref: root, className: "bt-us", key: focusId ?? "all" },
- (0, import_react37.createElement)(
+ {
+ className: "bt-pane",
+ style: { left: paneLeft },
+ "data-menu": menuOnly || void 0,
+ onMouseDown: menuOnly ? (event) => {
+ if (event.target === event.currentTarget) close();
+ } : void 0
+ },
+ (0, import_react34.createElement)(
"div",
- { className: "bt-us-top" },
- (0, import_react37.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }),
- view.scanning ? (0, import_react37.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null,
- picking ? (0, import_react37.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null
- ),
- error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
- body
+ { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 },
+ // The popover needs no title row: Escape or a press outside closes it.
+ menuOnly ? null : (0, import_react34.createElement)(
+ "div",
+ { className: "bt-to" },
+ (0, import_react34.createElement)("span", { className: "bt-to-label" }, t("To:")),
+ adminBot ? (0, import_react34.createElement)(
+ "span",
+ { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") },
+ (0, import_react34.createElement)(BotMark, { bot: adminBot, size: 16 }),
+ adminBot.name,
+ (0, import_react34.createElement)(AdminStar),
+ (0, import_react34.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
+ ) : null,
+ choosingAdmin ? (0, import_react34.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react34.createElement)(AdminStar)) : null,
+ pickedBots.map((bot) => (0, import_react34.createElement)(
+ "span",
+ { key: bot.id, className: "bt-token" },
+ (0, import_react34.createElement)(BotMark, { bot, size: 16 }),
+ bot.name,
+ (0, import_react34.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
+ )),
+ (0, import_react34.createElement)("input", {
+ ref: inputRef,
+ autoFocus: true,
+ value: query,
+ "aria-label": t("Recipient"),
+ placeholder: mode === "search" ? t("Search Bots…") : choosingAdmin ? t("Choose the group admin") : group ? t("Who to chat with…") : t("Start a chat with…"),
+ onChange: (event) => {
+ setQuery(event.target.value);
+ setCursor(0);
+ },
+ onKeyDown: (event) => {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setCursor(Math.min(active + 1, options.length - 1));
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setCursor(Math.max(active - 1, 0));
+ } else if (event.key === "Enter" && options[active]) {
+ event.preventDefault();
+ options[active].run();
+ } else if (event.key === "Backspace" && query === "") unpick();
+ }
+ }),
+ group && admin !== null ? (0, import_react34.createElement)("button", {
+ type: "button",
+ className: "bt-send bt-to-create",
+ disabled: busy || picked.length === 0,
+ title: picked.length === 0 ? t("Add at least one more Bot") : void 0,
+ onClick: createGroup
+ }, t("Create group chat")) : null,
+ (0, import_react34.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
+ ),
+ (0, import_react34.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react34.createElement)(
+ "div",
+ { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" },
+ (0, import_react34.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ options.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react34.createElement)(
+ "button",
+ {
+ key: option.key,
+ type: "button",
+ role: "option",
+ className: "bt-option",
+ "aria-selected": index === active,
+ "data-flip": option.key,
+ onMouseEnter: () => setCursor(index),
+ onClick: option.run
+ },
+ option.bot ? (0, import_react34.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react34.createElement)("span", { className: "bt-option-icon" }, option.icon),
+ // An action row's description sits under its title and is always there (#283): a long line
+ // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows.
+ // A Bot row's short hint stays beside the name and shows on the active row only.
+ option.bot || !option.hint ? (0, import_react34.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react34.createElement)("span", { className: "bt-option-copy" }, (0, import_react34.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react34.createElement)("span", { className: "bt-option-sub" }, option.hint)),
+ option.bot && option.hint && index === active ? (0, import_react34.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
+ ))
+ )),
+ menuOnly ? null : (0, import_react34.createElement)("div", { className: "bt-nc-spacer" }),
+ error && fix ? (0, import_react34.createElement)(
+ "div",
+ { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" },
+ (0, import_react34.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
+ (0, import_react34.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
+ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react34.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
+ void copyInvite(bot);
+ } }, t("Copy invite link for {name}", { name: bot.name })) : null,
+ (0, import_react34.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
+ ])),
+ fixNote ? (0, import_react34.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
+ inviteLink ? (0, import_react34.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
+ ) : error ? (0, import_react34.createElement)("div", { className: "bt-error" }, error) : null,
+ menuOnly ? null : (0, import_react34.createElement)(
+ "div",
+ { className: "bt-nc-compose" },
+ (0, import_react34.createElement)(
+ "div",
+ { className: "bt-nc-card" },
+ (0, import_react34.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react34.createElement)(PlusIcon)),
+ (0, import_react34.createElement)("textarea", {
+ rows: 1,
+ value: message,
+ placeholder,
+ "aria-label": placeholder,
+ onChange: (event) => setMessage(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ sendMessage();
+ }
+ }
+ }),
+ (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
+ )
+ )
+ )
);
}
-var USAGE_CSS = `
-.bt-us{--bt-us-ease:cubic-bezier(.22,1,.36,1);--bt-us-rest:color-mix(in srgb,var(--bt-ink) 30%,var(--bt-card));--bt-us-more:color-mix(in srgb,var(--bt-ink) 16%,var(--bt-card));display:flex;flex-direction:column;gap:14px;padding-bottom:4px}
-.bt-us-top{position:relative;z-index:4;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
-.bt-us-tabs{position:relative;display:inline-flex;flex:none;padding:3px;border-radius:999px;background:var(--bt-hover)}
-.bt-us-tabs button{position:relative;z-index:1;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 12px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:26px;white-space:nowrap;cursor:pointer;transition:color .15s ease}
-.bt-us-tabs button svg{flex:none;width:13px;height:13px;margin-left:-2px}
-.bt-us-pick{position:absolute;top:calc(100% + 8px);left:0;max-width:100%;box-sizing:border-box;padding:14px 16px 12px;border-radius:18px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 22px 50px -18px rgba(0,0,0,.32),0 2px 8px rgba(0,0,0,.05);transform-origin:24px 0;animation:bt-us-drop .2s var(--bt-us-ease)}
-.bt-us-pick-quick{display:flex;gap:6px;margin-bottom:12px}
-.bt-us-pick-quick button{height:26px;padding:0 11px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease}
-.bt-us-pick-quick button:hover{background:var(--bt-active);color:var(--bt-ink)}
-.bt-us-pick-quick button[aria-pressed=true]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:var(--bt-accent)}
-.bt-us-pick-months{display:flex;flex-wrap:wrap;gap:8px 28px}
-.bt-us-month{width:252px;animation:bt-us-fade .22s ease}
-.bt-us-month-head{position:relative;display:flex;align-items:center;justify-content:center;height:28px;margin-bottom:4px;font-size:13px;line-height:18px;font-weight:600}
-.bt-us-month-nav{position:absolute;top:0;width:28px;height:28px;padding:0;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease}
-.bt-us-month-nav[data-side=left]{left:0}
-.bt-us-month-nav[data-side=right]{right:0}
-.bt-us-month-nav:hover:not(:disabled){background:var(--bt-hover);color:var(--bt-ink)}
-.bt-us-month-nav:disabled{opacity:.3;cursor:default}
-.bt-us-weekdays,.bt-us-days{display:grid;grid-template-columns:repeat(7,36px)}
-.bt-us-weekdays span{height:24px;font-size:11px;line-height:24px;text-align:center;color:var(--bt-ink-3)}
-.bt-us-day{position:relative;height:36px;padding:0;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer}
-.bt-us-day::before{content:'';position:absolute;top:4px;bottom:4px;left:0;right:0;transition:background-color .12s ease}
-.bt-us-day>b{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-weight:400;transition:background-color .12s ease,color .12s ease}
-.bt-us-day:hover:not(:disabled)>b{background:var(--bt-hover)}
-.bt-us-day[data-today]>b{color:var(--bt-accent);font-weight:600}
-.bt-us-day[data-has]::after{content:'';position:absolute;z-index:2;left:50%;bottom:6px;width:3px;height:3px;margin-left:-1.5px;border-radius:50%;background:var(--bt-ink-3)}
-.bt-us-day[data-in]::before{background:color-mix(in srgb,var(--bt-accent) 13%,transparent)}
-.bt-us-day[data-in][data-l]::before{left:4px;border-radius:14px 0 0 14px}
-.bt-us-day[data-in][data-r]::before{right:4px;border-radius:0 14px 14px 0}
-.bt-us-day[data-in][data-l][data-r]::before{border-radius:14px}
-.bt-us-day[data-in][data-start]::before{left:50%;border-radius:0}
-.bt-us-day[data-in][data-end]::before{right:50%;border-radius:0}
-.bt-us-day[data-in][data-start][data-r]::before,.bt-us-day[data-in][data-end][data-l]::before,.bt-us-day[data-in][data-start][data-end]::before{display:none}
-.bt-us-day[data-start]>b,.bt-us-day[data-end]>b,.bt-us-day[data-start]:hover>b,.bt-us-day[data-end]:hover>b{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
-.bt-us-day[data-start][data-has]::after,.bt-us-day[data-end][data-has]::after{background:color-mix(in srgb,var(--bt-accent-ink,#fff) 75%,transparent)}
-.bt-us-day:disabled{color:var(--bt-ink-3);opacity:.45;cursor:default}
-.bt-us-pick-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:8px;padding-top:10px;border-top:1px solid var(--bt-line);font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap}
-.bt-us-pick-foot b{color:var(--bt-ink);font-weight:500}
-.bt-us-tabs button:hover,.bt-us-tabs button[aria-pressed=true]{color:var(--bt-ink)}
-.bt-us-tabs:not([data-glide]) button[aria-pressed=true]{background:var(--bt-card)}
-.bt-us-tabs>.bt-thumb{top:3px;bottom:3px;border-radius:999px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08),0 0 0 .5px var(--bt-line-2)}
-.bt-us-note{display:inline-flex;align-items:center;gap:8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
-.bt-us-note::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--bt-accent);animation:bt-us-pulse 1.4s ease-in-out infinite}
-.bt-us-sheet{display:flex;flex-direction:column;gap:4px}
-.bt-us-sheet>.bt-us-stats,.bt-us-sheet>.bt-us-card,.bt-us-sheet>.bt-us-pair>.bt-us-card{border-radius:0}
-.bt-us-sheet>:first-child{border-radius:16px 16px 0 0}
-.bt-us-sheet>:last-child{border-radius:0 0 16px 16px}
-.bt-us-stats{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards}
-.bt-us-stat{display:flex;flex-direction:column;justify-content:flex-end;gap:6px;min-width:0;padding:16px 18px}
-.bt-us-stat+.bt-us-stat{border-left:1px solid var(--bt-line)}
-.bt-us-stat-label{overflow:hidden;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;text-overflow:ellipsis}
-.bt-us-stat-value{display:flex;align-items:baseline;gap:6px;min-width:0;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums;white-space:nowrap}
-.bt-us-stat-main .bt-us-stat-value{font-size:34px;line-height:40px;letter-spacing:-.025em}
-.bt-us-stat-value small{font-size:13px;font-weight:400;letter-spacing:0;color:var(--bt-ink-3)}
-.bt-us-delta{align-self:center;margin-left:2px;padding:0 8px;border-radius:999px;background:var(--bt-tag-bg);color:var(--bt-ink-2);font-size:11px;line-height:20px;font-weight:500;letter-spacing:0}
-.bt-us-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 18px 16px;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 45ms)}
-.bt-us-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px}
-.bt-us-card-head h3{display:flex;align-items:baseline;gap:8px;min-width:0;margin:0;font-size:13px;line-height:18px;font-weight:600}
-.bt-us-card-head h3 span,.bt-us-head-note{font-size:12px;font-weight:400;color:var(--bt-ink-3)}
-.bt-us-chart-body{display:flex;flex-direction:column;gap:8px;padding-top:6px}
-.bt-us-plot{position:relative;height:200px;margin-left:40px}
-.bt-us-grid{position:absolute;left:0;right:0;border-top:1px dashed var(--bt-line-2);pointer-events:none}
-.bt-us-grid.bt-us-base{border-top:1px solid var(--bt-line-2)}
-.bt-us-grid span{position:absolute;right:calc(100% + 8px);top:-8px;font-size:10px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
-.bt-us-cols{position:absolute;inset:0;display:flex;align-items:flex-end}
-.bt-us-col{position:relative;flex:1 1 0;min-width:0;height:100%;display:flex;align-items:flex-end;justify-content:center}
-.bt-us-stack{display:flex;flex-direction:column-reverse;gap:2px;width:56%;min-width:4px;max-width:28px;transform-origin:50% 100%;animation:bt-us-grow .6s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 14ms);transition:height .45s var(--bt-us-ease),opacity .18s ease}
-.bt-us-stack>i{flex:1 1 0;min-height:2px;border-radius:5px;transition:flex-grow .45s var(--bt-us-ease)}
-.bt-us-plot[data-hover] .bt-us-col:not([data-on]) .bt-us-stack{opacity:.3}
-.bt-us-stub{width:4px;height:4px;margin-bottom:-2px;border-radius:50%;background:var(--bt-line-2)}
-.bt-us-ticks{display:flex;margin-left:40px;font-size:11px;line-height:14px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
-.bt-us-ticks span{flex:1 1 0;min-width:0;text-align:center;white-space:nowrap}
-.bt-us-ticks span[data-now]{color:var(--bt-ink);font-weight:500}
-.bt-us-tip{position:absolute;top:6px;z-index:3;min-width:160px;max-width:240px;padding:10px 12px;border-radius:12px;border:.5px solid var(--bt-line-2);background:color-mix(in srgb,var(--bt-card) 90%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 14px 34px -12px rgba(0,0,0,.28);font-size:12px;line-height:16px;pointer-events:none;animation:bt-us-pop .16s var(--bt-us-ease)}
-.bt-us-tip-title{color:var(--bt-ink-3)}
-.bt-us-tip-total{margin:2px 0 8px;font-size:15px;line-height:20px;font-weight:600;font-variant-numeric:tabular-nums}
-.bt-us-tip-total:last-child{margin-bottom:0}
-.bt-us-tip-row{display:flex;align-items:center;gap:8px;color:var(--bt-ink-2)}
-.bt-us-tip-row+.bt-us-tip-row{margin-top:5px}
-.bt-us-tip-row span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-us-tip-row b{font-weight:500;color:var(--bt-ink);font-variant-numeric:tabular-nums}
-.bt-us-legend{display:flex;flex-wrap:wrap;gap:6px}
-.bt-us-key{display:inline-flex;align-items:center;gap:6px;min-width:0;height:24px;padding:0 10px 0 8px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:24px}
-button.bt-us-key{cursor:pointer;transition:background-color .12s ease,color .12s ease}
-button.bt-us-key:hover{background:var(--bt-active);color:var(--bt-ink)}
-.bt-us-dot{flex:none;width:8px;height:8px;border-radius:50%}
-.bt-us-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:4px}
-.bt-us-rows{display:flex;flex-direction:column;margin:0 -8px}
-.bt-us-row{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;padding:8px;border:0;border-radius:12px;background:none;color:var(--bt-ink);font:inherit;text-align:left;animation:bt-us-in .4s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 90ms)}
-button.bt-us-row{cursor:pointer;transition:background-color .12s ease}
-button.bt-us-row:hover{background:var(--bt-hover)}
-.bt-us-row-glyph{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px}
-.bt-us-row-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px}
-.bt-us-row-line{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:13px;line-height:18px}
-.bt-us-row-name{flex:0 1 auto;min-width:0;overflow:hidden;font-weight:500;text-overflow:ellipsis;white-space:nowrap}
-.bt-us-row-sub{flex:0 1 auto;min-width:0;overflow:hidden;font-size:12px;color:var(--bt-ink-3);text-overflow:ellipsis;white-space:nowrap}
-.bt-us-track{display:block;height:4px;border-radius:999px;background:var(--bt-hover);overflow:hidden}
-.bt-us-track>span{display:block;height:100%;border-radius:inherit;transform-origin:0 50%;animation:bt-us-fill .7s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 140ms);transition:width .45s var(--bt-us-ease)}
-.bt-us-row-num{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:1px;min-width:52px;font-variant-numeric:tabular-nums}
-.bt-us-row-num b{font-size:13px;line-height:18px;font-weight:600}
-.bt-us-row-num small{font-size:11px;line-height:14px;color:var(--bt-ink-3)}
-.bt-us-split{height:1px;margin:6px 8px;background:var(--bt-line)}
-.bt-us-none{padding:18px 8px;font-size:12px;color:var(--bt-ink-3);text-align:center}
-.bt-us-glyph{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2)}
-.bt-us-model{display:inline-flex;align-items:center;justify-content:center;border-radius:30%;font-weight:600;line-height:1}
-.bt-us-profile{display:flex;align-items:center;gap:20px;padding:6px 4px 2px;animation:bt-us-in .45s var(--bt-us-ease) backwards}
-.bt-us-ring{position:relative;flex:none;width:96px;height:96px}
-.bt-us-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
-.bt-us-ring>svg circle{fill:none;stroke-width:4}
-.bt-us-ring-track{stroke:var(--bt-line)}
-.bt-us-ring-arc{stroke-linecap:round;transition:stroke-dasharray .6s var(--bt-us-ease);animation:bt-us-ring 1s var(--bt-us-ease) .1s backwards}
-.bt-us-ring-face{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
-.bt-us-profile-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
-.bt-us-profile-copy h3{margin:0;overflow:hidden;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;text-overflow:ellipsis;white-space:nowrap}
-.bt-us-profile-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
-.bt-us-profile-share{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
-.bt-us-heat{gap:8px}
-.bt-us-heat-months,.bt-us-heat-grid{display:grid;grid-template-columns:repeat(${HEAT_WEEKS},1fr);gap:3px}
-.bt-us-heat-months{height:14px;font-size:10px;line-height:14px;color:var(--bt-ink-3)}
-.bt-us-heat-months span{white-space:nowrap}
-.bt-us-heat-plot{position:relative}
-.bt-us-heat-grid{grid-template-rows:repeat(7,auto);grid-auto-flow:column}
-.bt-us-heat-grid .bt-us-cell[data-on]{outline:1.5px solid var(--bt-ink-2);outline-offset:1px}
-.bt-us-heat-tip{min-width:0;white-space:nowrap}
-.bt-us-cell{position:relative;aspect-ratio:1;border-radius:3px;background:var(--bt-hover);overflow:hidden}
-.bt-us-heat-grid .bt-us-cell{animation:bt-us-cell .5s var(--bt-us-ease) backwards;animation-delay:calc(var(--w,0) * 7ms + 120ms)}
-.bt-us-cell>i{position:absolute;inset:0}
-.bt-us-cell[data-today]{outline:1.5px solid var(--bt-ink-3);outline-offset:1px}
-.bt-us-cell[data-future]{visibility:hidden}
-.bt-us-heat-scale{display:flex;align-items:center;justify-content:flex-end;gap:4px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
-.bt-us-heat-scale .bt-us-cell{width:10px}
-.bt-us-skel{border-radius:16px;background:linear-gradient(90deg,var(--bt-hover) 0%,var(--bt-active) 50%,var(--bt-hover) 100%);background-size:200% 100%;animation:bt-us-shine 1.4s ease-in-out infinite}
-.bt-us-skel-sheet>.bt-us-skel:first-child{border-radius:16px 16px 0 0}
-.bt-us-skel-sheet>.bt-us-skel:not(:first-child){border-radius:0}
-.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:first-child{border-radius:0 0 0 16px}
-.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:last-child{border-radius:0 0 16px 0}
-.bt-us-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:72px 16px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
-.bt-us-empty b{margin-top:6px;font-size:15px;font-weight:600;color:var(--bt-ink)}
-@keyframes bt-us-in{from{opacity:0;transform:translateY(6px)}}
-@keyframes bt-us-grow{from{transform:scaleY(0)}}
-@keyframes bt-us-fill{from{transform:scaleX(0)}}
-@keyframes bt-us-pop{from{opacity:0;transform:translateY(3px) scale(.97)}}
-@keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}}
-@keyframes bt-us-fade{from{opacity:0}}
-@keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}}
-@keyframes bt-us-ring{from{stroke-dasharray:0 ${Math.ceil(RING2)}}}
-@keyframes bt-us-pulse{50%{opacity:.25}}
-@keyframes bt-us-shine{from{background-position:200% 0}to{background-position:-200% 0}}
-@media (prefers-reduced-motion:reduce){.bt-us *,.bt-us *::before{animation:none!important;transition:none!important}}
-`;
-// src/client/connectors.js
-var import_react38 = require("react");
-var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new";
-var COMPOSIO_KEY_URL = "https://platform.composio.dev/";
-var FEISHU_WEBHOOK_URL = "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot";
-var APPS = [
- ["gmail", "Gmail"],
- ["googlecalendar", "Google Calendar"],
- ["googledrive", "Google Drive"],
- ["googledocs", "Google Docs"],
- ["googlesheets", "Google Sheets"],
- ["outlook", "Outlook"],
- ["slack", "Slack"],
- ["microsoft_teams", "Microsoft Teams"],
- ["discord", "Discord"],
- ["telegram", "Telegram"],
- ["whatsapp", "WhatsApp"],
- ["notion", "Notion"],
- ["linear", "Linear"],
- ["jira", "Jira"],
- ["asana", "Asana"],
- ["trello", "Trello"],
- ["todoist", "Todoist"],
- ["airtable", "Airtable"],
- ["dropbox", "Dropbox"],
- ["hubspot", "HubSpot"],
- ["salesforce", "Salesforce"],
- ["stripe", "Stripe"],
- ["zoom", "Zoom"],
- ["calendly", "Calendly"],
- ["figma", "Figma"],
- ["twitter", "X (Twitter)"],
- ["youtube", "YouTube"]
-];
-var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug;
-var GitHubMark = () => (0, import_react38.createElement)(
- "svg",
- { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true },
- (0, import_react38.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" })
-);
-var FeishuMark = () => (0, import_react38.createElement)(
- "svg",
- { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
- (0, import_react38.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }),
- (0, import_react38.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }),
- (0, import_react38.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" })
-);
-var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark };
-var ABOUT = {
- github: () => t("Repositories, issues, and pull requests"),
- composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key"),
- feishu: () => t("Posts into one Feishu group through a custom bot webhook")
-};
-function statusText2(connector) {
- if (connector.state === "connecting") return t("Connecting…");
- if (connector.state === "error") return t("Could not connect: {error}", { error: connector.error ?? "" });
- if (connector.state === "ready" && connector.kind === "channel") {
- return connector.mode === "app" ? t("Connected: app, over the long connection") : t("Connected: custom bot webhook");
+// src/client/exchange.js
+var import_react35 = require("react");
+var SHEET_EXIT_MS = 220;
+function exchangeText(text2) {
+ if (typeof text2 !== "string") return "";
+ const CLOSE = "";
+ let body = text2;
+ for (let previous; previous !== body; ) {
+ previous = body;
+ const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body;
+ body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : ""));
}
- if (connector.state === "ready" && connector.apps?.length > 0) return t("Connected: {apps}", { apps: connector.apps.map(appName).join(", ") });
- if (connector.state === "ready") return connector.account ? t("Connected as {account}", { account: connector.account }) : t("Connected");
- return ABOUT[connector.id]?.() ?? "";
+ return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim();
}
-function CredentialField({ credential, value, onChange, onEnter, autoFocus }) {
- const label = t(credential.label);
- if (credential.masked) return (0, import_react38.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus });
- return (0, import_react38.createElement)(
- "label",
- { className: "bt-q-field" },
- (0, import_react38.createElement)("input", {
- className: "bt-input",
- type: "text",
- value,
- "aria-label": label,
- placeholder: label,
- spellCheck: false,
- autoFocus,
- onChange: (event) => onChange(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Enter") onEnter?.();
- }
- })
+function ExchangeLine({ entry, roster, actions }) {
+ const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" };
+ return (0, import_react35.createElement)(
+ "div",
+ { className: "bt-group-msg" },
+ (0, import_react35.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
+ (0, import_react35.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react35.createElement)("span", { className: "bt-lead" }, (0, import_react35.createElement)(BotMark, { bot, size: 22 })) })
);
}
-function AllowedSenders({ connector, actions, run, busy }) {
- const [openId, setOpenId] = (0, import_react38.useState)("");
- const allowed = connector.allowed ?? [];
- const refused = connector.inbound?.refusedSenders ?? [];
- const allow = (id) => void run(actions.connectorAllow(connector.id, id));
- return (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-allowed" },
- (0, import_react38.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")),
- allowed.length === 0 && refused.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null,
- allowed.map((entry) => (0, import_react38.createElement)(
- "div",
- { key: entry.openId, className: "bt-connector-person" },
- (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block"))
- )),
- refused.map((entry) => (0, import_react38.createElement)(
- "div",
- { key: entry.openId, className: "bt-connector-person" },
- (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow"))
- )),
- (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-form-row" },
- (0, import_react38.createElement)("input", {
- className: "bt-input",
- type: "text",
- value: openId,
- spellCheck: false,
- "aria-label": t("Feishu open_id"),
- placeholder: t("Paste an open_id to allow"),
- onChange: (event) => setOpenId(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Enter" && openId.trim() !== "") {
- allow(openId.trim());
- setOpenId("");
- }
- }
- }),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => {
- allow(openId.trim());
- setOpenId("");
- } }, t("Allow"))
+function ExchangeDialog({ pair, roster, actions }) {
+ const [entries2, setEntries] = (0, import_react35.useState)(null);
+ const [leaving, setLeaving] = (0, import_react35.useState)(false);
+ const close = () => {
+ if (leaving) return;
+ setLeaving(true);
+ setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS);
+ };
+ useEscape(close);
+ (0, import_react35.useEffect)(() => {
+ let stop = false;
+ let timer;
+ let seen = null;
+ const load = async () => {
+ const result = await actions.exchange(pair[0], pair[1]);
+ if (stop) return;
+ const key = JSON.stringify(result ?? []);
+ if (key !== seen) {
+ seen = key;
+ setEntries(result ?? []);
+ }
+ timer = setTimeout(load, document.hidden ? 8e3 : 2e3);
+ };
+ void load();
+ return () => {
+ stop = true;
+ clearTimeout(timer);
+ };
+ }, [pair[0], pair[1]]);
+ const left = roster.byId[pair[0]];
+ const right = roster.byId[pair[1]];
+ let lastDay = 0;
+ const paneLeft = usePaneLeft();
+ return (0, import_react35.createElement)(
+ "div",
+ { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 },
+ (0, import_react35.createElement)(
+ "div",
+ { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") },
+ (0, import_react35.createElement)("div", { className: "bt-head" }, (0, import_react35.createElement)(
+ "span",
+ { className: "bt-pill", style: { cursor: "default" } },
+ left ? (0, import_react35.createElement)(BotMark, { bot: left, size: 22 }) : null,
+ left?.name ?? "?",
+ (0, import_react35.createElement)("span", { className: "bt-swap" }, "⇄"),
+ right ? (0, import_react35.createElement)(BotMark, { bot: right, size: 22 }) : null,
+ right?.name ?? "?"
+ )),
+ (0, import_react35.createElement)(
+ "div",
+ { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") },
+ entries2 === null ? (0, import_react35.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
+ const showDay = entry.time - lastDay > 30 * 60 * 1e3;
+ lastDay = entry.time;
+ return (0, import_react35.createElement)(
+ import_react35.Fragment,
+ { key: entry.id },
+ showDay ? (0, import_react35.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
+ (0, import_react35.createElement)(ExchangeLine, { entry, roster, actions })
+ );
+ })
+ ),
+ (0, import_react35.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react35.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
)
);
}
-var APP_STATUS = { ready: () => t("Ready"), "sign-in": () => t("Needs sign-in") };
-function AppChip({ name, status, onRemove }) {
- const label = APP_STATUS[status]?.();
- return (0, import_react38.createElement)(
- "button",
- { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove },
- name,
- label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null
- );
-}
-function AppPicker({ apps, onChange, search, statuses = {} }) {
- const [query, setQuery] = (0, import_react38.useState)("");
- const [found, setFound] = (0, import_react38.useState)(null);
- const [failed, setFailed] = (0, import_react38.useState)(null);
- const names = (0, import_react38.useRef)(new Map(APPS));
- const asked = (0, import_react38.useRef)(0);
- (0, import_react38.useEffect)(() => {
- const text2 = query.trim();
- if (text2 === "" || search === void 0) {
- setFound(null);
- setFailed(null);
- return void 0;
- }
- const ask = ++asked.current;
- const timer = setTimeout(() => {
- search(text2).then((list) => {
- if (ask !== asked.current) return;
- for (const app of list) names.current.set(app.slug, app.name);
- setFound(list);
- setFailed(null);
- }, (failure) => {
- if (ask !== asked.current) return;
- setFound(null);
- setFailed(failure?.message ?? String(failure));
+
+// src/client/voice.js
+var import_react36 = require("react");
+var VOICE_LABELS = {
+ listening: "Listening",
+ thinking: "Thinking",
+ speaking: "Speaking",
+ paused: "Paused",
+ setup: "Voice input needs its speech model",
+ unsupported: "Voice chat is not available in this browser"
+};
+var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" };
+function useMicLevel(enabled) {
+ const [level, setLevel] = (0, import_react36.useState)(0);
+ (0, import_react36.useEffect)(() => {
+ if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0;
+ let stream = null;
+ let audio = null;
+ let frame = 0;
+ let stopped = false;
+ void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => {
+ if (stopped) {
+ media.getTracks().forEach((track) => track.stop());
+ return;
+ }
+ stream = media;
+ audio = new AudioContext();
+ const analyser = audio.createAnalyser();
+ analyser.fftSize = 256;
+ audio.createMediaStreamSource(media).connect(analyser);
+ const data = new Uint8Array(analyser.frequencyBinCount);
+ let smooth = 0;
+ const tick = () => {
+ analyser.getByteTimeDomainData(data);
+ let sum = 0;
+ for (const value of data) sum += ((value - 128) / 128) ** 2;
+ smooth = smooth * 0.8 + Math.min(1, Math.sqrt(sum / data.length) * 4) * 0.2;
+ setLevel(Math.round(smooth * 100) / 100);
+ frame = requestAnimationFrame(tick);
+ };
+ tick();
+ }).catch(() => {
+ });
+ return () => {
+ stopped = true;
+ cancelAnimationFrame(frame);
+ stream?.getTracks().forEach((track) => track.stop());
+ void audio?.close().catch(() => {
});
- }, 250);
- return () => clearTimeout(timer);
- }, [query, search]);
- const nameOf = (slug) => names.current.get(slug) ?? appName(slug);
- const picked = new Set(apps);
- const needle = query.trim().toLowerCase();
- const local = APPS.filter(([slug, name]) => needle === "" || slug.includes(needle) || name.toLowerCase().includes(needle)).map(([slug, name]) => ({ slug, name }));
- const results = (found ?? local).filter((app) => !picked.has(app.slug));
- return (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-apps" },
- apps.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react38.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null,
- (0, import_react38.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }),
- failed ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null,
- (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react38.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => {
- names.current.set(app.slug, app.name);
- onChange([...apps, app.slug]);
- } }, app.name)))
- );
-}
-function AppStatusList({ apps, statuses }) {
- if (apps.length === 0 || Object.keys(statuses).length === 0) return null;
- return (0, import_react38.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react38.createElement)(
- "li",
- { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" },
- (0, import_react38.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)),
- (0, import_react38.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "")
- )));
+ setLevel(0);
+ };
+ }, [enabled]);
+ return level;
}
-function ConnectorRow({ connector, roster, actions }) {
- const [form, setForm] = (0, import_react38.useState)(false);
- const [bots, setBots] = (0, import_react38.useState)(false);
- const [token, setToken] = (0, import_react38.useState)("");
- const [apps, setApps] = (0, import_react38.useState)(() => connector.apps ?? []);
- const [picking, setPicking] = (0, import_react38.useState)(false);
- const [busy, setBusy] = (0, import_react38.useState)(false);
- const [error, setError] = (0, import_react38.useState)(null);
- const [message, setMessage] = (0, import_react38.useState)("");
- const run = (task, after) => {
- setBusy(true);
- setError(null);
- return task.then(() => {
- after?.();
- }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false));
+function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }) {
+ const bot = roster.byId[sessionId];
+ const room2 = roster.roomsById[sessionId];
+ const name = bot?.name ?? room2?.name ?? t("Bot");
+ const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
+ const lastReply = useSessions((list) => {
+ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0;
+ return entry ? `${outline.length}|${entry.response ?? ""}` : "0|";
+ });
+ const [speech] = (0, import_react36.useState)(() => actions.speech());
+ const [phase, setPhase] = (0, import_react36.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
+ const [catalog, setCatalog] = (0, import_react36.useState)(null);
+ const [heard, setHeard] = (0, import_react36.useState)("");
+ const [said, setSaid] = (0, import_react36.useState)("");
+ const [note, setNote] = (0, import_react36.useState)("");
+ const [serviceLevel, setServiceLevel] = (0, import_react36.useState)(0);
+ const [cycle, setCycle] = (0, import_react36.useState)(0);
+ const waiting = (0, import_react36.useRef)(null);
+ const reply = (0, import_react36.useRef)(lastReply);
+ reply.current = lastReply;
+ const turn = (0, import_react36.useRef)(0);
+ const paneLeft = usePaneLeft();
+ const close = () => {
+ turn.current += 1;
+ window.speechSynthesis?.cancel();
+ actions.closeVoice();
};
- const gateway = connector.id === "composio";
- const sender = connector.kind === "sender";
- const channel = connector.kind === "channel";
- const modes = connector.modes ?? [];
- const [mode, setMode] = (0, import_react38.useState)(() => connector.mode ?? modes[0]?.id);
- const [values, setValues] = (0, import_react38.useState)({});
- const chosen = modes.find((entry) => entry.id === mode) ?? modes[0];
- const field = (name) => values[name] ?? "";
- const setField = (name, value) => setValues((current) => ({ ...current, [name]: value }));
- const filled = chosen !== void 0 && chosen.credentials.every((credential) => field(credential.name).trim() !== "");
- const [statuses, setStatuses] = (0, import_react38.useState)({});
- const appsKey = (connector.apps ?? []).join(",");
- (0, import_react38.useEffect)(() => {
- if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) {
- setStatuses({});
- return void 0;
- }
+ useEscape(close);
+ const submit = (text2) => {
+ setHeard(text2);
+ setPhase("thinking");
+ waiting.current = reply.current.split("|")[0];
+ void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening"));
+ };
+ const dictation = useDictation((text2, final) => {
+ setHeard(text2);
+ if (!final) return;
+ dictation.stop();
+ submit(text2);
+ });
+ (0, import_react36.useEffect)(() => {
+ if (!note) return void 0;
+ const timer = setTimeout(() => setNote(""), 2600);
+ return () => clearTimeout(timer);
+ }, [note]);
+ (0, import_react36.useEffect)(() => {
+ if (!speech) return void 0;
let live = true;
- actions.connectorAppStatus(connector.id).then((value) => {
- if (live) setStatuses(value ?? {});
+ speechCatalog(speech).then(({ catalog: next, usable }) => {
+ if (!live) return;
+ setCatalog(next);
+ setPhase(usable ? "listening" : "setup");
}, () => {
- if (live) setStatuses({});
+ if (live) setPhase("setup");
});
return () => {
live = false;
};
- }, [gateway, connector.state, appsKey, picking]);
- const search = (0, import_react38.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]);
- const connect = () => {
- if (busy) return;
- if (channel) {
- if (chosen === void 0 || !filled) return;
- const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)]));
- void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => {
- setValues({});
- setForm(false);
- });
- return;
- }
- if (token.trim() === "") return;
- if (gateway && apps.length === 0) {
- setError(t("Pick at least one app"));
- return;
- }
- void run(actions.connectorConnect(connector.id, token, gateway ? apps : void 0), () => {
- setToken("");
- setForm(false);
- });
- };
- const saveApps = () => {
- if (busy) return;
- if (apps.length === 0) {
- setError(t("Pick at least one app"));
+ }, [speech]);
+ const listen = phase === "listening";
+ (0, import_react36.useEffect)(() => {
+ if (speech || !listen || dictation.listening) return void 0;
+ const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => {
+ } }), 250);
+ return () => clearTimeout(timer);
+ }, [listen, dictation.listening]);
+ (0, import_react36.useEffect)(() => {
+ if (phase !== "listening") dictation.stop();
+ }, [phase]);
+ (0, import_react36.useEffect)(() => {
+ if (!speech || !catalog || !listen) return void 0;
+ const own = turn.current;
+ let capture = null;
+ let frame = 0;
+ let stopped = false;
+ let smooth = 0;
+ const detect = turnDetector();
+ const limit = Math.max(1, catalog.maxDurationSeconds - 1) * 1e3;
+ const finish = async (current) => {
+ setPhase("transcribing");
+ try {
+ const wave = await current.finish(catalog.maxDurationSeconds);
+ const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : "";
+ if (turn.current !== own) return;
+ if (text2) submit(text2);
+ else setPhase("listening");
+ } catch (error) {
+ if (turn.current !== own) return;
+ setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
+ setPhase("listening");
+ }
+ };
+ openCapture().then((opened) => {
+ if (stopped) {
+ opened.cancel();
+ return;
+ }
+ capture = opened;
+ const began = performance.now();
+ const tick = () => {
+ const now = performance.now();
+ const level2 = capture.level();
+ smooth = smooth * 0.8 + level2 * 0.2;
+ setServiceLevel(Math.round(smooth * 100) / 100);
+ const state = detect(level2, now);
+ if (state === "done" || state === "speech" && now - began >= limit) {
+ const current = capture;
+ capture = null;
+ setServiceLevel(0);
+ void finish(current);
+ return;
+ }
+ if (state === "waiting" && now - began >= limit) {
+ setCycle((value) => value + 1);
+ return;
+ }
+ frame = requestAnimationFrame(tick);
+ };
+ tick();
+ }, (error) => {
+ if (stopped) return;
+ setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
+ setPhase("paused");
+ });
+ return () => {
+ stopped = true;
+ cancelAnimationFrame(frame);
+ capture?.cancel();
+ setServiceLevel(0);
+ };
+ }, [speech, catalog, listen, cycle]);
+ (0, import_react36.useEffect)(() => {
+ if (phase !== "thinking" || running || waiting.current === null) return;
+ const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
+ if (count === waiting.current || !text2.trim()) return;
+ waiting.current = null;
+ const spoken = speechOf(text2, botNames(roster));
+ setSaid(spoken);
+ setHeard("");
+ if (!window.speechSynthesis || !spoken) {
+ setPhase("listening");
return;
}
- void run(actions.connectorApps(connector.id, apps), () => setPicking(false));
+ const utterance = new SpeechSynthesisUtterance(spoken);
+ utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
+ utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
+ utterance.onerror = utterance.onend;
+ setPhase("speaking");
+ window.speechSynthesis.cancel();
+ window.speechSynthesis.speak(utterance);
+ }, [phase, running, lastReply]);
+ (0, import_react36.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
+ const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
+ const level = speech ? serviceLevel : micLevel;
+ const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
+ const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
+ const togglePause = () => {
+ if (phase === "paused") {
+ setPhase("listening");
+ return;
+ }
+ turn.current += 1;
+ window.speechSynthesis?.cancel();
+ setPhase("paused");
};
- const on = connector.state !== "off";
- const canPost = sender || channel && connector.mode === "webhook";
- const perBot = !sender && (!channel || connector.mode === "app");
- const Mark = MARKS[connector.id];
- const row = (0, import_react38.useRef)(null);
- useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`);
- return (0, import_react38.createElement)(
+ const openSetup = phase === "setup" ? actions.voiceSetup() : null;
+ const caption = phase === "speaking" ? said : heard;
+ const shownNote = note || dictation.note;
+ return (0, import_react36.createElement)(
"div",
- { ref: row, className: "bt-connector", "data-state": connector.state },
- (0, import_react38.createElement)(
+ { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
+ (0, import_react36.createElement)(
"div",
- { className: "bt-connector-head" },
- (0, import_react38.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react38.createElement)(Mark) : null),
- (0, import_react38.createElement)(
- "span",
- { className: "bt-connector-text" },
- (0, import_react38.createElement)("span", { className: "bt-connector-name" }, connector.name),
- (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector))
+ { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
+ (0, import_react36.createElement)(
+ "div",
+ { className: "bt-voice-orb", style: { "--bt-level": level } },
+ (0, import_react36.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
+ (0, import_react36.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
+ bot ? (0, import_react36.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react36.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
),
- (0, import_react38.createElement)(
- "span",
- { className: "bt-connector-actions" },
- connector.state === "error" ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null,
- on && gateway && !picking ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
- setApps(connector.apps ?? []);
- setPicking(true);
- } }, t("Choose apps")) : null,
- on && perBot ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null,
- on ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect"))
- )
- ),
- on && gateway && !picking ? (0, import_react38.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null,
- on && perBot ? (0, import_react38.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null,
- on && bots ? (0, import_react38.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null,
- on && channel && connector.mode === "app" ? (0, import_react38.createElement)(AllowedSenders, { connector, actions, run, busy }) : null,
- on && canPost ? (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-form" },
- (0, import_react38.createElement)("label", { className: "bt-q-field" }, (0, import_react38.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })),
- (0, import_react38.createElement)(
+ (0, import_react36.createElement)("div", { className: "bt-voice-name" }, name),
+ (0, import_react36.createElement)(
"div",
- { className: "bt-connector-form-row" },
- (0, import_react38.createElement)("span", { className: "bt-connector-link" }),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send"))
- )
- ) : null,
- !on && form ? (0, import_react38.createElement)(
+ { className: "bt-voice-status", role: "status" },
+ phase === "thinking" ? (0, import_react36.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react36.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
+ ),
+ (0, import_react36.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
+ openSetup ? (0, import_react36.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
+ close();
+ openSetup();
+ } }, t("Set up voice input")) : null,
+ shownNote ? (0, import_react36.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
+ ),
+ (0, import_react36.createElement)(
"div",
- { className: "bt-connector-form" },
- channel ? (0, import_react38.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react38.createElement)("button", {
- key: entry.id,
+ { className: "bt-voice-bar" },
+ (0, import_react36.createElement)("button", {
type: "button",
- className: "bt-state",
- "aria-pressed": entry.id === chosen?.id,
- onClick: () => {
- setMode(entry.id);
- setValues({});
- setError(null);
- }
- }, t(entry.label)))) : null,
- channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, {
- key: credential.name,
- credential,
- value: field(credential.name),
- onChange: (value) => setField(credential.name, value),
- onEnter: connect,
- autoFocus: index === 0
- })) : (0, import_react38.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react38.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })),
- gateway ? (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null,
- (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-form-row" },
- (0, import_react38.createElement)(
- "a",
- { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" },
- channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub")
- ),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
- setForm(false);
- setToken("");
- setValues({});
- setError(null);
- } }, t("Cancel")),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect"))
- )
- ) : null,
- on && gateway && picking ? (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-form" },
- (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }),
- (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")),
- (0, import_react38.createElement)(
- "div",
- { className: "bt-connector-form-row" },
- (0, import_react38.createElement)("span", { className: "bt-connector-link" }),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
- setPicking(false);
- setError(null);
- } }, t("Cancel")),
- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save"))
- )
- ) : null,
- error ? (0, import_react38.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null
- );
-}
-function ConnectorsPage({ roster, actions }) {
- const list = roster?.connectors ?? [];
- return (0, import_react38.createElement)(
- "section",
- { className: "bt-set-section" },
- (0, import_react38.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react38.createElement)("div", { className: "bt-set-row" }, (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react38.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions })))
+ className: "bt-voice-btn",
+ "aria-pressed": phase === "paused",
+ disabled: blocked,
+ "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
+ onClick: togglePause
+ }, (0, import_react36.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react36.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
+ (0, import_react36.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react36.createElement)(CloseIcon))
+ )
);
}
-// src/client/feedback.js
-var import_react39 = require("react");
-var FeedbackIcon = () => (0, import_react39.createElement)(
- "svg",
- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
- (0, import_react39.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }),
- (0, import_react39.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" })
-);
-function download(name, text2) {
- const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" }));
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = name;
- anchor.click();
- setTimeout(() => URL.revokeObjectURL(url), 1e3);
+// src/client/workflows.js
+var import_react37 = require("react");
+var READ_MS = 8e3;
+var POLL_MS6 = 2e3;
+var WORKFLOW_PLUGIN_HINT = "The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.";
+var COLORS = { Planner: "#8b5cf6", Writer: "#f0509a", Coder: "#1fbf6a", Reviewer: "#f5a524", Tester: "#14b8a6", Deployer: "#2f7cf6" };
+function workflowCallMissing(failure) {
+ const code = failure && typeof failure === "object" ? String(failure.code ?? "") : "";
+ const message = typeof failure === "string" ? failure : String(failure?.message ?? "");
+ return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message);
}
-var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false);
-function whenText2(time) {
- const date = new Date(time);
- const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString();
- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" });
- return today ? clock4 : `${date.toLocaleDateString(dateLocale(), { month: "short", day: "numeric" })} ${clock4}`;
+function workflowCallError(failure, fallback = "Could not read workflows") {
+ if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT;
+ if (typeof failure === "string" && failure.trim()) return failure.trim();
+ const message = typeof failure?.message === "string" ? failure.message.trim() : "";
+ return message || fallback;
}
-function entrySummary(entry) {
- if (entry.source === "turn") return failureLabel(entry.code, entry.text);
- return entry.text;
+function settleWorkflowRead(value, fallback = "Could not read workflows") {
+ if (!value || value.ok === false || !Array.isArray(value.workflows)) {
+ const raw = typeof value?.error === "string" && value.error.trim() ? value.error : "";
+ const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : raw || fallback;
+ return { phase: "error", error, pack: null };
+ }
+ const templates = Array.isArray(value.templates) ? value.templates : [];
+ const workflows = value.workflows;
+ const pack = { ...value, templates, workflows };
+ if (templates.length === 0 && workflows.length === 0) return { phase: "empty", error: "", pack };
+ return { phase: "ready", error: "", pack };
}
-var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "This window" };
-function FeedbackPage({ actions, run, Section, Row: Row4 }) {
- const [what, setWhat] = (0, import_react39.useState)("");
- const [report, setReport] = (0, import_react39.useState)(null);
- const [preview, setPreview] = (0, import_react39.useState)(false);
- const [note, setNote] = (0, import_react39.useState)("");
- const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => {
- setReport(next);
- return next;
- });
- const load = () => fetchReport().then(() => void 0);
- (0, import_react39.useEffect)(() => {
- void run(load());
- }, []);
- const copyReport = () => run(fetchReport().then(async (next) => {
- const copied = await copy(next.markdown);
- setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it."));
- if (!copied) setPreview(true);
- }));
- const [issueLink, setIssueLink] = (0, import_react39.useState)(null);
- const openIssue = () => {
- const win = window.open("", "_blank");
- if (win !== null) win.opener = null;
- run(fetchReport().then(async (next) => {
- const copied = await copy(next.markdown);
- const { url, inline } = issueUrl(next.issuesUrl, { what, diagnostics: next.markdown });
- if (win !== null) {
- win.location.href = url;
- setIssueLink(null);
- setNote(inline ? t("Opened a new GitHub issue with the diagnostics filled in. Check it before you submit.") : copied ? t("Opened a new GitHub issue. The diagnostics are long, so they are on the clipboard: paste them into the issue.") : t("Opened a new GitHub issue. Copy the diagnostics below into it."));
- } else {
- setIssueLink(url);
- setNote(t("The browser blocked the new window."));
- }
- if (!inline && !copied) setPreview(true);
- }));
- };
- const downloadLog = () => run(actions.diagnosticsLog().then((text2) => {
- download("ds-bot-diagnostics.log", text2 || "No entries.\n");
- setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it."));
- }));
- const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log."))));
- const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : [];
- const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time));
- const total = (report?.total ?? 0) + browserErrors.length;
- const shown = entries2.slice(0, 8);
- return [
- (0, import_react39.createElement)(
- Section,
- { key: "report", title: t("Report a problem") },
- (0, import_react39.createElement)(
- "div",
- { className: "bt-set-row bt-set-paste bt-fb-what" },
- (0, import_react39.createElement)(
- "label",
- { className: "bt-set-copy", htmlFor: "bt-fb-what" },
- (0, import_react39.createElement)("span", null, t("What happened?")),
- (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue."))
- ),
- (0, import_react39.createElement)("textarea", { id: "bt-fb-what", className: "bt-input bt-input-area", value: what, maxLength: WHAT_MAX, placeholder: t("For example: I asked Writer in the group chat and it showed an error."), onChange: (event) => setWhat(event.target.value) })
- ),
- (0, import_react39.createElement)(
- Row4,
- { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") },
- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")),
- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy"))
- ),
- preview ? (0, import_react39.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react39.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null,
- (0, import_react39.createElement)(
- Row4,
- { label: t("GitHub issue"), hint: t("Opens a new issue in the FlyBot repository with all of this filled in. Nothing is sent until you submit it there.") },
- (0, import_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub"))
- )
- ),
- note ? (0, import_react39.createElement)(
- "div",
- { key: "note", className: "bt-note bt-fb-note", role: "status" },
- note,
- issueLink ? [" ", (0, import_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null
- ) : null,
- (0, import_react39.createElement)(
- Section,
- { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") },
- shown.length === 0 ? (0, import_react39.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react39.createElement)(
- "div",
- { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text },
- (0, import_react39.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }),
- (0, import_react39.createElement)(
- "div",
- { className: "bt-set-copy" },
- (0, import_react39.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)),
- (0, import_react39.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · "))
- ),
- entry.code ? (0, import_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null
- )),
- (0, import_react39.createElement)(
- Row4,
- { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") },
- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")),
- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear"))
- )
- )
- ];
-}
-var TOAST_MS = 6e3;
-function ErrorToast({ useUi, actions }) {
- const toast = useUi((state) => state.toast);
- const [leaving, setLeaving] = (0, import_react39.useState)(false);
- (0, import_react39.useEffect)(() => {
- if (!toast) return void 0;
- setLeaving(false);
- const fade = setTimeout(() => setLeaving(true), TOAST_MS);
- const gone = setTimeout(() => actions.dismissToast(toast.token), TOAST_MS + 200);
- return () => {
- clearTimeout(fade);
- clearTimeout(gone);
+function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (value) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve(value);
};
- }, [toast?.token]);
- if (!toast) return null;
- const info = toast.tone === "info";
- return (0, import_react39.createElement)(
- "div",
- { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 },
- (0, import_react39.createElement)("span", { className: "bt-toast-text" }, toast.text),
- info ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => {
- actions.dismissToast(toast.token);
- actions.openSettings("feedback");
- } }, t("Details")),
- (0, import_react39.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react39.createElement)(CloseIcon))
- );
+ const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
+ Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
+ });
}
-var FEEDBACK_CSS = `
-.bt-fb-what{cursor:default}
-.bt-fb-what:hover,.bt-fb-entry:hover,.bt-fb-preview:hover{background:none}
-.bt-fb-what .bt-input-area{min-height:80px}
-.bt-fb-preview{cursor:default;padding:0}
-.bt-fb-preview pre{margin:0;width:100%;max-height:240px;overflow:auto;padding:10px 12px;box-sizing:border-box;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink-2);white-space:pre-wrap;word-break:break-word;outline:none}
-.bt-fb-primary{height:28px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);padding:0 14px;font-size:12px;cursor:pointer;transition:filter .12s ease}
-.bt-fb-primary:hover{filter:brightness(1.08)}
-.bt-fb-primary:active{filter:brightness(.94)}
-.bt-fb-note{margin:-8px 4px 0}
-.bt-fb-entry{cursor:default;align-items:flex-start}
-.bt-fb-dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:#e02135}
-.bt-fb-dot[data-level=warn]{background:#e3a008}
-.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
-.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
-.bt-event-error{color:#e02135}
-.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
-.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
-.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
-.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
-.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
-.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
-.bt-toast-close{flex:none}
-@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
-@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
-`;
-
-// src/client/about.js
-var import_react40 = require("react");
-var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count);
-function botHint(update) {
- if (update.phase === "installing") return t("Updating FlyBot core…");
- if (update.phase === "installed") return t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest });
- if (update.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(update.error ?? "") });
- if (update.available) return t("FlyBot core {version} is available", { version: update.latest });
- if (update.enabled === false) return t("Update checks are off.");
- if (update.source === "path") return t("Installed from a local folder; update it there.");
- if (update.source === "git" && update.build?.branch !== void 0) return branchHint(update);
- if (update.source === "git" && update.build?.pinned) return t("Pinned to commit {commit}. Updates come from the maintainer: reinstall FlyBot to get them.", { commit: update.build.commit });
- if (!update.checkedAt) return null;
- if (!update.latest && update.source !== "registry") return t("Updates are not offered for this install.");
- const checked = t("Checked {time}", { time: whenText2(update.checkedAt) });
- return `${update.latest ? t("Up to date") : t("Could not check for updates.")} · ${checked}`;
+function moveNode(nodes, id, x, y) {
+ return nodes.map((node) => node.id === id ? { ...node, x, y } : node);
}
-function branchHint({ build, behind, checkedAt }) {
- const where = [build.branch, build.commit].filter(Boolean).join(" ");
- if (build.commit === void 0 || !checkedAt) return t("Installed from GitHub ({where}). Updates come from the maintainer: reinstall FlyBot to get them.", { where });
- const checked = t("Checked {time}", { time: whenText2(checkedAt) });
- const state = behind === null ? t("Could not compare with {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : behind === 0 ? t("Up to date with {branch}.", { branch: build.branch }) : behind === 1 ? t("1 commit behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : t("{count} commits behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { count: behind, branch: build.branch });
- return `${state} · ${checked}`;
+function connectNodes(edges, from, to) {
+ if (!from || !to || from === to) return edges;
+ if (edges.some((edge) => edge.from === from && edge.to === to)) return edges;
+ return edges.concat([{ from, to }]);
}
-var versionLine = (update) => [t("Version {version}", { version: update.current }), [update.build?.branch, update.build?.commit].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
-function dshHint(dsh, shown) {
- if (dsh.phase === "installing") return t("Updating DSH…");
- if (dsh.phase === "installed") return t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified });
- if (dsh.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(dsh.error ?? "") });
- if (dsh.available === false && dsh.blockedBy) return t("Update FlyBot core {version} first.", { version: dsh.blockedBy });
- if (shown) return t("DSH {version} is available", { version: dsh.verified });
- return t("Version {version}", { version: dsh.current });
+function dropBot(nodes, bot, x, y) {
+ const ids = new Set(nodes.map((node) => node.id));
+ let n = nodes.length + 1;
+ while (ids.has(`n${n}`)) n += 1;
+ return nodes.concat([{ id: `n${n}`, name: bot.name, botId: bot.id, role: bot.role ?? "", x, y }]);
}
-var linkProps = (href) => ({ className: "bt-soft bt-soft-sm bt-about-link", href, target: "_blank", rel: "noopener noreferrer" });
-function AboutPage({ useUpdate, actions, run, Section, Row: Row4 }) {
- const update = useUpdate((value) => value) ?? {};
- const [checking, setChecking] = (0, import_react40.useState)(false);
- const [stars, setStars] = (0, import_react40.useState)(null);
- (0, import_react40.useEffect)(() => {
- let live = true;
- actions.repoStars().then((count) => {
- if (live && Number.isInteger(count)) setStars(count);
- }, () => {
- });
- return () => {
- live = false;
- };
- }, []);
- const check = () => {
- setChecking(true);
- void run(actions.checkUpdate()).then(() => {
- setChecking(false);
- });
+function removeNode(nodes, edges, id) {
+ return {
+ nodes: nodes.filter((node) => node.id !== id),
+ edges: edges.filter((edge) => edge.from !== id && edge.to !== id)
};
- const phase = update.phase ?? "idle";
- const dsh = update.dsh;
- const dshPhase = dsh?.phase ?? "idle";
- const dshNew = showsDshRow(dsh, update.app);
- const dshShown = dshNew && dsh.available;
- const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react40.createElement)(
- "button",
- { type: "button", className: "bt-fb-primary", disabled: phase === "installing", onClick: () => run(actions.installUpdate()) },
- phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")
- ) : (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: checking || update.enabled === false, onClick: check }, checking ? t("Checking…") : t("Check for updates"));
- const dshControl = !dshNew || dshPhase === "installed" ? null : dsh.kind === "app" ? dsh.available ? (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.openAppUpdates(dsh.verified)) }, t("Open updates")) : null : dsh.available || dshPhase !== "idle" ? (0, import_react40.createElement)(
- "button",
- { type: "button", className: "bt-fb-primary", disabled: dshPhase === "installing", onClick: () => run(actions.installDshUpdate()) },
- dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")
- ) : null;
- const repo = update.repoUrl;
- return [
- (0, import_react40.createElement)(
- "div",
- { key: "hero", className: "bt-about-hero" },
- (0, import_react40.createElement)("span", { className: "bt-about-mark" }, (0, import_react40.createElement)(FlyBotMark, { size: 52 })),
- (0, import_react40.createElement)(
- "div",
- { className: "bt-about-copy" },
- (0, import_react40.createElement)("span", { className: "bt-about-name" }, t("FlyBot")),
- update.current ? (0, import_react40.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null
- ),
- repo ? (0, import_react40.createElement)(
- "a",
- {
- className: "bt-star",
- href: repo,
- target: "_blank",
- rel: "noopener noreferrer",
- title: t("Star FlyBot on GitHub"),
- "aria-label": stars === null ? t("Star FlyBot on GitHub") : `${t("Star FlyBot on GitHub")} · ${t("{count} stars", { count: stars })}`
- },
- (0, import_react40.createElement)("span", { className: "bt-star-main" }, (0, import_react40.createElement)("span", { className: "bt-star-icon" }, (0, import_react40.createElement)(StarLineIcon)), "Star"),
- stars === null ? null : (0, import_react40.createElement)("span", { className: "bt-star-count" }, starCount(stars))
- ) : null
- ),
- repo ? (0, import_react40.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If FlyBot helps you, a star on GitHub helps more people find it.")) : null,
- (0, import_react40.createElement)(
- Section,
- { key: "updates", title: t("Updates") },
- (0, import_react40.createElement)(Row4, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null),
- dsh?.current ? (0, import_react40.createElement)(Row4, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null
- ),
- (0, import_react40.createElement)(
- Section,
- { key: "links", title: t("Links") },
- repo ? (0, import_react40.createElement)(Row4, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react40.createElement)("a", linkProps(repo), (0, import_react40.createElement)(GitHubMark), t("Open"))) : null,
- update.notesUrl ? (0, import_react40.createElement)(Row4, { label: t("Release notes") }, (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null,
- (0, import_react40.createElement)(Row4, { label: t("Report a problem") }, (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open")))
- )
- ];
}
-var ABOUT_CSS = `
-.bt-about-hero{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
-.bt-about-mark{flex:none;display:inline-flex}
-.bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
-.bt-about-name{font-size:16px;line-height:22px;font-weight:600}
-.bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums}
-.bt-about-ask{margin:-10px 4px 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
-.bt-star{flex:none;display:inline-flex;align-items:stretch;height:30px;border-radius:999px;border:1px solid var(--bt-line-2,var(--bt-line));overflow:hidden;color:var(--bt-ink);text-decoration:none;font-size:12px;line-height:28px;transition:transform .12s ease,border-color .15s ease}
-.bt-star:hover{border-color:color-mix(in srgb,#e3a008 55%,var(--bt-line))}
-.bt-star:active{transform:scale(.96)}
-.bt-star-main{display:inline-flex;align-items:center;gap:6px;padding:0 12px 0 10px;background:var(--bt-hover);font-weight:500}
-.bt-star-icon{display:inline-flex;color:var(--bt-ink-2);transition:color .15s ease}
-.bt-star-icon path{transition:fill .15s ease}
-.bt-star:hover .bt-star-icon{color:#e3a008;animation:bt-star-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
-.bt-star:hover .bt-star-icon path{fill:currentColor}
-.bt-star-count{display:inline-flex;align-items:center;padding:0 11px;border-left:1px solid var(--bt-line-2,var(--bt-line));color:var(--bt-ink-2);font-variant-numeric:tabular-nums;animation:bt-star-count-in ${SPRING_POP.duration}ms ${SPRING_POP.easing},bt-veil-in .2s ease-out}
-@keyframes bt-star-pop{from{scale:.6;rotate:-30deg}}
-@keyframes bt-star-count-in{from{translate:-6px 0}}
-.bt-about-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none}
-.bt-about-link svg{width:14px;height:14px}
-.bt-fb-primary:disabled{opacity:.6;cursor:default;filter:none}
-@media (prefers-reduced-motion:reduce){.bt-star:hover .bt-star-icon,.bt-star-count{animation:none}}
-`;
-
-// src/client/settings.js
-var import_react41 = require("react");
-var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
-var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
-var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
-function SetSection({ title, children }) {
- return (0, import_react41.createElement)("section", { className: "bt-set-section" }, (0, import_react41.createElement)("h3", null, title), (0, import_react41.createElement)("div", { className: "bt-set-card" }, children));
+function chainOf(item) {
+ if (typeof item?.chain === "string" && item.chain) return item.chain;
+ if (!Array.isArray(item?.layers) || !Array.isArray(item?.nodes)) return "";
+ const name = (id) => item.nodes.find((node) => node.id === id)?.name ?? id;
+ return item.layers.map((layer) => layer.map(name).join(" ‖ ")).join(" → ");
}
-function SetRow({ label, hint, children }) {
- return (0, import_react41.createElement)(
- "div",
- { className: "bt-set-row" },
- (0, import_react41.createElement)("div", { className: "bt-set-copy" }, (0, import_react41.createElement)("span", null, label), hint ? (0, import_react41.createElement)("span", { className: "bt-set-hint" }, hint) : null),
- children ? (0, import_react41.createElement)("div", { className: "bt-set-control" }, children) : null
- );
+function openedGraph(pack, opened) {
+ if (!pack || !opened) return null;
+ if (opened.source === "own") return (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
+ return (pack.templates ?? []).find((item) => item.id === opened.id) ?? (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
}
-var NOTIFY_KINDS = [
- ["approval", "Waiting for approval", "Once more if it still waits after 15 min."],
- ["finished", "Finished"],
- ["failed", "Failed"]
-];
-function GeneralPage({ roster, actions, run }) {
- const prefs = roster.prefs ?? {};
- const themes = allThemes();
- const current = themes[prefs.theme] ? prefs.theme : "deepseek";
- const [custom, setCustom] = (0, import_react41.useState)(prefs.accent ?? "#4d6bfe");
- const timer = (0, import_react41.useRef)(0);
- (0, import_react41.useEffect)(() => () => clearTimeout(timer.current), []);
- const pickCustom = (value) => {
- setCustom(value);
- clearTimeout(timer.current);
- timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350);
- };
- return [
- (0, import_react41.createElement)(
- SetSection,
- { key: "look", title: t("Appearance") },
- (0, import_react41.createElement)(
- SetRow,
- { label: t("Theme"), hint: t("Colors for the whole team, on every device.") },
- (0, import_react41.createElement)(
- "select",
- { className: "bt-select", "aria-label": t("Theme"), value: current, onChange: (event) => run(actions.setPrefs({ theme: event.target.value })) },
- Object.entries(themes).map(([id, theme]) => (0, import_react41.createElement)("option", { key: id, value: id }, t(theme.label)))
- )
- ),
- (0, import_react41.createElement)(
- SetRow,
- { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") },
- (0, import_react41.createElement)(
- "div",
- { className: "bt-swatches" },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-swatch bt-swatch-default", title: t("Theme accent"), "aria-label": t("Theme accent"), "aria-pressed": !prefs.accent, onClick: () => run(actions.setPrefs({ accent: null })) }),
- ACCENT_INKS.map((ink) => (0, import_react41.createElement)("button", {
- key: ink,
- type: "button",
- className: "bt-swatch",
- title: t(capitalize2(ink)),
- "aria-label": t(capitalize2(ink)),
- "aria-pressed": prefs.accent === INKS[ink][0],
- style: { background: INKS[ink][0] },
- onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] }))
- })),
- (0, import_react41.createElement)(
- "label",
- { className: "bt-swatch bt-swatch-custom", title: t("Custom accent"), "data-on": Boolean(prefs.accent) && !ACCENT_INKS.some((ink) => INKS[ink][0] === prefs.accent) },
- (0, import_react41.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) })
- )
- )
- )
- ),
- // One switch for every system notification (#353), then one per kind (#469).
- (0, import_react41.createElement)(
- SetSection,
- { key: "notify", title: t("Notifications") },
- (0, import_react41.createElement)(SwitchRow, {
- label: t("In other chats"),
- hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."),
- on: prefs.notifications !== false,
- onChange: (on) => run(actions.setPrefs({ notifications: on }))
- }),
- NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react41.createElement)(SwitchRow, {
- key: kind,
- label: t(label),
- hint: hint ? t(hint) : void 0,
- disabled: prefs.notifications === false,
- on: prefs.notify?.[kind] !== false,
- onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } }))
- }))
- )
- ];
+function statusLabel2(status) {
+ if (status === "running") return t("Running");
+ if (status === "done") return t("Done");
+ if (status === "failed") return t("Failed");
+ if (status === "idle") return t("Idle");
+ if (status === "skipped") return t("Skipped");
+ return t("Queued");
}
-function MainBotsSection({ roster, actions, run }) {
- const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean);
- const [first, ...others] = mains;
- const own = roster.bots.filter((bot) => !bot.remote);
- const candidates = own.filter((bot) => !isMainOf(roster, bot.id));
- return (0, import_react41.createElement)(
- SetSection,
- { title: t("Main Bot") },
- (0, import_react41.createElement)(
- SetRow,
- {
- label: t("Main Bot"),
- hint: first ? t("First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.", { name: first.name }) : void 0
- },
- (0, import_react41.createElement)("select", {
- className: "bt-select",
- "aria-label": t("Main Bot"),
- value: first?.id ?? "",
- onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {}))
- }, first ? null : (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
- ),
- others.map((bot) => (0, import_react41.createElement)(
- SetRow,
- { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t("Move to first")),
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role"))
- )),
- candidates.length > 0 ? (0, import_react41.createElement)(
- SetRow,
- { label: t("Add a Main Bot"), hint: t("Main Bots can create and change Bots, and run every group chat.") },
- (0, import_react41.createElement)("select", {
- className: "bt-select",
- "aria-label": t("Add a Main Bot"),
- value: "",
- onChange: (event) => {
- if (event.target.value) void run(actions.setMain(event.target.value, true));
- }
- }, (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
- ) : null
- );
+function hitNode(event) {
+ const direct = event.target?.dataset?.node ? event.target : null;
+ const nested = event.target?.closest?.("[data-node]");
+ return (nested ?? direct)?.dataset?.node || "";
}
-var MOTIONS = [["quiet", "Quiet"], ["normal", "Normal"], ["lively", "Lively"]];
-function MotionSection({ roster, actions, run }) {
- const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal";
- return (0, import_react41.createElement)(
- SetSection,
- { title: t("Animation") },
- (0, import_react41.createElement)(
- SetRow,
- { label: t("Bot animation") },
- (0, import_react41.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react41.createElement)("button", {
- key: id,
- type: "button",
- "aria-pressed": motion === id,
- onClick: () => {
- if (motion !== id) run(actions.setPrefs({ motion: id }));
- }
- }, t(label))))
- )
- );
+var WIRE_SPAN = 480;
+var NODE_WIRE_OUT = 56;
+var CARD_HALF = 84;
+var CARD_H = 72;
+var FRAME_SIDE = 108;
+var FRAME_LABEL = 18;
+var FRAME_FOOT = 10;
+var MIN_FIT = 0.5;
+function wirePath(from, to) {
+ const x1 = from.x + WIRE_SPAN;
+ const y1 = from.y + NODE_WIRE_OUT;
+ const x2 = to.x + WIRE_SPAN;
+ const y2 = to.y;
+ const mid = (y1 + y2) / 2;
+ return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`;
}
-function BotsPage({ roster, actions, run }) {
- const [creating, setCreating] = (0, import_react41.useState)(false);
- return [
- (0, import_react41.createElement)(MainBotsSection, { key: "main", roster, actions, run }),
- (0, import_react41.createElement)(MotionSection, { key: "motion", roster, actions, run }),
- creating ? (0, import_react41.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react41.createElement)(
- "div",
- { key: "new", className: "bt-actions" },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("New Bot"))
- ),
- (0, import_react41.createElement)(
- SetSection,
- { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) },
- roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react41.createElement)(
- "button",
- { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) },
- (0, import_react41.createElement)(
- "span",
- { className: "bt-set-bot" },
- (0, import_react41.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }),
- (0, import_react41.createElement)("span", { className: "bt-row-title" }, bot.name),
- bot.role ? (0, import_react41.createElement)("span", { className: "bt-tag" }, bot.role) : null,
- isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null,
- bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
- ),
- (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon))
- ))
- )
- ];
+function fitScale(nodes, width) {
+ const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
+ if (!(width > 0) || reach === 0) return 1;
+ return Math.min(1, Math.max(MIN_FIT, width / 2 / reach));
}
-function GroupsPage({ roster, actions, run }) {
- const [creating, setCreating] = (0, import_react41.useState)(false);
- return [
- creating ? (0, import_react41.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react41.createElement)(
- "div",
- { key: "new", className: "bt-actions" },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", disabled: roster.bots.length < 2, title: roster.bots.length < 2 ? t("A group chat needs at least two Bots") : void 0, onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("Create group chat"))
- ),
- (0, import_react41.createElement)(
- SetSection,
- { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) },
- roster.rooms.length === 0 ? (0, import_react41.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => {
- const admin = roster.byId[room2.admin];
- return (0, import_react41.createElement)(
- "button",
- { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) },
- (0, import_react41.createElement)(
- "span",
- { className: "bt-set-bot" },
- (0, import_react41.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
- (0, import_react41.createElement)("span", { className: "bt-row-title" }, room2.name),
- (0, import_react41.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })),
- admin ? (0, import_react41.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react41.createElement)(AdminStar), admin.name) : null,
- room2.hidden ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
- ),
- (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon))
- );
- })
- )
- ];
+function fitLayout(nodes, width) {
+ const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
+ const scale = fitScale(nodes, width);
+ const spare = width > 0 && reach > 0 ? reach * scale - width / 2 : 0;
+ const pad2 = spare > 1e-6 ? Math.ceil(spare) : 0;
+ const centre = width > 0 ? pad2 + width / 2 : 0;
+ return {
+ reach,
+ scale,
+ pad: pad2,
+ left: centre - reach * scale,
+ right: centre + reach * scale,
+ scrollWidth: Math.max(width + pad2, centre + reach * scale)
+ };
}
-function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) {
- const [error, setError] = (0, import_react41.useState)("");
- const close = (0, import_react41.useCallback)(() => actions.closeSettings(), []);
- useEscape(close);
- const run = (promise) => Promise.resolve(promise).then(() => {
- setError("");
- return true;
- }).catch((failure) => {
- setError(failure?.message ?? String(failure));
- return false;
- });
- const bot = page === "bots" && id ? roster.byId[id] : void 0;
- const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
- const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` }));
- const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]);
- const shown = pages.some(([key]) => key === page) ? page : "general";
- const pluginPage = extra.find(({ key }) => key === shown)?.entry;
- const usageFocus = shown === "usage" && id ? id : null;
- const label = t(pages.find(([key]) => key === shown)[1]);
- const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null;
- const nav = useGlide(shown, { axis: "y" });
- const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`;
- const depth = bot || room2 ? 1 : 0;
- const last = (0, import_react41.useRef)({ key: pageKey, depth, move: "up" });
- if (last.current.key !== pageKey) {
- last.current = { key: pageKey, depth, move: depth > last.current.depth ? "in" : depth < last.current.depth ? "out" : "up" };
- }
- const [usageOpened, setUsageOpened] = (0, import_react41.useState)(shown === "usage");
- if (shown === "usage" && !usageOpened) setUsageOpened(true);
- const errorLine = error ? (0, import_react41.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
- return (0, import_react41.createElement)(
- "div",
- { className: "bt-settings-layer", "data-leaving": leaving || void 0 },
- (0, import_react41.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
- (0, import_react41.createElement)(
- "div",
- { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 },
- (0, import_react41.createElement)(
- "nav",
- { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") },
- (0, import_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }),
- pages.map(([key, text2]) => (0, import_react41.createElement)("button", {
- key,
- type: "button",
- className: "bt-settings-item",
- "aria-current": shown === key ? "page" : void 0,
- onClick: () => actions.openSettings(key)
- }, (0, import_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))),
- // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot
- // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421).
- (0, import_react41.createElement)("button", {
- type: "button",
- className: "bt-settings-item bt-settings-shell",
- title: t("Opens DeepSeek Harness's own settings (models, providers, permissions) in a separate window"),
- "aria-label": t("Harness settings, opens DeepSeek Harness's own settings window"),
- onClick: () => {
- close();
- openHarnessSettings();
- }
- }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗"))
- ),
- (0, import_react41.createElement)(
- "div",
- { className: "bt-settings-main" },
- (0, import_react41.createElement)(
- "div",
- { className: "bt-settings-head" },
- back ? (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react41.createElement)(ChevronLeftIcon)) : null,
- (0, import_react41.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label)
- ),
- shown === "usage" ? null : (0, import_react41.createElement)(
- "div",
- { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move },
- errorLine,
- shown === "general" ? (0, import_react41.createElement)(GeneralPage, { roster, actions, run }) : null,
- bot ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null,
- shown === "bots" && !bot ? (0, import_react41.createElement)(BotsPage, { roster, actions, run }) : null,
- room2 ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null,
- shown === "groups" && !room2 ? (0, import_react41.createElement)(GroupsPage, { roster, actions, run }) : null,
- shown === "projects" ? (0, import_react41.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null,
- shown === "connectors" ? (0, import_react41.createElement)(ConnectorsPage, { roster, actions }) : null,
- shown === "workflows" ? (0, import_react41.createElement)(WorkflowsPage, { call: call2, roster }) : null,
- pluginPage ? (0, import_react41.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
- shown === "capabilities" ? (0, import_react41.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
- shown === "schedules" ? (0, import_react41.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
- shown === "secrets" ? (0, import_react41.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
- shown === "feedback" ? (0, import_react41.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null,
- shown === "about" ? (0, import_react41.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null
- ),
- usageOpened ? (0, import_react41.createElement)(
- "div",
- { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true },
- shown === "usage" ? errorLine : null,
- (0, import_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus })
- ) : null
- ),
- (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon))
- )
- );
+function measureCanvasWidth(el) {
+ const width = el?.clientWidth;
+ return Number.isFinite(width) && width > 0 ? width : 0;
}
-var openProjectBoard = (id) => {
- if (typeof window !== "undefined") window.location.hash = `#project=${id}`;
-};
-var projectMembers = (roster) => [
- ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })),
- ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
-];
-function ProjectsList({ projects, roster, call: call4, reload }) {
- const members = projectMembers(roster);
- const bind = async (choice, project) => {
- const [kind, id] = String(choice ?? "").split(":");
- if (!kind || !id) return;
- await call4("project-bind", { kind, id, projectId: project.id });
- await reload?.();
- };
- const unbind = async (member) => {
- await call4("project-bind", { kind: member.kind, id: member.id, projectId: null });
- await reload?.();
+function GraphCanvas({ graph, editing, roster, onCommit, width: knownWidth = 0 }) {
+ const gesture = (0, import_react37.useRef)(null);
+ const canvasRef = (0, import_react37.useRef)(null);
+ const [measuredWidth, setCanvasWidth] = (0, import_react37.useState)(0);
+ const nodes = graph.nodes ?? [];
+ const edges = graph.edges ?? [];
+ const status = graph.run?.nodeStatus ?? null;
+ const locals = (roster?.bots ?? []).filter((bot) => bot && bot.remote !== true && !nodes.some((node) => node.botId === bot.id));
+ const height = Math.max(420, ...nodes.map((node) => (node.y ?? 0) + 150), 0);
+ const canvasWidth = measuredWidth || knownWidth;
+ const { scale, pad: pad2 } = fitLayout(nodes, canvasWidth);
+ const drawnHeight = Math.round(height * scale);
+ (0, import_react37.useEffect)(() => {
+ const el = canvasRef.current;
+ const measure = () => {
+ const width = measureCanvasWidth(el);
+ if (width > 0 && Math.abs(width - canvasWidth) > 0.5) setCanvasWidth(width);
+ };
+ measure();
+ if (typeof ResizeObserver !== "function" || !el) return void 0;
+ const observer = new ResizeObserver(measure);
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [canvasWidth]);
+ const end = (event) => {
+ const current = gesture.current;
+ gesture.current = null;
+ if (!current || !editing) return;
+ if (current.kind === "move") {
+ const x = Math.round(current.x + (event.clientX - current.ox) / scale);
+ const y = Math.round(current.y + (event.clientY - current.oy) / scale);
+ onCommit?.({ nodes: moveNode(nodes, current.id, x, y), edges });
+ } else if (current.kind === "wire") {
+ const next = connectNodes(edges, current.from, hitNode(event));
+ if (next !== edges) onCommit?.({ nodes, edges: next });
+ } else if (current.kind === "add" && event.target?.dataset?.plane) {
+ const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX / scale) : 24;
+ const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY / scale) : 24;
+ onCommit?.({ nodes: dropBot(nodes, current.bot, x, y), edges });
+ }
};
- const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0);
- return [
- (0, import_react41.createElement)(
- SetSection,
- { key: "projects", title: t("Projects · {count}", { count: projects.length }) },
- projects.length === 0 ? (0, import_react41.createElement)("div", { className: "bt-set-row" }, (0, import_react41.createElement)("div", { className: "bt-set-copy" }, (0, import_react41.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react41.createElement)(
- SetRow,
- {
- key: project.id,
- label: project.name,
- hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir
- },
- (0, import_react41.createElement)(
+ return (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-canvas", ref: canvasRef, onPointerUp: end },
+ editing ? (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-palette" },
+ (0, import_react37.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")),
+ locals.map((bot) => (0, import_react37.createElement)("button", {
+ key: bot.id,
+ type: "button",
+ "data-bot": bot.id,
+ onPointerDown: (event) => {
+ event.preventDefault?.();
+ gesture.current = { kind: "add", bot };
+ }
+ }, bot.name))
+ ) : null,
+ // A node's system prompt is the run Bot's prompt (plugins/workflow
+ // nodeConfig). The sidebar panel has this field, so the Settings editor
+ // writes the same graph instead of only drawing it (#1393).
+ editing ? (0, import_react37.createElement)("div", { className: "bt-wf-prompts" }, nodes.map((node) => (0, import_react37.createElement)(
+ "label",
+ { key: node.id, className: "bt-wf-prompt" },
+ (0, import_react37.createElement)("span", null, `${node.name} · ${t("System prompt")}`),
+ (0, import_react37.createElement)("textarea", {
+ "data-prompt": node.id,
+ rows: 3,
+ defaultValue: node.prompt || "",
+ onPointerDown: (event) => event.stopPropagation?.(),
+ onBlur: (event) => {
+ const prompt = event.target?.value ?? "";
+ if (prompt === (node.prompt || "")) return;
+ onCommit?.({ nodes: nodes.map((item) => item.id === node.id ? { ...item, prompt } : item), edges });
+ }
+ })
+ ))) : null,
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-stage", style: pad2 > 0 ? { height: drawnHeight, paddingLeft: pad2 } : { height: drawnHeight } },
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } },
+ (0, import_react37.createElement)(
"div",
- { className: "bt-project-row-controls" },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
- (0, import_react41.createElement)(
- "select",
+ { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } },
+ (0, import_react37.createElement)(
+ "svg",
{
- className: "bt-select",
- "aria-label": t("Bind to {name}", { name: project.name }),
- value: "",
- onChange: (event) => {
- void bind(event.target.value, project);
- }
+ className: "bt-wf-wires",
+ "aria-hidden": true,
+ width: WIRE_SPAN * 2,
+ height,
+ viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`
},
- (0, import_react41.createElement)("option", { value: "" }, t("Bind a Bot or group")),
- members.map((member) => (0, import_react41.createElement)(
- "option",
- { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` },
- `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}`
- ))
- )
+ (0, import_react37.createElement)(
+ "defs",
+ null,
+ (0, import_react37.createElement)("marker", {
+ id: "bt-wf-arrow",
+ viewBox: "0 0 10 10",
+ refX: "8",
+ refY: "5",
+ markerWidth: "6",
+ markerHeight: "6",
+ orient: "auto"
+ }, (0, import_react37.createElement)("path", { d: "M0 0 10 5 0 10z" }))
+ ),
+ edges.map((edge) => {
+ const from = nodes.find((node) => node.id === edge.from);
+ const to = nodes.find((node) => node.id === edge.to);
+ if (!from || !to) return null;
+ return (0, import_react37.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" });
+ })
+ ),
+ (graph.layers ?? []).filter((layer) => layer.length > 1).map((layer) => {
+ const members = layer.map((id) => nodes.find((node) => node.id === id)).filter(Boolean);
+ if (members.length < 2) return null;
+ const left = Math.min(...members.map((node) => node.x ?? 0)) - FRAME_SIDE;
+ const right = Math.max(...members.map((node) => node.x ?? 0)) + FRAME_SIDE;
+ const top = Math.min(...members.map((node) => node.y ?? 0)) - FRAME_LABEL;
+ const bottom = Math.max(...members.map((node) => node.y ?? 0)) + CARD_H + FRAME_FOOT;
+ return (0, import_react37.createElement)("div", {
+ key: layer.join("+"),
+ className: "bt-wf-par",
+ style: { left, top, width: right - left, height: bottom - top }
+ }, (0, import_react37.createElement)("div", { className: "bt-wf-plabel" }, t("Parallel branch, then join")));
+ }),
+ nodes.map((node) => {
+ const state = status ? status[node.id] ?? "queued" : "idle";
+ return (0, import_react37.createElement)(
+ "div",
+ {
+ key: node.id,
+ "data-node": node.id,
+ className: `bt-wf-node ${state}${editing ? " editing" : ""}`,
+ style: { left: (node.x ?? 0) - CARD_HALF, top: node.y ?? 0 },
+ onPointerDown: (event) => {
+ if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return;
+ gesture.current = { kind: "move", id: node.id, ox: event.clientX, oy: event.clientY, x: node.x ?? 0, y: node.y ?? 0 };
+ }
+ },
+ editing ? (0, import_react37.createElement)("button", {
+ type: "button",
+ className: "bt-wf-port",
+ "data-port": "out",
+ "aria-label": node.name,
+ onPointerDown: (event) => {
+ event.preventDefault?.();
+ event.stopPropagation?.();
+ gesture.current = { kind: "wire", from: node.id };
+ }
+ }) : null,
+ editing ? (0, import_react37.createElement)("button", {
+ type: "button",
+ className: "bt-wf-remove",
+ "data-remove": node.id,
+ "aria-label": t("Remove"),
+ onClick: (event) => {
+ event.stopPropagation?.();
+ const next = removeNode(nodes, edges, node.id);
+ onCommit?.(next);
+ }
+ }, "×") : null,
+ (0, import_react37.createElement)("span", { className: `bt-wf-chip ${state}` }, statusLabel2(state)),
+ (0, import_react37.createElement)("span", { className: "bt-wf-av", style: { background: COLORS[node.name] ?? "#4d6bfe" } }, (node.name ?? "?").slice(0, 1)),
+ (0, import_react37.createElement)(
+ "span",
+ { className: "bt-wf-copy" },
+ (0, import_react37.createElement)("span", { className: "bt-wf-name" }, node.name),
+ (0, import_react37.createElement)("span", { className: "bt-wf-role" }, node.role ?? "")
+ )
+ );
+ })
)
- ))
- ),
- bound2.length === 0 ? null : (0, import_react41.createElement)(
- SetSection,
- { key: "bindings", title: t("Bindings") },
- bound2.map((member) => (0, import_react41.createElement)(
- SetRow,
- {
- key: `${member.kind}:${member.id}`,
- label: member.name,
- hint: member.kind === "group" ? t("Group") : t("Bot")
- },
- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
- void unbind(member);
- } }, t("Unbind"))
- ))
+ )
)
- ];
+ );
}
-function ProjectsPage({ roster, actions, run, call: call4 }) {
- const [state, setState] = (0, import_react41.useState)({ projects: [], error: null });
- const [name, setName] = (0, import_react41.useState)("");
- const [workDir, setWorkDir] = (0, import_react41.useState)(null);
- const reload = (0, import_react41.useCallback)(async () => {
- const listing = await call4("projects");
- setState({ projects: listing?.projects ?? [], error: null });
- }, [call4]);
- (0, import_react41.useEffect)(() => {
- void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) }));
- }, [reload]);
- const create = async () => {
- const trimmed3 = name.trim();
- if (trimmed3 === "") return;
- const body = { name: trimmed3 };
- if (workDir !== null && workDir !== "") body.workDir = workDir;
- try {
- await call4("project-create", body);
- setName("");
- setWorkDir(null);
- await reload();
- } catch (error) {
- setState({ projects: state.projects, error: error?.message ?? String(error) });
- }
- };
- return [
- state.error === null ? null : (0, import_react41.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
- (0, import_react41.createElement)(
- SetSection,
- { key: "new", title: t("New Project") },
- (0, import_react41.createElement)(
- SetRow,
- { label: t("Name") },
- (0, import_react41.createElement)("input", {
- className: "bt-input",
- "aria-label": t("Project name"),
- value: name,
- placeholder: t("e.g. Website"),
- onChange: (event) => setName(event.target.value)
- })
- ),
- (0, import_react41.createElement)(
- SetRow,
- { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") },
- (0, import_react41.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })
- ),
- (0, import_react41.createElement)(
- "div",
- { className: "bt-actions" },
- (0, import_react41.createElement)("button", {
- type: "button",
- className: "bt-primary bt-with-icon",
- disabled: name.trim() === "",
- onClick: () => {
- void create();
- }
- }, (0, import_react41.createElement)(PlusIcon), t("New Project"))
- )
+function Row2({ item, source, onOpen, onRename, onDelete }) {
+ return (0, import_react37.createElement)(
+ "button",
+ {
+ type: "button",
+ className: "bt-set-row",
+ "data-workflow": item.id,
+ "data-source": source,
+ onClick: () => onOpen?.(item)
+ },
+ (0, import_react37.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+ (0, import_react37.createElement)("span", null, item.name),
+ (0, import_react37.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "")
),
- // The list is rendered here rather than as a child element: it holds no state of its own,
- // and the page keeps one reload path for both halves.
- ...ProjectsList({ projects: state.projects, roster, call: call4, reload })
- ];
-}
-
-// src/client/context-menu.js
-var import_react42 = require("react");
-function ContextMenu({ menu, roster, actions, leaving = false }) {
- useEscape(() => actions.closeMenu());
- (0, import_react42.useEffect)(() => {
- const onDown = (event) => {
- if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu();
- };
- window.addEventListener("mousedown", onDown);
- return () => window.removeEventListener("mousedown", onDown);
- }, []);
- const structure = useSidebarStructure();
- const ref = (0, import_react42.useRef)(null);
- const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y });
- (0, import_react42.useLayoutEffect)(() => {
- const box = ref.current?.getBoundingClientRect();
- if (!box) return;
- setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
- }, [menu.x, menu.y]);
- if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react42.createElement)(AgentMenu, { menu, roster, actions, leaving });
- const bot = roster.byId[menu.id];
- const room2 = roster.roomsById[menu.id];
- const entry = bot ?? room2;
- if (entry === void 0) return null;
- const isMainBot = isMainOf(roster, menu.id);
- const isTile = roster.mainBotId === menu.id;
- const mainCount = (roster.mainBotIds ?? []).length;
- const groups = bot?.remote ? [
- [
- [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
- ],
- [
- [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)]
- ],
- pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]),
- [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]]
- ] : [
- [
- isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
- [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
- ],
- [
- // One way to edit a Bot (#385): its name is edited in Edit Bot too.
- bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null,
- room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null,
- room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null,
- bot ? [t("Duplicate"), DuplicateIcon, async () => {
- const copy2 = await actions.duplicateBot(bot.id);
- actions.openSession(copy2.id);
- }] : null,
- bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null,
- bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null
- ],
- bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [],
- [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]],
- [
- isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })],
- isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true]
- ]
- ];
- const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0);
- return (0, import_react42.createElement)(
- "div",
- { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 },
- shown.map((group, index) => (0, import_react42.createElement)(
- import_react42.Fragment,
- { key: index },
- index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
- group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", {
- key: label,
+ source === "own" ? (0, import_react37.createElement)(
+ "span",
+ { className: "bt-set-actions" },
+ (0, import_react37.createElement)("button", {
type: "button",
- role: "menuitem",
- className: danger ? "bt-danger" : void 0,
- onClick: () => {
- actions.closeMenu();
- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
+ className: "bt-set-action",
+ "aria-label": t("Rename"),
+ title: t("Rename"),
+ onClick: (event) => {
+ event.stopPropagation();
+ onRename?.(item);
}
- }, (0, import_react42.createElement)(Icon), label))
- ))
- );
-}
-function AgentMenu({ menu, roster, actions, leaving }) {
- const ref = (0, import_react42.useRef)(null);
- const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y });
- (0, import_react42.useLayoutEffect)(() => {
- const box = ref.current?.getBoundingClientRect();
- if (!box) return;
- setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
- }, [menu.x, menu.y]);
- const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId];
- if (agent === void 0) return null;
- const groups = menu.kind === "agent" ? [
- [
- [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })]
- ],
- [
- [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true]
- ]
- ] : [
- [
- [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)]
- ],
- [
- [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true]
- ]
- ];
- return (0, import_react42.createElement)(
- "div",
- { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 },
- groups.map((group, index) => (0, import_react42.createElement)(
- import_react42.Fragment,
- { key: index },
- index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
- group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", {
- key: label,
+ }, "✎"),
+ (0, import_react37.createElement)("button", {
type: "button",
- role: "menuitem",
- className: danger ? "bt-danger" : void 0,
- onClick: () => {
- actions.closeMenu();
- void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error));
+ className: "bt-set-action",
+ "aria-label": t("Delete"),
+ title: t("Delete"),
+ onClick: (event) => {
+ event.stopPropagation();
+ onDelete?.(item);
}
- }, (0, import_react42.createElement)(Icon), label))
- ))
+ }, "×")
+ ) : null,
+ (0, import_react37.createElement)("span", { className: "bt-chevron", "aria-hidden": true }, "›")
);
}
-
-// src/client/pane-width.js
-var DEFAULT_WIDTH = 520;
-var DETAILS_WIDTH = 380;
-var DETAILS_MAX = 520;
-var MIN_WIDTH = 360;
-var MIN_CHAT = 420;
-var MIN_DETAILS = 240;
-function panelWidthRange(view, viewport) {
- const room2 = Math.max(0, viewport - MIN_CHAT);
- if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)];
- const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2));
- return [low, Math.max(low, Math.min(DETAILS_MAX, room2))];
-}
-
-// src/client/storage.js
-var STORAGE = {
- unread: "ds-bot:unread",
- reactions: "ds-bot:reactions",
- seen: "ds-bot:seen",
- friendLines: "ds-bot:friend-lines",
- prefs: "ds-bot:prefs",
- surface: "ds-bot:surface",
- agentSessions: "ds-bot:agent-sessions",
- browserWidth: "ds-bot:browser-width",
- browsers: "ds-bot.browser.v2"
-};
-var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"];
-var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"];
-var LEGACY_THEMES = { grok: "mono" };
-function legacyKeys(key) {
- if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS;
- const name = key.slice(key.indexOf(":") + 1);
- return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`);
-}
-var migrateBrowsers = (storage) => {
- if (storage.getItem(STORAGE.browsers) !== null) {
- for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key);
- return;
- }
- for (const key of LEGACY_BROWSER_KEYS) {
- try {
- const value = storage.getItem(key);
- if (value === null) continue;
- const previous = JSON.parse(value) ?? {};
- const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => {
- const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" };
- return [botId, { tabs: [tab], active: tab.id }];
- }));
- storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
- for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
- return;
- } catch {
- }
- }
-};
-function migrateStorage(storage = globalThis.localStorage) {
- for (const key of Object.values(STORAGE)) {
- if (key === STORAGE.browsers) continue;
- for (const legacy of legacyKeys(key)) {
- try {
- const value = storage.getItem(legacy);
- if (value !== null && storage.getItem(key) === null) storage.setItem(key, value);
- storage.removeItem(legacy);
- } catch {
- }
- }
- }
- try {
- migrateBrowsers(storage);
- } catch {
- }
- try {
- const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null");
- if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) {
- storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] }));
- }
- } catch {
- }
+function alertCopy(error) {
+ if (error === WORKFLOW_PLUGIN_HINT) return t("The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.");
+ return error || t("Could not read workflows");
}
-
-// src/client/details-drawer.js
-var import_react43 = require("react");
-var TAB_LABELS = { details: "Details", browser: "Browser" };
-var hostOf = (url) => {
- try {
- return new URL(url).hostname.replace(/^www\./, "");
- } catch {
- return "";
- }
-};
-var shortUrl = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
-var EMPTY_TABS = { ids: [], active: null };
-var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" };
-var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]);
-function PermissionRow({ bot, roster, actions, run }) {
- const [pending, setPending] = (0, import_react43.useState)(null);
- const catalog = roster.permissions;
- const saved = bot.permission ?? catalog?.defaultPreset;
- (0, import_react43.useEffect)(() => {
- if (pending === saved) setPending(null);
- }, [pending, saved]);
- if (!catalog?.options?.length) return null;
- const current = pending ?? saved;
- const pick2 = (value) => {
- if (value === current && bot.permission !== void 0) return;
- setPending(value);
- void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null));
- };
- return (0, import_react43.createElement)(
+function WorkflowsView({ phase = "loading", pack = null, error = "", opened = null, editing = false, roster = null, canvasWidth = 0, onOpen, onBack, onEditing, onRetry, onUse, onCommit, onRename, onDelete }) {
+ const graph = phase === "ready" ? openedGraph(pack, opened) : null;
+ const templates = pack?.templates ?? [];
+ const templateIds = new Set(templates.map((item) => item.id));
+ const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id));
+ const showUse = opened?.source === "factory" || opened?.fromFactory !== void 0;
+ return (0, import_react37.createElement)(
"div",
- { className: "bt-drawer-permission" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Permissions")),
- (0, import_react43.createElement)(
- Segmented,
- { label: t("Permissions"), value: current },
- catalog.options.map((option) => (0, import_react43.createElement)("button", {
- key: option.value,
- type: "button",
- "aria-pressed": option.value === current,
- title: option.description,
- onClick: () => pick2(option.value),
- className: DANGER.has(option.value) ? "bt-perm-danger" : void 0
- }, PERMISSION_LABELS[option.value] ? t(PERMISSION_LABELS[option.value]) : option.name))
- )
+ { className: "bt-wf-page" },
+ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.")),
+ error ? (0, import_react37.createElement)(
+ "div",
+ { className: "bt-error", role: "alert" },
+ alertCopy(error),
+ phase === "error" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onRetry?.() }, t("Retry")) : null
+ ) : null,
+ phase === "loading" ? (0, import_react37.createElement)("p", { className: "bt-wf-status", role: "status" }, t("Reading workflows…")) : null,
+ phase === "empty" ? (0, import_react37.createElement)("p", { className: "bt-wf-status" }, t("No workflows yet.")) : null,
+ phase === "ready" && !graph ? (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-lists" },
+ (0, import_react37.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react37.createElement)("h3", null, t("Factory presets")),
+ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("Ready-made pipelines. Use one to start your own.")),
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-set-card" },
+ templates.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No workflows yet."))) : templates.map((item) => {
+ const live = (pack.workflows ?? []).find((workflow) => workflow.id === item.id);
+ const row = live ? { ...item, name: live.name, preview: live.preview || chainOf(live) || chainOf(item) } : item;
+ return (0, import_react37.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") });
+ })
+ )
+ ),
+ (0, import_react37.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react37.createElement)("h3", null, t("Yours")),
+ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")),
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-set-card" },
+ yours.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react37.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete }))
+ )
+ )
+ ) : null,
+ graph ? (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-editor" },
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-wf-actions" },
+ (0, import_react37.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")),
+ showUse ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null,
+ opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null
+ ),
+ (0, import_react37.createElement)("h3", { className: "bt-wf-title" }, graph.name),
+ graph.description ? (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null,
+ (graph.nodes ?? []).length === 0 ? (0, import_react37.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react37.createElement)(GraphCanvas, {
+ graph,
+ roster,
+ onCommit,
+ width: canvasWidth,
+ editing: opened?.source === "own" && editing === true
+ })
+ ) : null
);
}
-function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) {
- const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS);
- const pages = useBrowserPages((map) => map);
- (0, import_react43.useEffect)(() => {
- actions.browserEnsure?.(bot.id);
- }, [bot.id]);
- if (roster.browser !== true || !actions.browserShow) {
- return (0, import_react43.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app."));
- }
- if (state.ids.length === 0) {
- return (0, import_react43.createElement)(
- "div",
- { className: "bt-browser-empty-mark" },
- (0, import_react43.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }),
- (0, import_react43.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
- (0, import_react43.createElement)("div", { className: "bt-muted" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })),
- (0, import_react43.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser"))
- );
- }
- return (0, import_react43.createElement)(
+function RenameModal({ workflow, onClose, onConfirm }) {
+ const [name, setName] = (0, import_react37.useState)(workflow?.name ?? "");
+ return (0, import_react37.createElement)(
"div",
- { className: "bt-bcards" },
- state.ids.map((tabId, index) => {
- const page = pages[tabId] ?? {};
- const label = page.title || shortUrl(page.url) || t("New tab");
- return (0, import_react43.createElement)(
+ { className: "bt-modal-mask", onClick: (event) => {
+ if (event.target === event.currentTarget) onClose?.();
+ } },
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") },
+ (0, import_react37.createElement)("h3", null, t("Rename workflow")),
+ (0, import_react37.createElement)(
+ "label",
+ { className: "bt-field" },
+ t("Name"),
+ (0, import_react37.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true })
+ ),
+ (0, import_react37.createElement)(
"div",
- { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } },
- (0, import_react43.createElement)(
- "button",
- { type: "button", className: "bt-bcard-shot", "aria-label": t("Open {title}", { title: label }), title: page.url, onClick: () => actions.browserPick(sessionId, tabId) },
- page.excerpt ? (0, import_react43.createElement)(
- "span",
- { className: "bt-bcard-preview" },
- (0, import_react43.createElement)("span", { className: "bt-bcard-ptitle" }, label),
- (0, import_react43.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt)
- ) : (0, import_react43.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react43.createElement)(GlobeIcon), (0, import_react43.createElement)("span", null, label))
- ),
- (0, import_react43.createElement)("button", {
- type: "button",
- className: "bt-bcard-x",
- "aria-label": t("Close tab"),
- title: t("Close tab"),
- onClick: () => {
- void actions.browserCloseTab(bot.id, tabId);
- }
- }, "×"),
- (0, import_react43.createElement)(
- "div",
- { className: "bt-bcard-row" },
- (0, import_react43.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }),
- (0, import_react43.createElement)("span", { className: "bt-bcard-title" }, label),
- page.url ? (0, import_react43.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null
- )
- );
- }),
- (0, import_react43.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react43.createElement)(PlusIcon), t("New tab"))
+ { className: "bt-modal-actions" },
+ (0, import_react37.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
+ (0, import_react37.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename"))
+ )
+ )
);
}
-function About({ text: text2, title = "About" }) {
- const [open, setOpen] = (0, import_react43.useState)(false);
- const long = text2.split("\n").length > 5 || text2.length > 240;
- const body = (0, import_react43.useRef)(null);
- useMorph(body, open);
- return (0, import_react43.createElement)(
+function DeleteModal({ workflow, onClose, onConfirm }) {
+ return (0, import_react37.createElement)(
"div",
- null,
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t(title)),
- (0, import_react43.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2),
- long ? (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null
+ { className: "bt-modal-mask", onClick: (event) => {
+ if (event.target === event.currentTarget) onClose?.();
+ } },
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") },
+ (0, import_react37.createElement)("h3", null, t("Delete workflow")),
+ (0, import_react37.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })),
+ (0, import_react37.createElement)(
+ "div",
+ { className: "bt-modal-actions" },
+ (0, import_react37.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
+ (0, import_react37.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete"))
+ )
+ )
);
}
-function SharedRoomApprovals({ room: room2 }) {
- const [items, setItems] = (0, import_react43.useState)(room2.pendingApprovals ?? []);
- const [busy, setBusy] = (0, import_react43.useState)("");
- const face = room2.origin === "face-to-face";
- (0, import_react43.useEffect)(() => {
- const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length;
- if (haveItems) setItems(room2.pendingApprovals);
- if (room2.viewerRole !== "invited-owner" && !face) return void 0;
- let alive = true;
- call("social/status", {}).then((value) => {
- if (!alive) return;
- if (room2.viewerRole === "invited-owner" && !haveItems) {
- setItems(Array.isArray(value?.approvals) ? value.approvals : []);
- }
- }, () => {
+function WorkflowsPage({ call: call4, roster }) {
+ const [phase, setPhase] = (0, import_react37.useState)("loading");
+ const [pack, setPack] = (0, import_react37.useState)(null);
+ const [error, setError] = (0, import_react37.useState)("");
+ const [opened, setOpened] = (0, import_react37.useState)(null);
+ const [editing, setEditing] = (0, import_react37.useState)(false);
+ const [renaming, setRenaming] = (0, import_react37.useState)(null);
+ const [deleting, setDeleting] = (0, import_react37.useState)(null);
+ const invoke = typeof call4 === "function" ? call4 : () => Promise.reject(new Error("Could not read workflows"));
+ const apply2 = (settled) => {
+ setPhase(settled.phase);
+ setPack(settled.pack);
+ setError(settled.error);
+ };
+ (0, import_react37.useEffect)(() => {
+ let gone = false;
+ readWorkflows(invoke).then((settled) => {
+ if (!gone) apply2(settled);
});
return () => {
- alive = false;
+ gone = true;
};
- }, [room2.id, room2.viewerRole, room2.pendingCount, face]);
- if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null;
- const decide = async (item, decision) => {
- setBusy(item.id);
- try {
- await call("social/decide-approval", { approvalId: item.id, decision });
- setItems((list) => list.filter((entry) => entry.id !== item.id));
- } finally {
- setBusy("");
+ }, []);
+ const live = (pack?.workflows ?? []).some((item) => (item.activeCount ?? 0) > 0);
+ (0, import_react37.useEffect)(() => {
+ if (!live || editing) return void 0;
+ let gone = false;
+ const timer = setInterval(() => {
+ readWorkflows(invoke).then((settled) => {
+ if (!gone) apply2(settled);
+ });
+ }, POLL_MS6);
+ return () => {
+ gone = true;
+ clearInterval(timer);
+ };
+ }, [live, editing]);
+ const retry = () => {
+ setPhase("loading");
+ setError("");
+ setOpened(null);
+ readWorkflows(invoke).then(apply2);
+ };
+ const open = (item, source) => {
+ if (source === "factory") {
+ const live2 = (pack?.workflows ?? []).find((workflow) => workflow.id === item.id);
+ setOpened(live2 ? { source: "own", id: live2.id, fromFactory: item.id } : { source: "factory", id: item.id });
+ setEditing(false);
+ return;
}
+ setOpened({ source: "own", id: item.id });
+ setEditing(false);
};
- return (0, import_react43.createElement)(
- "div",
- { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react43.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))),
- items.map((item) => (0, import_react43.createElement)(
- "div",
- { key: item.id, className: "bt-set-row" },
- (0, import_react43.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")),
- (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")),
- (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject"))
- ))
- );
-}
-function FaceMembers({ room: room2, roster, actions }) {
- const people2 = facePeople(room2, roster);
- const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot });
- return (0, import_react43.createElement)(
- "div",
- null,
- (0, import_react43.createElement)(FaceLead, { room: room2, roster, pick: pick2 }),
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })),
- (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react43.createElement)(FacePersonRow, {
- key: person.key,
- person,
- roster,
- className: "bt-member",
- onOpenBot: (id) => actions.openSession(id),
- lead: room2.face?.lead?.handle
- })))
- );
-}
-function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) {
- const live = room2.presentedParticipants ?? room2.participants ?? [];
- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
- const { entries: entries2, now } = useMemberWork(room2, useWork);
- const invited = room2.viewerRole === "invited-owner";
- const title = roomTitle(room2);
- const leaveMine = () => {
- const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId);
- if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] });
+ const useTemplate = (id) => {
+ const template = typeof id === "string" ? id : "";
+ if (!template) return;
+ Promise.resolve(invoke("workflow/create", { template })).then((value) => {
+ const settled = settleWorkflowRead(value);
+ if (settled.phase === "error") {
+ setError(settled.error);
+ return;
+ }
+ const before = new Set((pack?.workflows ?? []).map((item) => item.id));
+ const made = settled.pack.workflows.find((item) => !before.has(item.id));
+ setPack(settled.pack);
+ setPhase(settled.phase);
+ setError("");
+ if (made) {
+ setOpened({ source: "own", id: made.id });
+ setEditing(true);
+ }
+ }).catch((failure) => {
+ setError(workflowCallError(failure, t("Could not copy this workflow")));
+ });
};
- const face = room2.origin === "face-to-face";
- return [
- (0, import_react43.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }),
- invited && room2.sharedFrom && !face ? (0, import_react43.createElement)(
- "div",
- { key: "from", className: "bt-shared-from" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, title),
- (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })),
- room2.sharedFrom.invitedAt ? (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null,
- (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
- ) : face ? null : room2.notice ? (0, import_react43.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react43.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")),
- face ? null : (0, import_react43.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }),
- face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react43.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react43.createElement)(
- "div",
- { key: "members" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })),
- (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, [
- ...humans.map((person) => (0, import_react43.createElement)(
- "div",
- { key: person.id, role: "listitem" },
- (0, import_react43.createElement)(
- "div",
- { className: "bt-member" },
- (0, import_react43.createElement)("span", { className: "bt-member-name" }, (0, import_react43.createElement)("span", null, person.displayName || person.id)),
- (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Human")),
- person.role === "admin" ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
- )
- )),
- ...remotes.map((person) => (0, import_react43.createElement)(
- "div",
- { key: person.handle || person.id, role: "listitem" },
- (0, import_react43.createElement)(
- "div",
- { className: "bt-member" },
- (0, import_react43.createElement)(
- "span",
- { className: "bt-member-name" },
- (0, import_react43.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
- (0, import_react43.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person))
- ),
- face ? null : (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Friend's Bot"))
- )
- )),
- ...members.map((member) => {
- const entry = entries2.find((item) => item.id === member.id);
- return (0, import_react43.createElement)(
- "div",
- { key: member.id, role: "listitem" },
- (0, import_react43.createElement)(
- "div",
- { className: "bt-member" },
- (0, import_react43.createElement)(
- "button",
- { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) },
- (0, import_react43.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }),
- (0, import_react43.createElement)("span", null, member.name)
- ),
- room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
- ),
- entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null
- );
- })
- ])
- ),
- face ? null : (0, import_react43.createElement)(
- "div",
- { key: "mode" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Who answers")),
- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone")))
- ),
- face || !usage ? null : (0, import_react43.createElement)(
- "div",
- { key: "usage" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")),
- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, room2.name), (0, import_react43.createElement)("span", null, usage))
- )
- ];
-}
-function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
- const owner = roster.byId[sessionId];
- const part = owner ? currentPart(owner) : sessionId;
- const isMainBot = isMainOf(roster, owner?.id);
- const [tab, setTab] = (0, import_react43.useState)("details");
- const room2 = roster.roomsById[sessionId];
- const [view, setView] = (0, import_react43.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview");
- const [editing, setEditing] = (0, import_react43.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId])));
- const [draftName, setDraftName] = (0, import_react43.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : ""));
- const [copied, setCopied] = (0, import_react43.useState)(false);
- const [error, setError] = (0, import_react43.useState)("");
- const glide = useGlide(tab);
- const last = (0, import_react43.useRef)({ view, tab, move: void 0 });
- if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" };
- else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" };
- const move = last.current.move;
- const side = move === "tab" ? void 0 : move;
- const close = () => actions.closeOverlay();
- useEscape(close);
- const bot = owner;
- const running = useSessionStatus((map) => map.get(part)?.running === true);
- const doing = useActivity((map) => map[bot?.id ?? sessionId]);
- const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster)));
- const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []]));
- const run = (promise) => Promise.resolve(promise).then(() => {
- setError("");
- return true;
- }).catch((failure) => {
- setError(failure?.message ?? String(failure));
- return false;
- });
- const share = () => {
- const text2 = transcript;
- if (!text2) {
- actions.notify?.(t("Nothing to copy yet"));
- return;
- }
- void copiedToClipboard(text2).then((written) => {
- if (!written) {
- actions.reportError?.(new Error(t("Could not copy the conversation")), "share");
+ const rename = (item) => {
+ setRenaming(item);
+ };
+ const confirmRename = (workflowId, name) => {
+ if (!name.trim()) return;
+ Promise.resolve(invoke("workflow/rename", { workflowId, name: name.trim() })).then((value) => {
+ const settled = settleWorkflowRead(value);
+ if (settled.phase === "error") {
+ setError(settled.error);
return;
}
- setCopied(true);
- setTimeout(() => setCopied(false), 1600);
- actions.notify?.(t("Conversation copied as Markdown"));
+ setPack(settled.pack);
+ setPhase("ready");
+ setError("");
+ setRenaming(null);
+ }).catch((failure) => {
+ setError(workflowCallError(failure, t("Could not rename this workflow")));
});
};
- const errorLine = error ? (0, import_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
- if ((bot || room2) && view === "settings" && !bot?.remote) {
- return [
- (0, import_react43.createElement)(
- "div",
- { key: "head", className: "bt-subhead", "data-move": move },
- (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react43.createElement)(ChevronLeftIcon)),
- (0, import_react43.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")),
- hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon))
- ),
- (0, import_react43.createElement)(
- "div",
- { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move },
- errorLine,
- bot ? (0, import_react43.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react43.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close })
- )
- ];
- }
- const top = (0, import_react43.createElement)(
+ const deleteWorkflow = (item) => {
+ setDeleting(item);
+ };
+ const confirmDelete = (workflowId) => {
+ Promise.resolve(invoke("workflow/delete", { workflowId })).then((value) => {
+ const settled = settleWorkflowRead(value);
+ if (settled.phase === "error") {
+ setError(settled.error);
+ return;
+ }
+ setPack(settled.pack);
+ setPhase(settled.phase);
+ setError("");
+ setDeleting(null);
+ if (opened?.id === workflowId) setOpened(null);
+ }).catch((failure) => {
+ setError(workflowCallError(failure, t("Could not delete this workflow")));
+ });
+ };
+ const commit = (graph) => {
+ if (!opened || opened.source !== "own") return;
+ Promise.resolve(invoke("workflow/graph", { workflowId: opened.id, nodes: graph.nodes, edges: graph.edges })).then((value) => {
+ const settled = settleWorkflowRead(value);
+ if (settled.phase === "error") {
+ setError(settled.error);
+ return;
+ }
+ setPack(settled.pack);
+ setPhase("ready");
+ setError("");
+ }).catch((failure) => {
+ setError(workflowCallError(failure, t("Could not save this workflow")));
+ });
+ };
+ return (0, import_react37.createElement)(
"div",
- { className: "bt-drawer-top", "data-move": side },
- bot || room2 ? (0, import_react43.createElement)("button", {
- type: "button",
- className: "bt-round",
- "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
- title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
- "data-info-row": "settings",
- onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings")
- }, (0, import_react43.createElement)(GearIcon)) : null,
- bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null,
- bot || room2 ? (0, import_react43.createElement)("button", {
- type: "button",
- className: "bt-round",
- // #1347: the accessible name says what the click does, which is copy — "Share conversation"
- // promised a sharing step that never came. It still says "Copied" after a copy, so a screen
- // reader hears the same confirmation the toast gives.
- "aria-label": copied ? t("Copied conversation") : t("Copy conversation"),
- title: copied ? t("Copied") : t("Copy conversation as Markdown"),
- onClick: share
- }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null,
- hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon))
+ null,
+ (0, import_react37.createElement)(WorkflowsView, {
+ phase,
+ pack,
+ error,
+ opened,
+ editing,
+ roster,
+ onOpen: open,
+ onBack: () => {
+ setOpened(null);
+ setEditing(false);
+ },
+ onEditing: setEditing,
+ onRetry: retry,
+ onUse: useTemplate,
+ onCommit: commit,
+ onRename: rename,
+ onDelete: deleteWorkflow
+ }),
+ renaming ? (0, import_react37.createElement)(RenameModal, {
+ workflow: renaming,
+ onClose: () => setRenaming(null),
+ onConfirm: (name) => confirmRename(renaming.id, name)
+ }) : null,
+ deleting ? (0, import_react37.createElement)(DeleteModal, {
+ workflow: deleting,
+ onClose: () => setDeleting(null),
+ onConfirm: () => confirmDelete(deleting.id)
+ }) : null
);
- if (!bot && !room2) {
- return [
- (0, import_react43.createElement)(import_react43.Fragment, { key: "top" }, top),
- (0, import_react43.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react43.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team.")))
- ];
+}
+
+// src/client/usage-data.js
+var HOUR = 0;
+var OWNER = 1;
+var MODEL = 2;
+var KIND = 3;
+var HOUR_MS = 36e5;
+var DAY_MS = 864e5;
+var RANGES = [
+ { id: "24h", unit: "hour", count: 24, stride: 6 },
+ { id: "7d", unit: "day", count: 7, stride: 1 },
+ { id: "30d", unit: "day", count: 30, stride: 5 },
+ { id: "12w", unit: "week", count: 12, stride: 2 },
+ { id: "12m", unit: "month", count: 12, stride: 1 }
+];
+var hourMs = (key) => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, Number(key.slice(8, 10)), Number(key.slice(11, 13)) || 0);
+var dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
+var hourKey = (ms) => new Date(ms).toISOString().slice(0, 13).replace("T", " ");
+var mondayOf = (ms) => ms - (new Date(ms).getUTCDay() + 6) % 7 * DAY_MS;
+function spanRange(from, to) {
+ if (to < from) [from, to] = [to, from];
+ const days = Math.round((hourMs(to) - hourMs(from)) / DAY_MS) + 1;
+ const unit = days <= 2 ? "hour" : days <= 62 ? "day" : days <= 182 ? "week" : "month";
+ const range = { id: "custom", unit, from, to, days };
+ const count = spanBuckets(range).length;
+ const stride = unit === "hour" ? 6 : [1, 2, 3, 5, 7, 10, 14].find((step) => count / step <= 12) ?? Math.ceil(count / 12);
+ return { ...range, count, stride };
+}
+function spanBuckets(range) {
+ const first = hourMs(range.from);
+ const end = hourMs(range.to) + DAY_MS;
+ const buckets = [];
+ if (range.unit === "hour" || range.unit === "day") {
+ const step = range.unit === "hour" ? HOUR_MS : DAY_MS;
+ for (let start = first; start < end; start += step) buckets.push({ start, end: start + step, key: range.unit === "hour" ? hourKey(start) : dayKey(start) });
+ } else if (range.unit === "week") {
+ for (let monday = mondayOf(first); monday < end; monday += 7 * DAY_MS) {
+ buckets.push({ start: Math.max(monday, first), end: Math.min(monday + 7 * DAY_MS, end), key: dayKey(monday) });
+ }
+ } else {
+ const year = new Date(first).getUTCFullYear();
+ for (let month = new Date(first).getUTCMonth(); Date.UTC(year, month, 1) < end; month += 1) {
+ const start = Date.UTC(year, month, 1);
+ buckets.push({ start: Math.max(start, first), end: Math.min(Date.UTC(year, month + 1, 1), end), key: dayKey(start).slice(0, 7) });
+ }
}
- const current = bot?.name ?? room2.name;
- const saveName = async () => {
- setEditing(false);
- const next = draftName.trim();
- if (next === current || room2 && !room2.named && next === "") return;
- if (bot && next) await run(actions.updateBot(bot.id, { name: next }));
- if (room2) await run(actions.updateRoom(room2.id, { name: next }));
+ return buckets;
+}
+function spanOf(range, now) {
+ if (range.from) {
+ return { buckets: spanBuckets(range), from: `${range.from} 00`, to: `${range.to} 23`, before: hourKey(hourMs(range.from) - range.days * DAY_MS) };
+ }
+ const buckets = bucketsOf(range, now);
+ return { buckets: buckets.slice(range.count), from: hourKey(buckets[range.count].start), to: now, before: hourKey(buckets[0].start) };
+}
+function bucketsOf(range, now) {
+ const end = hourMs(now);
+ const today = end - end % DAY_MS;
+ const buckets = [];
+ for (let back = range.count * 2 - 1; back >= 0; back -= 1) {
+ let start;
+ if (range.unit === "hour") start = end - back * HOUR_MS;
+ else if (range.unit === "day") start = today - back * DAY_MS;
+ else if (range.unit === "week") start = mondayOf(today) - back * 7 * DAY_MS;
+ else start = Date.UTC(new Date(end).getUTCFullYear(), new Date(end).getUTCMonth() - back, 1);
+ buckets.push({ start, key: range.unit === "hour" ? hourKey(start) : range.unit === "month" ? dayKey(start).slice(0, 7) : dayKey(start) });
+ }
+ return buckets;
+}
+var tokensOf = (row) => row[4] + row[5] + row[6] + row[7];
+var blank2 = () => ({ input: 0, cacheRead: 0, output: 0, calls: 0, total: 0 });
+function addRow(sum, row) {
+ sum.input += row[4] + row[6];
+ sum.cacheRead += row[5];
+ sum.output += row[7];
+ sum.calls += row[8];
+ sum.total += tokensOf(row);
+ return sum;
+}
+function addTo(map, key, row) {
+ map.set(key, addRow(map.get(key) ?? blank2(), row));
+}
+function summarize(view, range, keep = () => true, seriesOf = () => "all") {
+ const { buckets, from, to, before } = spanOf(range, view.now);
+ const index = new Map(buckets.map((bucket, at) => [bucket.key, at]));
+ const weeks = /* @__PURE__ */ new Map();
+ const keyOf = (hour) => {
+ if (range.unit === "hour") return hour;
+ if (range.unit === "day") return hour.slice(0, 10);
+ if (range.unit === "month") return hour.slice(0, 7);
+ const day = hour.slice(0, 10);
+ let week = weeks.get(day);
+ if (week === void 0) weeks.set(day, week = dayKey(mondayOf(hourMs(day))));
+ return week;
};
- const subtitle = bot ? bot.role : roomSubtitle(room2, roster);
- const tabs = bot ? ["details", "browser"] : ["details"];
- return [
- (0, import_react43.createElement)(import_react43.Fragment, { key: "top" }, top),
- (0, import_react43.createElement)(
- "div",
- { key: "id", className: "bt-drawer-id", "data-move": side },
- bot ? (0, import_react43.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react43.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
- bot?.remote ? (0, import_react43.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react43.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react43.createElement)("input", {
- className: "bt-drawer-name-input",
- autoFocus: true,
- value: draftName,
- "aria-label": bot ? t("Bot name") : t("Group name"),
- placeholder: room2 ? t("Name after members") : void 0,
- onChange: (event) => setDraftName(event.target.value),
- onBlur: saveName,
- onKeyDown: (event) => {
- if (event.key === "Enter") void saveName();
- if (event.key === "Escape") {
- event.stopPropagation();
- setEditing(false);
- }
- }
- }) : (0, import_react43.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => {
- setDraftName(room2 && !room2.named ? "" : current);
- setEditing(true);
- } }, current),
- subtitle ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null
- ),
- bot?.remote ? null : (0, import_react43.createElement)(
- "div",
- { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side },
- (0, import_react43.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
- tabs.map((key) => (0, import_react43.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))
- ),
- (0, import_react43.createElement)(
- "div",
- { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move },
- errorLine,
- room2 && tab === "details" ? (0, import_react43.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null,
- bot && bot.remote && tab === "details" ? [
- bot.remote.handle ? (0, import_react43.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null,
- (0, import_react43.createElement)(FlyBotDetails, { key: "social", bot, roster, actions })
- ] : null,
- bot && !bot.remote && tab === "details" ? [
- (0, import_react43.createElement)(
- "div",
- { key: "model", className: "bt-drawer-model" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Model")),
- (0, import_react43.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }),
- modelOf(bot, roster) ? null : (0, import_react43.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name }))
- ),
- (0, import_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }),
- (0, import_react43.createElement)(
- "div",
- { key: "folder", className: "bt-drawer-model" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Working folder")),
- (0, import_react43.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) })
- ),
- bot.instructions ? (0, import_react43.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react43.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")),
- roster.memory === false ? null : (0, import_react43.createElement)(MemoryRow, { key: "memory", bot, actions }),
- usage ? (0, import_react43.createElement)(
- "div",
- { key: "usage" },
- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")),
- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, bot.name), (0, import_react43.createElement)("span", null, usage))
- ) : null,
- (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions })
- ] : null,
- bot && !bot.remote && tab === "browser" ? (0, import_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null
- )
- ];
+ const current = buckets.map((bucket) => ({ ...bucket, sum: blank2(), stacks: /* @__PURE__ */ new Map() }));
+ const result = { buckets: current, total: blank2(), previous: 0, owners: /* @__PURE__ */ new Map(), models: /* @__PURE__ */ new Map(), kinds: /* @__PURE__ */ new Map() };
+ for (const row of view.rows) {
+ const hour = row[HOUR];
+ if (hour < before || hour > to || !keep(row)) continue;
+ if (hour < from) {
+ result.previous += tokensOf(row);
+ continue;
+ }
+ const at = index.get(keyOf(hour));
+ if (at === void 0) continue;
+ const bucket = current[at];
+ addRow(bucket.sum, row);
+ addRow(result.total, row);
+ const series = seriesOf(row);
+ bucket.stacks.set(series, (bucket.stacks.get(series) ?? 0) + tokensOf(row));
+ addTo(result.owners, row[OWNER], row);
+ addTo(result.models, row[MODEL], row);
+ addTo(result.kinds, row[KIND], row);
+ }
+ return result;
+}
+function trimmed2(value) {
+ const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
+ return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2;
+}
+function compact(count) {
+ for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) {
+ if (count >= size * 0.9995) return `${trimmed2(count / size)}${unit}`;
+ }
+ return String(Math.round(count));
+}
+function percent(part, whole) {
+ if (!(whole > 0) || !(part > 0)) return "0";
+ const value = part / whole * 100;
+ return value < 1 ? "<1" : String(Math.round(value));
}
-// src/client/browser-pane.js
-var import_react44 = require("react");
-var import_react_dom10 = require("react-dom");
-var PANEL_LINGER_MS = 300;
-var WIDTH_KEY = STORAGE.browserWidth;
-var READ_FLASH_MS = 2400;
-var EMPTY_PAGE = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, excerpt: "" };
-var EMPTY_TABS2 = { ids: [], active: null };
-var SPRING_LEAD2 = springEasing(576, 39.4);
-var SPRING_TRAIL2 = springEasing(144, 19.7);
-var icon2 = (paths) => function BrowserIcon() {
- return (0, import_react44.createElement)(
- "svg",
- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
- paths.map((d, index) => (0, import_react44.createElement)("path", { key: index, d }))
- );
+// src/client/usage-page.js
+var import_react38 = require("react");
+var HEAT_WEEKS = 53;
+var TOP_SERIES = 7;
+var RING_R = 42;
+var RING2 = 2 * Math.PI * RING_R;
+var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id];
+var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit];
+var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"];
+var PARTS = [["output", 1], ["input", 0.6], ["cacheRead", 0.32]];
+var partLabel = (part) => ({ output: t("Output"), input: t("Input"), cacheRead: t("Cache hit") })[part];
+var soften = (paint, strength = 1) => {
+ const mix = (color) => `color-mix(in srgb, ${color} ${Math.round(88 * strength)}%, var(--bt-card))`;
+ return paint.startsWith("linear-gradient(") ? paint.replace(/#[0-9a-f]{6}/gi, mix) : mix(paint);
};
-var GlobeIcon = icon2(["M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M3 10h14", "M10 3c2 2.2 2.9 4.5 2.9 7S12 14.8 10 17c-2-2.2-2.9-4.5-2.9-7S8 5.2 10 3z"]);
-var BackIcon = icon2(["M12.5 4.5L7 10l5.5 5.5"]);
-var ForwardIcon = icon2(["M7.5 4.5L13 10l-5.5 5.5"]);
-var ReloadIcon = icon2(["M15.5 10a5.5 5.5 0 1 1-1.6-3.9", "M15.5 3.5v3.5H12"]);
-var StopIcon = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
-var CloseIcon2 = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
-var PlusTabIcon = icon2(["M10 5v10M5 10h10"]);
-var LockIcon2 = icon2(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]);
-function Favicon({ src, className }) {
- const [failed, setFailed] = (0, import_react44.useState)(null);
- if (!src || failed === src) return (0, import_react44.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react44.createElement)(GlobeIcon));
- return (0, import_react44.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) });
+var full = (count) => new Intl.NumberFormat(dateLocale()).format(Math.round(count));
+var hitRate = (sum) => percent(sum.cacheRead, sum.input + sum.cacheRead);
+var dates = (options) => new Intl.DateTimeFormat(dateLocale(), { timeZone: "UTC", ...options });
+var hourText = (ms) => `${String(new Date(ms).getUTCHours()).padStart(2, "0")}:00`;
+var yearOf = (ms) => new Date(ms).getUTCFullYear();
+function spanText(from, to) {
+ const start = hourMs(from);
+ const end = hourMs(to);
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
+ const format = dates(yearOf(start) !== year || yearOf(end) !== year ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
+ return from === to ? format.format(start) : `${format.format(start)} – ${format.format(end)}`;
}
-var savedWidth = () => {
- try {
- const value = Number(localStorage.getItem(WIDTH_KEY));
- return Number.isFinite(value) && value >= MIN_DETAILS ? value : null;
- } catch {
- return null;
+var daysText = (count) => count === 1 ? t("1 day") : t("{count} days", { count });
+var lastText = (range) => range.from ? spanText(range.from, range.to) : t("Last {range}", { range: rangeLabel(range.id) });
+function compareText(range) {
+ if (!range.from) return t("Compared with the {range} before", { range: rangeLabel(range.id) });
+ return range.days === 1 ? t("Compared with the day before") : t("Compared with the {count} days before", { count: range.days });
+}
+function tickOf(range, bucket) {
+ if (range.unit === "hour") {
+ return range.days > 1 && new Date(bucket.start).getUTCHours() === 0 ? dates({ month: "numeric", day: "numeric" }).format(bucket.start) : hourText(bucket.start);
}
-};
-var fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high)));
-var widthRange = (view, viewport) => panelWidthRange(view, viewport);
-var room = { rule: null, selector: "" };
-var roomRule = () => {
- const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected;
- if (live && room.selector) return room.rule;
- const column = document.querySelector('[class*="_centerCol"]');
- const selector = column ? [...column.classList].filter((name) => name.includes("_centerCol")).map((name) => `.${CSS.escape(name)}`).join(",") : "";
- if (live && !selector) return room.rule;
- const declarations2 = room.rule?.style.cssText ?? "";
- room.rule?.parentStyleSheet?.ownerNode?.remove();
- const style = document.createElement("style");
- style.dataset.dshBot = "chat-room";
- style.textContent = `${selector || '[class*="_centerCol"]'}{${declarations2}}`;
- document.head.append(style);
- room.rule = style.sheet.cssRules[0];
- room.selector = selector;
- return room.rule;
-};
-var roomHold = 0;
-var chatRoom = {
- /** The column's right margin in px, or null for the shell's own. */
- set(width) {
- const { style } = roomRule();
- if (width === null) style.removeProperty("margin-right");
- else style.setProperty("margin-right", `${width}px`, "important");
- },
- /** While on, a margin change lands at once instead of gliding. */
- hold(on) {
- cancelAnimationFrame(roomHold);
- if (on) {
- roomRule().style.setProperty("transition", "none", "important");
- return;
+ if (range.unit === "month") return dates({ month: "short" }).format(bucket.start);
+ return dates({ month: "numeric", day: "numeric" }).format(bucket.start);
+}
+function titleOf(range, bucket) {
+ if (range.unit === "hour") return `${dates({ month: "short", day: "numeric" }).format(bucket.start)} ${hourText(bucket.start)}–${hourText(bucket.start + HOUR_MS)}`;
+ if (range.unit === "day") return dates({ month: "long", day: "numeric", weekday: "short" }).format(bucket.start);
+ if (range.unit === "week") return spanText(dayKey(bucket.start), dayKey((bucket.end ?? bucket.start + 7 * DAY_MS) - DAY_MS));
+ if (bucket.end && (new Date(bucket.start).getUTCDate() !== 1 || new Date(bucket.end).getUTCDate() !== 1)) return spanText(dayKey(bucket.start), dayKey(bucket.end - DAY_MS));
+ return dates({ year: "numeric", month: "long" }).format(bucket.start);
+}
+function useTween(value) {
+ const [shown, setShown] = (0, import_react38.useState)(0);
+ const from = (0, import_react38.useRef)(0);
+ (0, import_react38.useEffect)(() => {
+ if (reducedMotion() || typeof requestAnimationFrame !== "function") {
+ from.current = value;
+ setShown(value);
+ return void 0;
}
- roomHold = requestAnimationFrame(() => {
- roomHold = requestAnimationFrame(() => roomRule().style.removeProperty("transition"));
- });
- },
- /** The columns themselves, for a drag's per-frame writes. */
- columns() {
- roomRule();
- return room.selector ? [...document.querySelectorAll(room.selector)] : [];
- }
-};
-var shortAddress = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
-var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
-var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab");
-function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) {
- const pages = useBrowserPages((map) => map);
- const wrap = (0, import_react44.useRef)(null);
- const ind = (0, import_react44.useRef)(null);
- const spot = (0, import_react44.useRef)(null);
- const under = (0, import_react44.useRef)(null);
- (0, import_react44.useLayoutEffect)(() => {
- const box = wrap.current;
- const capsule = ind.current;
- if (!box || !capsule) return void 0;
- const node = view === "details" ? box.querySelector(".bt-ptab-bot") : box.querySelector(`[data-tab="${active}"]`);
- under.current = node;
- if (!node) {
- capsule.style.opacity = "0";
- spot.current = null;
- return void 0;
- }
- if (view === "details") box.scrollLeft = 0;
- else node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
- const to = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
- const from = spot.current;
- spot.current = to;
- capsule.style.opacity = "1";
- capsule.style.left = `${to.left}px`;
- capsule.style.right = `${to.right}px`;
- if (from === null || from.left === to.left && from.right === to.right || reducedMotion2()) return void 0;
- const ahead = to.left > from.left ? "right" : "left";
- const behind = ahead === "right" ? "left" : "right";
- const lead = capsule.animate([{ [ahead]: `${from[ahead]}px` }, { [ahead]: `${to[ahead]}px` }], { duration: SPRING_LEAD2.duration, easing: SPRING_LEAD2.easing });
- const trail = capsule.animate([{ [behind]: `${from[behind]}px` }, { [behind]: `${to[behind]}px` }], { duration: SPRING_TRAIL2.duration, easing: SPRING_TRAIL2.easing });
- return () => {
- lead.cancel();
- trail.cancel();
+ const begin = from.current;
+ const started = performance.now();
+ let frame;
+ const step = (now) => {
+ const progress = Math.min(1, (now - started) / 650);
+ from.current = begin + (value - begin) * (1 - (1 - progress) ** 3);
+ setShown(from.current);
+ if (progress < 1) frame = requestAnimationFrame(step);
};
- });
- (0, import_react44.useEffect)(() => {
- const box = wrap.current;
- if (!box) return void 0;
- const observer = new ResizeObserver(() => {
- const node = under.current;
- const capsule = ind.current;
- if (!node?.isConnected || !capsule || spot.current === null) return;
- spot.current = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
- capsule.style.left = `${spot.current.left}px`;
- capsule.style.right = `${spot.current.right}px`;
- });
- observer.observe(box);
- return () => observer.disconnect();
- }, []);
- return (0, import_react44.createElement)(
- "div",
- { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") },
- (0, import_react44.createElement)(
- "div",
- { className: "bt-ptabs-in", ref: wrap },
- (0, import_react44.createElement)(
- "button",
- {
- type: "button",
- role: "tab",
- "aria-selected": view === "details",
- className: "bt-ptab bt-ptab-bot",
- title: bot.name,
- onClick: () => onView("details")
- },
- (0, import_react44.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }),
- view === "details" ? (0, import_react44.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null
- ),
- ids.map((tabId) => {
- const page = pages[tabId] ?? EMPTY_PAGE;
- const on = view === "browser" && tabId === active;
- return (0, import_react44.createElement)(
- "button",
- {
- key: tabId,
- type: "button",
- role: "tab",
- "aria-selected": on,
- "data-tab": tabId,
- className: "bt-ptab",
- title: page.url || t("New tab"),
- onClick: () => {
- actions.browserActivate(bot.id, tabId);
- onView("browser");
- },
- onAuxClick: (event) => {
- if (event.button === 1) {
- event.preventDefault();
- void actions.browserCloseTab(bot.id, tabId);
- }
- }
- },
- (0, import_react44.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }),
- (0, import_react44.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)),
- (0, import_react44.createElement)("span", {
- className: "bt-ptab-x",
- role: "button",
- "aria-label": t("Close tab"),
- title: t("Close tab"),
- onClick: (event) => {
- event.stopPropagation();
- void actions.browserCloseTab(bot.id, tabId);
- }
- }, "×")
- );
- }),
- (0, import_react44.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true })
- ),
- // New tab and Close stay outside the scrolling tabs, so a narrow panel keeps both.
- (0, import_react44.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react44.createElement)(PlusTabIcon)),
- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn bt-ptab-close", "aria-label": t("Close panel"), title: t("Close panel"), onClick: () => actions.closePanel() }, (0, import_react44.createElement)(CloseIcon2))
- );
+ frame = requestAnimationFrame(step);
+ return () => cancelAnimationFrame(frame);
+ }, [value]);
+ return shown;
}
-function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) {
- const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE;
- const frame = (0, import_react44.useRef)(null);
- const input = (0, import_react44.useRef)(null);
- const [draft, setDraft] = (0, import_react44.useState)(null);
- const showGuest = landed && !leaving && Boolean(page.url) && !page.error;
- (0, import_react44.useLayoutEffect)(() => {
- const element = frame.current;
- if (!element || leaving || !tabId) {
- void actions.browserShow(null);
- return void 0;
- }
- let raf = 0;
- const measure = () => {
- cancelAnimationFrame(raf);
- raf = 0;
- const box = element.getBoundingClientRect();
- void actions.browserShow(tabId, showGuest ? { left: box.left, top: box.top, width: box.width, height: box.height } : null);
+var glyphBox = (size, icon3) => (0, import_react38.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react38.createElement)(icon3));
+function ModelGlyph({ name, paint, size }) {
+ return (0, import_react38.createElement)("span", {
+ className: "bt-us-model",
+ "aria-hidden": true,
+ style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` }
+ }, (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase());
+}
+function ownerFace(owner, roster) {
+ if (owner.kind === "bot") {
+ const bot = roster.byId[owner.id] ?? { id: owner.id, name: owner.name };
+ const name = bot.name || owner.name || t("Deleted Bot");
+ const role = bot.role?.trim() ?? "";
+ return {
+ kind: "bot",
+ name,
+ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role,
+ paint: paintCss(colorOf(bot)),
+ stops: GRADIENTS[colorOf(bot)],
+ glyph: (size) => (0, import_react38.createElement)(BotAvatar, { bot, size, badge: false, live: false })
};
- const schedule = () => {
- if (!raf) raf = requestAnimationFrame(measure);
+ }
+ if (owner.kind === "room") {
+ const room2 = roster.roomsById[owner.id];
+ return {
+ kind: "room",
+ name: owner.name || t("Deleted group chat"),
+ sub: owner.deleted ? t("Deleted") : t("Group chat"),
+ paint: "var(--bt-us-rest)",
+ glyph: (size) => room2 ? (0, import_react38.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon)
};
- measure();
- const observer = new ResizeObserver(measure);
- observer.observe(element);
- window.addEventListener("resize", schedule);
- return () => {
- cancelAnimationFrame(raf);
- observer.disconnect();
- window.removeEventListener("resize", schedule);
+ }
+ if (owner.kind === "agent") {
+ return {
+ kind: "agent",
+ name: owner.name || "DSH Agent",
+ sub: owner.deleted ? t("Deleted") : "",
+ paint: "var(--bt-us-rest)",
+ glyph: (size) => glyphBox(size, ChatsIcon)
};
- }, [tabId, showGuest, leaving]);
- (0, import_react44.useEffect)(() => {
- setDraft(null);
- if (landed && !page.url) input.current?.focus();
- }, [tabId, landed]);
- const go = (event) => {
- event.preventDefault();
- const text2 = draft ?? "";
- if (text2.trim() === "") return;
- setDraft(null);
- input.current?.blur();
- void actions.browserNavigate(tabId, text2).then((opened) => {
- if (opened) actions.browserFocus(tabId);
- });
- };
- const editing = draft !== null;
- const secure = /^https:/i.test(page.url);
- return (0, import_react44.createElement)(
+ }
+ return { kind: "other", name: t("Other"), sub: "", paint: "var(--bt-us-rest)", glyph: (size) => glyphBox(size, ChatsIcon) };
+}
+function kindLabel(kind, botFocus) {
+ const labels = { chat: botFocus ? t("Own chat") : t("Conversations"), group: t("Group chats"), compaction: t("Compaction and handoff"), image: t("Image reading"), memory: t("Memory"), "session-title": t("Titles"), other: t("Other") };
+ return labels[kind] ?? kind;
+}
+function Tabs({ label, value, options, onChange, boxRef }) {
+ const names = options.map(([, text2]) => text2).join("\n");
+ const glide = useGlide(`${value}
+${names}`, { box: boxRef });
+ return (0, import_react38.createElement)(
"div",
- { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) },
- (0, import_react44.createElement)(
- "div",
- { className: "bt-browser-bar" },
- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back"), title: t("Back"), disabled: !page.canGoBack, onClick: () => actions.browserCommand(tabId, "back") }, (0, import_react44.createElement)(BackIcon)),
- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Forward"), title: t("Forward"), disabled: !page.canGoForward, onClick: () => actions.browserCommand(tabId, "forward") }, (0, import_react44.createElement)(ForwardIcon)),
- (0, import_react44.createElement)("button", {
- type: "button",
- className: "bt-icon-btn",
- disabled: !page.url,
- "aria-label": page.loading ? t("Stop") : t("Reload"),
- title: page.loading ? t("Stop") : t("Reload"),
- onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload")
- }, (0, import_react44.createElement)(page.loading ? StopIcon : ReloadIcon)),
- (0, import_react44.createElement)(
- "form",
- { className: "bt-browser-address", onSubmit: go, "data-editing": editing || void 0 },
- (0, import_react44.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react44.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)),
- (0, import_react44.createElement)("input", {
- ref: input,
- type: "text",
- spellCheck: false,
- autoComplete: "off",
- "aria-label": t("Address"),
- placeholder: t("Search or enter address"),
- value: editing ? draft : shortAddress(page.url),
- onFocus: (event) => {
- setDraft(page.url);
- const target = event.target;
- requestAnimationFrame(() => target.select());
- },
- onBlur: () => setDraft(null),
- onChange: (event) => setDraft(event.target.value),
- onKeyDown: (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- setDraft(null);
- event.currentTarget.blur();
- }
- }
- })
- ),
- page.loading ? (0, import_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null
- ),
- (0, import_react44.createElement)(
- "div",
- { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 },
- page.error ? (0, import_react44.createElement)(
- "div",
- { className: "bt-browser-empty" },
- (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")),
- (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)),
- page.url ? (0, import_react44.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => {
- void actions.browserNavigate(tabId, page.url);
- } }, t("Try again")) : null
- ) : !page.url ? (0, import_react44.createElement)(
- "div",
- { className: "bt-browser-empty" },
- (0, import_react44.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }),
- (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
- (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name }))
- ) : null
- )
+ { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label },
+ (0, import_react38.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ options.map(([id, text2, icon3]) => (0, import_react38.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react38.createElement)(icon3) : null, text2))
);
}
-function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
- const bot = roster.byId[pane.id];
- const canBrowse = Boolean(bot && roster.browser === true && actions.browserShow);
- const view = pane.view === "browser" && canBrowse ? "browser" : "details";
- const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2);
- const active = tabState.active;
- const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE);
- const root = (0, import_react44.useRef)(null);
- const fly = (0, import_react44.useRef)(null);
- const [landed, setLanded] = (0, import_react44.useState)(false);
- const [settling, setSettling] = (0, import_react44.useState)(false);
- const [width, setWidth] = (0, import_react44.useState)(savedWidth);
- const [viewport, setViewport] = (0, import_react44.useState)(() => window.innerWidth);
- const [dragging, setDragging] = (0, import_react44.useState)(false);
- const [resizing, setResizing] = (0, import_react44.useState)(false);
- const instant = dragging || resizing;
- const [reading, setReading] = (0, import_react44.useState)(false);
- const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport));
- (0, import_react44.useLayoutEffect)(() => {
- if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH);
- }, [view, width]);
- (0, import_react44.useLayoutEffect)(() => {
- chatRoom.hold(instant);
- }, [instant]);
- const [last, setLast] = (0, import_react44.useState)({ view, width: panelWidth });
- if (last.view !== view || last.width !== panelWidth) {
- setLast({ view, width: panelWidth });
- if (last.view !== view) setSettling(last.width !== panelWidth && !instant);
- }
- const shownView = (0, import_react44.useRef)(view);
- (0, import_react44.useLayoutEffect)(() => {
- const switched = settling && shownView.current !== view;
- shownView.current = view;
- if (switched) chatRoom.hold(true);
- chatRoom.set(leaving ? null : panelWidth);
- if (switched) chatRoom.hold(false);
- }, [view, panelWidth, leaving]);
- (0, import_react44.useEffect)(() => () => {
- chatRoom.set(null);
- chatRoom.hold(false);
- }, []);
- (0, import_react44.useEffect)(() => {
- let timer;
- const onResize = () => {
- setResizing(true);
- setViewport(window.innerWidth);
- clearTimeout(timer);
- timer = setTimeout(() => setResizing(false), 160);
+function Stats({ range, total, previous }) {
+ const shown = useTween(total.total);
+ const change = previous > 0 ? Math.round((total.total - previous) / previous * 100) : null;
+ const cell = (label, value) => (0, import_react38.createElement)("div", { className: "bt-us-stat" }, (0, import_react38.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react38.createElement)("span", { className: "bt-us-stat-value" }, value));
+ return (0, import_react38.createElement)(
+ "section",
+ { className: "bt-us-stats", style: { "--i": 0 } },
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-stat bt-us-stat-main" },
+ (0, import_react38.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)),
+ (0, import_react38.createElement)(
+ "span",
+ { className: "bt-us-stat-value" },
+ compact(shown),
+ (0, import_react38.createElement)("small", null, t("tokens")),
+ change === null ? null : (0, import_react38.createElement)(
+ "span",
+ { className: "bt-us-delta", title: compareText(range) },
+ `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%`
+ )
+ )
+ ),
+ cell(t("Cache hit rate"), `${hitRate(total)}%`),
+ cell(t("Output"), compact(total.output)),
+ cell(t("Calls"), full(total.calls))
+ );
+}
+function niceTop(max) {
+ if (!(max > 0)) return 1;
+ const step = 10 ** Math.floor(Math.log10(max));
+ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max);
+}
+function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) {
+ const [hover, setHover] = (0, import_react38.useState)(null);
+ const top = niceTop(Math.max(0, ...columns.map((column) => column.total)));
+ const count = columns.length;
+ const fillOf = new Map(series.map((entry) => [entry.key, entry.fill]));
+ const tipOf = new Map(tipSeries.map((entry) => [entry.key, entry]));
+ const tip = hover === null ? null : columns[hover];
+ const tipSide = hover !== null && hover >= count / 2 ? { right: `calc(${(count - hover) / count * 100}% + 8px)` } : { left: `calc(${(hover + 1) / count * 100}% + 8px)` };
+ return (0, import_react38.createElement)(
+ "section",
+ { className: "bt-us-card bt-us-chart", style: { "--i": 1 } },
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-card-head" },
+ (0, import_react38.createElement)("h3", null, t("Tokens over time"), (0, import_react38.createElement)("span", null, unitLabel(range.unit))),
+ onBy ? (0, import_react38.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null
+ ),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-chart-body" },
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-plot", "data-hover": hover === null ? void 0 : "", onMouseLeave: () => setHover(null) },
+ [1, 0.5].map((level) => (0, import_react38.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react38.createElement)("span", null, compact(top * level)))),
+ (0, import_react38.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react38.createElement)("span", null, "0")),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-cols" },
+ columns.map((column, at) => (0, import_react38.createElement)("div", {
+ key: `${range.from ? `${range.from}~${range.to}` : range.id}:${column.key}`,
+ className: "bt-us-col",
+ "data-on": hover === at ? "" : void 0,
+ onMouseEnter: () => setHover(at)
+ }, column.total > 0 ? (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-stack", style: { height: `${column.total / top * 100}%`, "--i": at } },
+ column.parts.map(([key, value]) => value > 0 ? (0, import_react38.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null)
+ ) : (0, import_react38.createElement)("div", { className: "bt-us-stub" })))
+ ),
+ tip ? (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-tip", style: tipSide },
+ (0, import_react38.createElement)("div", { className: "bt-us-tip-title" }, tip.title),
+ (0, import_react38.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(tip.total) })),
+ tip.tip.filter(([, value]) => value > 0).sort((a, b) => b[1] - a[1]).map(([key, value]) => (0, import_react38.createElement)(
+ "div",
+ { key, className: "bt-us-tip-row" },
+ (0, import_react38.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }),
+ (0, import_react38.createElement)("span", null, tipOf.get(key)?.name),
+ (0, import_react38.createElement)("b", null, compact(value))
+ ))
+ ) : null
+ ),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-ticks", "aria-hidden": true },
+ // A preset counts its labels back from now; a picked span forward from its first day.
+ columns.map((column, at) => (0, import_react38.createElement)(
+ "span",
+ { key: column.key, "data-now": at === count - 1 && range.ends !== false ? "" : void 0 },
+ (range.from ? at : count - 1 - at) % range.stride === 0 ? column.tick : ""
+ ))
+ )
+ ),
+ series.length > 1 || series[0]?.pick ? (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-legend" },
+ series.map((entry) => (0, import_react38.createElement)(entry.pick ? "button" : "span", {
+ key: entry.key,
+ className: "bt-us-key",
+ ...entry.pick ? { type: "button", onClick: () => onPick(entry.pick) } : {}
+ }, (0, import_react38.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name))
+ ) : null
+ );
+}
+function Row3({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) {
+ const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0;
+ return (0, import_react38.createElement)(
+ onClick ? "button" : "div",
+ {
+ className: "bt-us-row",
+ "data-kind": kind,
+ style: { "--i": index },
+ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }),
+ ...onClick ? { type: "button", onClick } : {}
+ },
+ glyph ? (0, import_react38.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null,
+ (0, import_react38.createElement)(
+ "span",
+ { className: "bt-us-row-body" },
+ (0, import_react38.createElement)(
+ "span",
+ { className: "bt-us-row-line" },
+ (0, import_react38.createElement)("span", { className: "bt-us-row-name" }, name),
+ sub ? (0, import_react38.createElement)("span", { className: "bt-us-row-sub" }, sub) : null
+ ),
+ (0, import_react38.createElement)("span", { className: "bt-us-track" }, (0, import_react38.createElement)("span", { style: { width: `${width}%`, background: fill3 } }))
+ ),
+ (0, import_react38.createElement)("span", { className: "bt-us-row-num" }, (0, import_react38.createElement)("b", null, compact(sum.total)), (0, import_react38.createElement)("small", null, `${percent(sum.total, whole)}%`))
+ );
+}
+function ListCard({ title, count, index, children }) {
+ return (0, import_react38.createElement)(
+ "section",
+ { className: "bt-us-card", style: { "--i": index } },
+ (0, import_react38.createElement)("div", { className: "bt-us-card-head" }, (0, import_react38.createElement)("h3", null, title, count === void 0 ? null : (0, import_react38.createElement)("span", null, count))),
+ (0, import_react38.createElement)("div", { className: "bt-us-rows" }, children)
+ );
+}
+function Profile({ face, share, range }) {
+ const arc = Math.max(0, Math.min(1, share)) * RING2;
+ return (0, import_react38.createElement)(
+ "section",
+ { className: "bt-us-profile" },
+ (0, import_react38.createElement)(
+ "span",
+ { className: "bt-us-ring" },
+ (0, import_react38.createElement)(
+ "svg",
+ { viewBox: "0 0 96 96", "aria-hidden": true },
+ face.stops ? (0, import_react38.createElement)("defs", null, (0, import_react38.createElement)(
+ "linearGradient",
+ { id: "bt-us-ring-paint", x1: 0, y1: 1, x2: 1, y2: 0 },
+ face.stops.map(([offset, color]) => (0, import_react38.createElement)("stop", { key: offset, offset, stopColor: color }))
+ )) : null,
+ (0, import_react38.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }),
+ arc > 0 ? (0, import_react38.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null
+ ),
+ (0, import_react38.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64))
+ ),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-profile-copy" },
+ (0, import_react38.createElement)("h3", null, face.name),
+ face.sub ? (0, import_react38.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null,
+ (0, import_react38.createElement)("span", { className: "bt-us-profile-share" }, range.from ? t("{share}% of all usage from {span}", { share: percent(share, 1), span: spanText(range.from, range.to) }) : t("{share}% of all usage in the last {range}", { share: percent(share, 1), range: rangeLabel(range.id) }))
+ )
+ );
+}
+var monthOf = (ms) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth(), 1);
+var addMonths = (ms, count) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth() + count, 1);
+function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) {
+ const box = (0, import_react38.useRef)(null);
+ const todayMs = hourMs(today);
+ const thisMonth = monthOf(todayMs);
+ const [right, setRight] = (0, import_react38.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today))));
+ const [anchor, setAnchor] = (0, import_react38.useState)(null);
+ const [hover, setHover] = (0, import_react38.useState)(null);
+ (0, import_react38.useEffect)(() => {
+ const onDown = (event) => {
+ if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose();
};
- window.addEventListener("resize", onResize);
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ onClose();
+ }
+ };
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
return () => {
- clearTimeout(timer);
- window.removeEventListener("resize", onResize);
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
};
}, []);
- (0, import_react44.useEffect)(() => {
- if (!settling) return void 0;
- const timer = setTimeout(() => setSettling(false), SPRING_SHAPE.duration + 40);
- return () => clearTimeout(timer);
- }, [settling, view]);
- (0, import_react44.useEffect)(() => {
- const timer = setTimeout(() => setLanded(true), 360);
- return () => clearTimeout(timer);
- }, []);
- (0, import_react44.useEffect)(() => {
- if (canBrowse) actions.browserEnsure(bot.id);
- }, [bot?.id, canBrowse]);
- (0, import_react44.useEffect)(() => {
- if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details");
- }, [view, active, tabState.ids.length]);
- (0, import_react44.useEffect)(() => {
- if (view !== "browser" || leaving) void actions.browserShow?.(null);
- }, [view, leaving]);
- (0, import_react44.useEffect)(() => () => {
- void actions.browserShow?.(null);
- }, []);
- (0, import_react44.useEffect)(() => {
- if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0;
- setReading(true);
- const timer = setTimeout(() => setReading(false), READ_FLASH_MS);
- return () => clearTimeout(timer);
- }, [page.readAt]);
- const beginSwitch = (next) => {
- if (reducedMotion2()) return;
- const panel = root.current;
- const outgoing = panel?.querySelector(".bt-panel-view");
- if (outgoing) {
- const ghostEl = document.createElement("div");
- ghostEl.className = "bt-panel-ghost";
- ghostEl.style.width = `${outgoing.offsetWidth}px`;
- ghostEl.append(outgoing.cloneNode(true));
- outgoing.parentElement.append(ghostEl);
- void ghostEl.animate([
- { opacity: 1, transform: "scale(1)" },
- { opacity: 0, transform: "scale(0.985)" }
- ], { duration: 160, easing: "ease-out", fill: "forwards" }).finished.catch(() => {
- }).finally(() => ghostEl.remove());
+ const [low, high] = anchor ? [anchor, hover ?? anchor].sort() : span ? [span.from, span.to] : [null, null];
+ const press = (key) => {
+ if (!anchor) {
+ setAnchor(key);
+ return;
}
- const source = next === "browser" ? panel?.querySelector(".bt-drawer-id .bt-mark") : panel?.querySelector(".bt-ptab-bot .bt-mark");
- if (source) fly.current = { node: source.cloneNode(true), from: source.getBoundingClientRect() };
- };
- const switchView = (next) => {
- if (next === view || leaving) return;
- beginSwitch(next);
- actions.setPanelView(next);
+ const [from, to] = [anchor, key].sort();
+ onPick(from, to);
};
- const panelActions = { ...actions };
- for (const name of ["openBrowser", "browserNewTab", "browserPick"]) {
- if (actions[name]) panelActions[name] = (...args) => {
- if (view !== "browser") beginSwitch("browser");
- return actions[name](...args);
- };
- }
- (0, import_react44.useLayoutEffect)(() => {
- const move = fly.current;
- if (!move) return;
- fly.current = null;
- const target = view === "browser" ? root.current?.querySelector(".bt-ptab-bot .bt-mark") : root.current?.querySelector(".bt-drawer-id .bt-mark");
- if (!target || move.from.width === 0) return;
- const growing = root.current.getAnimations({ subtree: true }).filter((animation) => animation.animationName === "bt-strip-in");
- const at = growing.map((animation) => animation.currentTime);
- for (const animation of growing) animation.currentTime = animation.effect.getComputedTiming().endTime;
- const to = target.getBoundingClientRect();
- growing.forEach((animation, index) => {
- animation.currentTime = at[index];
- });
- const clone = move.node;
- Object.assign(clone.style, {
- position: "fixed",
- left: `${move.from.left}px`,
- top: `${move.from.top}px`,
- width: `${move.from.width}px`,
- height: `${move.from.height}px`,
- margin: "0",
- transformOrigin: "0 0",
- zIndex: "80",
- pointerEvents: "none",
- visibility: ""
- });
- document.body.append(clone);
- target.style.visibility = "hidden";
- const scale = to.width / move.from.width;
- const anim = clone.animate([
- { transform: "translate(0px, 0px) scale(1)" },
- { transform: `translate(${to.left - move.from.left}px, ${to.top - move.from.top}px) scale(${scale})` }
- ], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, fill: "forwards" });
- void anim.finished.catch(() => {
- }).finally(() => {
- clone.remove();
- target.style.visibility = "";
- });
- }, [view, pane.id]);
- const startResize = (event) => {
- if (event.button !== 0 || leaving) return;
- event.preventDefault();
- const panel = root.current;
- const grip = event.currentTarget;
- grip.setPointerCapture?.(event.pointerId);
- const range = widthRange(view, window.innerWidth);
- const from = { x: event.clientX, width: panelWidth };
- let next = panelWidth;
- setDragging(true);
- setSettling(false);
- chatRoom.hold(true);
- const columns = chatRoom.columns();
- for (const column of columns) column.style.setProperty("transition", "none", "important");
- const onMove = (move) => {
- next = fitWidth(from.width + from.x - move.clientX, range);
- panel.style.width = `${next}px`;
- if (columns.length === 0) chatRoom.set(next);
- for (const column of columns) column.style.setProperty("margin-right", `${next}px`, "important");
- };
- const ends = ["pointerup", "pointercancel", "lostpointercapture"];
- const onEnd = (end) => {
- if (end.type === "pointerup") onMove(end);
- grip.removeEventListener("pointermove", onMove);
- for (const name of ends) grip.removeEventListener(name, onEnd);
- chatRoom.set(next);
- for (const column of columns) {
- column.style.removeProperty("margin-right");
- column.style.removeProperty("transition");
- }
- panel.style.setProperty("--bt-panel-w", `${next}px`);
- panel.style.removeProperty("width");
- setDragging(false);
- if (next === from.width) return;
- setWidth(next);
- try {
- localStorage.setItem(WIDTH_KEY, String(next));
- } catch {
- }
- };
- grip.addEventListener("pointermove", onMove);
- for (const name of ends) grip.addEventListener(name, onEnd);
+ const weekdays = (0, import_react38.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []);
+ const month = (start, side) => {
+ const length = new Date(addMonths(start, 1) - DAY_MS).getUTCDate();
+ const lead = (new Date(start).getUTCDay() + 6) % 7;
+ const cells = Array.from({ length: lead }, (_, at) => (0, import_react38.createElement)("span", { key: `b${at}` }));
+ for (let day = 1; day <= length; day += 1) {
+ const ms = start + (day - 1) * DAY_MS;
+ const key = dayKey(ms);
+ const weekday = (lead + day - 1) % 7;
+ const flag = (on) => on ? "" : void 0;
+ cells.push((0, import_react38.createElement)("button", {
+ key,
+ type: "button",
+ className: "bt-us-day",
+ disabled: ms > todayMs,
+ "aria-label": dates({ year: "numeric", month: "long", day: "numeric", weekday: "short" }).format(ms),
+ "aria-pressed": key === low || key === high,
+ "data-in": flag(low !== null && key >= low && key <= high),
+ "data-start": flag(key === low),
+ "data-end": flag(key === high),
+ "data-l": flag(weekday === 0 || day === 1),
+ "data-r": flag(weekday === 6 || day === length),
+ "data-today": flag(key === today),
+ "data-has": flag(active.has(key)),
+ onMouseEnter: () => setHover(key),
+ onClick: () => press(key)
+ }, (0, import_react38.createElement)("b", null, day)));
+ }
+ const nav = side === "left" ? (0, import_react38.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "left", "aria-label": t("Previous month"), disabled: start <= oldest, onClick: () => setRight(addMonths(right, -1)) }, (0, import_react38.createElement)(ChevronLeftIcon)) : (0, import_react38.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "right", "aria-label": t("Next month"), disabled: start >= thisMonth, onClick: () => setRight(addMonths(right, 1)) }, (0, import_react38.createElement)(ChevronRightIcon));
+ return (0, import_react38.createElement)(
+ "div",
+ { key: start, className: "bt-us-month" },
+ (0, import_react38.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react38.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))),
+ (0, import_react38.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react38.createElement)("span", { key: at }, name))),
+ (0, import_react38.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells)
+ );
};
- const hasStrip = canBrowse && tabState.ids.length > 0;
- return (0, import_react44.createElement)(
- "aside",
- {
- ref: root,
- className: "bt-panel",
- "data-view": view,
- "aria-label": view === "browser" && bot ? t("{name}'s browser", { name: bot.name }) : t("Conversation details"),
- "data-leaving": leaving || void 0,
- "aria-hidden": leaving || void 0,
- "data-settling": settling || void 0,
- "data-instant": instant || void 0,
- "data-dragging": dragging || void 0,
- style: { "--bt-panel-w": `${panelWidth}px` },
- onAnimationEnd: (event) => {
- if (event.target === event.currentTarget) setLanded(true);
- }
- },
- leaving ? null : (0, import_react44.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }),
- // Over the whole window while dragging: the cursor stays a resize cursor and the
- // pages underneath (the chat, the guest) see no pointer.
- dragging ? (0, import_react_dom10.createPortal)((0, import_react44.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null,
- hasStrip ? (0, import_react44.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null,
- view === "browser" && bot ? (0, import_react44.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react44.createElement)(
+ const shortcuts = [
+ ["month", t("This month"), dayKey(thisMonth), today],
+ ["last", t("Last month"), dayKey(addMonths(thisMonth, -1)), dayKey(thisMonth - DAY_MS)],
+ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today]
+ ];
+ const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1;
+ return (0, import_react38.createElement)(
+ "div",
+ { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") },
+ (0, import_react38.createElement)(
"div",
- { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" },
- (0, import_react44.createElement)(DetailsDrawer, {
- sessionId: pane.id,
- startRenaming: pane.renaming,
- startTab: pane.tab,
- hasStrip,
- roster,
- actions: panelActions,
- useSessions,
- useSessionStatus,
- useActivity,
- useWork,
- useBrowserTabs,
- useBrowserPages
- })
+ { className: "bt-us-pick-quick" },
+ shortcuts.map(([id, label, from, to]) => (0, import_react38.createElement)("button", {
+ key: id,
+ type: "button",
+ "aria-pressed": span?.from === from && span?.to === to,
+ onClick: () => onPick(from, to)
+ }, label))
+ ),
+ (0, import_react38.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-pick-foot", role: "status" },
+ (0, import_react38.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")),
+ low === null ? null : (0, import_react38.createElement)("span", null, (0, import_react38.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`)
)
);
}
-var BROWSER_CSS = `
-.bt-panel{--bt-panel-view-fade:${SPRING_SHAPE.duration}ms}
-.bt-ptabs{position:relative;display:flex;align-items:center;height:44px;flex:none;border-bottom:.5px solid var(--bt-line);padding:0 6px;gap:0;animation:bt-strip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
-@keyframes bt-strip-in{from{height:0;opacity:0}}
-.bt-ptabs-in{position:relative;display:flex;align-items:center;gap:2px;flex:1;min-width:0;height:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;padding:4px 2px;scroll-padding-left:48px;mask-image:linear-gradient(to right,#000,#000 calc(100% - 10px),transparent)}
-.bt-ptabs-in>.bt-ptab-bot{position:sticky;left:0;z-index:2;background:var(--bt-main)}
-.bt-ptabs-in>.bt-ptab-bot[aria-selected=true]{background:transparent}
-.bt-ptabs-in::-webkit-scrollbar{display:none}
-.bt-ptab{position:relative;z-index:1;display:inline-flex;align-items:center;gap:6px;height:32px;max-width:180px;padding:0 8px;flex:none;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12.5px;cursor:pointer;white-space:nowrap;transition:color .12s ease}
-.bt-ptab:hover{color:var(--bt-ink)}
-.bt-ptab[aria-selected=true]{color:var(--bt-ink)}
-.bt-ptab-bot{padding:0 7px}
-.bt-ptab-bot .bt-mark{flex:none}
-.bt-ptab-name{max-width:96px;overflow:hidden;text-overflow:ellipsis;font-weight:500;animation:bt-ptab-label .18s ease}
-@keyframes bt-ptab-label{from{opacity:0;filter:blur(4px);max-width:0}}
-.bt-ptab-fav{width:14px;height:14px;flex:none;border-radius:3px;object-fit:cover;color:var(--bt-ink-3)}
-.bt-ptab-globe{display:inline-flex}
-.bt-ptab-globe svg{width:14px;height:14px}
-.bt-ptab-title{max-width:140px;overflow:hidden;text-overflow:ellipsis}
-.bt-ptab-x{flex:none;width:16px;height:16px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--bt-ink-3);opacity:0;transition:opacity .12s ease,background-color .12s ease}
-.bt-ptab:hover .bt-ptab-x,.bt-ptab[aria-selected=true] .bt-ptab-x{opacity:1}
-.bt-ptab-x:hover{background:var(--bt-active);color:var(--bt-ink)}
-.bt-ptab-new{position:relative;z-index:1;flex:none;width:28px;height:32px;border:0;border-radius:9px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
-.bt-ptab-new:hover{color:var(--bt-ink)}
-.bt-ptab-new svg{width:15px;height:15px}
-.bt-ptab-ind{position:absolute;top:4px;bottom:4px;border-radius:9px;background:var(--bt-hover);opacity:0;pointer-events:none;z-index:0;transition:opacity .15s ease}
-.bt-ptab-close{flex:none;margin-left:2px}
-.bt-panel-ghost{position:absolute;top:44px;right:0;bottom:0;overflow:hidden;pointer-events:none;z-index:2}
-.bt-panel-ghost>.bt-panel-view{position:absolute;inset:0}
-.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column;animation:bt-panel-view-in .22s cubic-bezier(.16,1,.3,1)}
-@keyframes bt-panel-view-in{from{opacity:0}}
-/* While the panel changes width its views keep their final width on the right edge, so
- only the panel edge moves and nothing inside reflows frame by frame. */
-.bt-panel[data-settling]{overflow:hidden}
-.bt-panel[data-settling]>.bt-ptabs,.bt-panel[data-settling]>.bt-panel-view{flex-shrink:0;align-self:flex-end;box-sizing:border-box;width:calc(var(--bt-panel-w,320px) - .5px)}
-.bt-panel-browser{position:relative}
-.bt-browser-bar{position:relative;display:flex;align-items:center;gap:2px;height:52px;padding:0 10px 0 12px;flex:none;border-bottom:.5px solid var(--bt-line)}
-.bt-browser-bar .bt-icon-btn:disabled{opacity:.35;cursor:default;background:none}
-.bt-browser-address{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:32px;margin:0 4px;padding:0 12px;border-radius:999px;background:var(--bt-hover);border:.5px solid transparent;transition:background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
-.bt-browser-address:hover{background:var(--bt-active)}
-.bt-browser-address[data-editing]{background:var(--bt-main);border-color:var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04)}
-.bt-browser-scheme{display:inline-flex;color:var(--bt-ink-3);flex:none}
-.bt-browser-scheme svg{width:14px;height:14px}
-.bt-browser-address input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;padding:0;text-overflow:ellipsis}
-.bt-browser-address input::placeholder{color:var(--bt-ink-3)}
-.bt-browser-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;pointer-events:none}
-.bt-browser-progress::after{content:"";position:absolute;inset:0;width:40%;background:var(--bt-accent,#4D6BFE);border-radius:2px;animation:bt-browser-load 1.1s cubic-bezier(.4,0,.2,1) infinite}
-@keyframes bt-browser-load{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
-.bt-browser-frame{position:relative;flex:1;min-height:0;background:var(--bt-main)}
-.bt-browser-frame[data-guest]{background:#fff}
-.bt-browser-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:32px;text-align:center;animation:bt-foot-in .3s ease}
-.bt-browser-empty-title{font-size:15px;line-height:22px;font-weight:500;margin-top:6px}
-.bt-browser-empty-text{max-width:300px;font-size:13px;line-height:19px;color:var(--bt-ink-2);overflow-wrap:anywhere}
-.bt-browser-retry{margin-top:6px;height:32px;padding:0 14px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer}
-.bt-browser-retry:hover{background:var(--bt-hover)}
-.bt-bcards{display:flex;flex-direction:column;gap:12px}
-.bt-bcard{position:relative;border:.5px solid var(--bt-line-2);border-radius:12px;overflow:hidden;background:var(--bt-main);animation:bt-bcard-in .24s cubic-bezier(.16,1,.3,1) both;transition:border-color .12s ease,box-shadow .12s ease}
-@keyframes bt-bcard-in{from{opacity:0;filter:blur(4px);transform:translateY(4px)}}
-.bt-bcard[data-active]{border-color:var(--bt-accent,#4D6BFE);box-shadow:0 0 0 1px var(--bt-accent,#4D6BFE)}
-.bt-bcard-shot{position:relative;display:block;width:100%;aspect-ratio:16/10;border:0;background:var(--bt-hover);padding:0;cursor:pointer}
-.bt-bcard-blank{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--bt-ink-3);font-size:12px}
-.bt-bcard-blank svg{width:20px;height:20px}
-.bt-bcard-preview{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:14px 16px;text-align:left;background:linear-gradient(to bottom,var(--bt-main),var(--bt-hover));overflow:hidden}
-.bt-bcard-ptitle{font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
-.bt-bcard-excerpt{font-size:12px;line-height:17px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
-.bt-bcard-row{display:flex;align-items:center;gap:7px;padding:8px 10px;min-width:0}
-.bt-bcard-fav{width:14px;height:14px;flex:none;border-radius:3px;color:var(--bt-ink-3)}
-.bt-bcard-row svg.bt-bcard-fav{width:14px;height:14px}
-.bt-bcard-title{flex:1;min-width:0;font-size:12.5px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink)}
-.bt-bcard-host{flex:none;font-size:11px;line-height:15px;color:var(--bt-ink-3);max-width:38%;overflow:hidden;text-overflow:ellipsis}
-.bt-bcard-x{position:absolute;top:6px;right:6px;width:22px;height:22px;border:0;border-radius:7px;background:color-mix(in srgb,var(--bt-main) 88%,transparent);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;opacity:0;transition:opacity .12s ease;backdrop-filter:blur(4px)}
-.bt-bcard:hover .bt-bcard-x{opacity:1}
-.bt-bcard-new{display:flex;align-items:center;justify-content:center;gap:6px;height:40px;border:.5px dashed var(--bt-line-2);border-radius:12px;background:none;color:var(--bt-ink-3);font:inherit;font-size:12.5px;cursor:pointer}
-.bt-bcard-new:hover{color:var(--bt-ink);border-color:var(--bt-ink-3)}
-.bt-bcard-new svg{width:14px;height:14px}
-.bt-browser-empty-mark{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center}
-@media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}}
-`;
-
-// src/client/remote-bot-settings.js
-var import_react45 = require("react");
-var REMOTE_BOT_SETTINGS_CSS = `
-.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
-.bt-remote-sheet-layer[data-leaving]{pointer-events:none}
-.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto}
-.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
-.bt-remote-sheet:focus{outline:none}
-.bt-remote-sheet{position:relative;z-index:1;pointer-events:auto;width:min(320px,calc(100vw - 32px));max-height:min(70vh,420px);margin:0 auto 0;border-radius:14px;background:var(--bt-card,#fff);border:.5px solid var(--bt-line-2);box-shadow:0 12px 40px rgba(20,20,20,.16);display:flex;flex-direction:column;overflow:hidden;animation:bt-remote-sheet-in .2s ease-out}
-.bt-remote-sheet-layer[data-leaving] .bt-remote-sheet{animation:bt-remote-sheet-out .16s ease-in forwards}
-.bt-remote-sheet-head{display:flex;align-items:center;gap:10px;padding:12px 12px 8px}
-.bt-remote-sheet-who{display:flex;flex-direction:column;min-width:0;gap:1px;flex:1 1 auto}
-.bt-remote-sheet-name{font-size:16px;line-height:22px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-remote-sheet-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.bt-remote-sheet-body{padding:0 12px 14px;overflow:auto}
-@keyframes bt-remote-sheet-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
-@keyframes bt-remote-sheet-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(8px)}}
-@media (min-width:520px){
-.bt-remote-sheet-layer{align-items:center;padding:24px}
-.bt-remote-sheet{margin:0}
-}
-`;
-function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) {
- const close = () => actions.closeSettings();
- useEscape(close);
- const dialog = (0, import_react45.useRef)(null);
- (0, import_react45.useEffect)(() => {
- const before = document.activeElement;
- dialog.current?.focus?.({ preventScroll: true });
- return () => {
- if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true });
- };
- }, []);
- const onKeyDown = (event) => {
- if (event.key === "Escape") {
- event.stopPropagation();
- close();
+function Heat({ view, keep, paint, index }) {
+ const [hover, setHover] = (0, import_react38.useState)(null);
+ const today = hourMs(`${view.now.slice(0, 10)} 00`);
+ const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS;
+ const from = dayKey(first);
+ const daily = /* @__PURE__ */ new Map();
+ for (const row of view.rows) {
+ const day = row[HOUR].slice(0, 10);
+ if (day >= from && keep(row)) daily.set(day, (daily.get(day) ?? 0) + tokensOf(row));
+ }
+ const most = Math.max(0, ...daily.values());
+ const levels = [0.3, 0.52, 0.76, 1];
+ const months = [];
+ const cells = [];
+ for (let week = 0; week < HEAT_WEEKS; week += 1) {
+ const monday = first + week * 7 * DAY_MS;
+ if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) {
+ months.push((0, import_react38.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday)));
}
- };
- if (!bot?.remote) return null;
- const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot");
- return (0, import_react45.createElement)(
- "div",
- { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" },
- (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
- (0, import_react45.createElement)(
+ for (let day = 0; day < 7; day += 1) {
+ const ms = monday + day * DAY_MS;
+ const value = daily.get(dayKey(ms)) ?? 0;
+ const level = value > 0 ? Math.max(1, Math.ceil(4 * Math.sqrt(value / most))) : 0;
+ cells.push((0, import_react38.createElement)("span", {
+ key: ms,
+ className: "bt-us-cell",
+ style: { "--w": week },
+ "data-on": hover?.ms === ms ? "" : void 0,
+ "data-future": ms > today ? "" : void 0,
+ "data-today": ms === today ? "" : void 0,
+ onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day })
+ }, level > 0 ? (0, import_react38.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null));
+ }
+ }
+ const tipSide = hover ? {
+ ...hover.week >= HEAT_WEEKS / 2 ? { right: `calc(${(HEAT_WEEKS - hover.week) / HEAT_WEEKS * 100}% + 8px)` } : { left: `calc(${(hover.week + 1) / HEAT_WEEKS * 100}% + 8px)` },
+ ...hover.day < 4 ? { top: `calc(${hover.day / 7 * 100}% - 2px)` } : { top: "auto", bottom: `calc(${(6 - hover.day) / 7 * 100}% - 2px)` }
+ } : null;
+ const active = [...daily.values()].filter((value) => value > 0).length;
+ const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active });
+ return (0, import_react38.createElement)(
+ "section",
+ { className: "bt-us-card bt-us-heat", style: { "--i": index } },
+ (0, import_react38.createElement)("div", { className: "bt-us-card-head" }, (0, import_react38.createElement)("h3", null, t("Activity")), (0, import_react38.createElement)("span", { className: "bt-us-head-note" }, summary)),
+ (0, import_react38.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months),
+ (0, import_react38.createElement)(
"div",
- { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown },
- (0, import_react45.createElement)(
+ { className: "bt-us-heat-plot", onMouseLeave: () => setHover(null) },
+ (0, import_react38.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells),
+ hover ? (0, import_react38.createElement)(
"div",
- { className: "bt-remote-sheet-head" },
- (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }),
- (0, import_react45.createElement)(
- "div",
- { className: "bt-remote-sheet-who" },
- (0, import_react45.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name),
- (0, import_react45.createElement)("span", { className: "bt-remote-sheet-sub" }, who)
- ),
- (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react45.createElement)(CloseIcon))
- ),
- (0, import_react45.createElement)(
+ { className: "bt-us-tip bt-us-heat-tip", style: tipSide },
+ (0, import_react38.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)),
+ (0, import_react38.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) }))
+ ) : null
+ ),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-heat-scale", "aria-hidden": true },
+ t("Light"),
+ levels.map((level) => (0, import_react38.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react38.createElement)("i", { style: { background: soften(paint, level) } }))),
+ t("Heavy")
+ )
+ );
+}
+function Skeleton2() {
+ return (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us", "aria-busy": true },
+ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }),
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-sheet bt-us-skel-sheet" },
+ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }),
+ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }),
+ (0, import_react38.createElement)(
"div",
- { className: "bt-remote-sheet-body" },
- bot.remote.handle ? (0, import_react45.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null,
- (0, import_react45.createElement)(FlyBotDetails, { bot, roster, actions })
+ { className: "bt-us-pair" },
+ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }),
+ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 200 } })
)
)
);
}
-
-// src/client/overlays.js
-var import_react46 = require("react");
-var import_react_dom11 = require("react-dom");
-var DIALOG_EXIT_MS = 160;
-var MENU_EXIT_MS3 = 120;
-function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) {
- const roster = useRoster((value) => value);
- const ui2 = useUi((value) => value);
- useRegistry((value) => value);
- const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null);
- const previous = (0, import_react46.useRef)(current);
- (0, import_react46.useEffect)(() => {
- if (previous.current === current) return;
- previous.current = current;
- if (current) actions.followPanel?.(current);
- }, [current]);
- const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null;
- const [kept, setKept] = (0, import_react46.useState)(open);
- (0, import_react46.useEffect)(() => {
- if (open) {
- setKept(open);
- return void 0;
+var focusOf = (id) => typeof id !== "string" ? null : id.startsWith("model:") ? { kind: "model", key: id.slice("model:".length) } : id.startsWith("owner:") ? { kind: "owner", key: id.slice("owner:".length) } : null;
+function UsagePage({ roster, actions, focusId }) {
+ const [view, setView] = (0, import_react38.useState)(null);
+ const [error, setError] = (0, import_react38.useState)("");
+ const [rangeId, setRangeId] = (0, import_react38.useState)("7d");
+ const [span, setSpan] = (0, import_react38.useState)(null);
+ const [picking, setPicking] = (0, import_react38.useState)(false);
+ const [by, setBy] = (0, import_react38.useState)("bot");
+ const tabs = (0, import_react38.useRef)(null);
+ const focus = focusOf(focusId);
+ (0, import_react38.useEffect)(() => {
+ let stopped = false;
+ let timer;
+ const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => {
+ })));
+ const load = async () => {
+ try {
+ const value = await actions.usage();
+ await resized;
+ if (stopped) return;
+ (0, import_react38.startTransition)(() => setView(value));
+ setError("");
+ timer = setTimeout(load, value.scanning ? 3e3 : 2e4);
+ } catch (failure) {
+ if (stopped) return;
+ setError(failure?.message ?? String(failure));
+ timer = setTimeout(load, 1e4);
+ }
+ };
+ void load();
+ return () => {
+ stopped = true;
+ clearTimeout(timer);
+ };
+ }, []);
+ const today = view?.now.slice(0, 10);
+ const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId);
+ const shaped = (0, import_react38.useMemo)(() => {
+ if (!view) return null;
+ const faces2 = view.owners.map((owner) => ownerFace(owner, roster));
+ const lifetime = /* @__PURE__ */ new Map();
+ for (const row of view.rows) lifetime.set(row[MODEL], (lifetime.get(row[MODEL]) ?? 0) + tokensOf(row));
+ const modelPaint = new Map([...lifetime].sort((a, b) => b[1] - a[1]).map(([model], rank) => [model, `var(--bt-ink-${MODEL_INKS[rank % MODEL_INKS.length]})`]));
+ const models2 = view.models.map((model, at) => {
+ const name = model.name || model.model || t("Unknown model");
+ const paint = modelPaint.get(at) ?? "var(--bt-us-rest)";
+ return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react38.createElement)(ModelGlyph, { name, paint, size }) };
+ });
+ const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1;
+ const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1;
+ const isBot2 = (at) => ["bot", "agent"].includes(view.owners[at]?.kind);
+ const all2 = summarize(view, range, void 0, (row) => by === "bot" ? isBot2(row[OWNER]) ? `o${row[OWNER]}` : "rest" : `m${row[MODEL]}`);
+ const keep2 = ownerAt2 >= 0 ? (row) => row[OWNER] === ownerAt2 : modelAt >= 0 ? (row) => row[MODEL] === modelAt : () => true;
+ const face2 = ownerAt2 >= 0 ? faces2[ownerAt2] : modelAt >= 0 ? models2[modelAt] : null;
+ const part2 = face2 ? summarize(view, range, keep2) : all2;
+ const activeDays2 = /* @__PURE__ */ new Set();
+ let first = view.now;
+ for (const row of view.rows) {
+ if (row[HOUR] < first) first = row[HOUR];
+ if (keep2(row) && tokensOf(row) > 0) activeDays2.add(row[HOUR].slice(0, 10));
}
- const timer = setTimeout(() => setKept(null), PANEL_LINGER_MS);
- return () => clearTimeout(timer);
- }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]);
- const pane = open ?? kept;
- const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS);
- const remoteBotId = ui2.remoteBotSettings && roster.byId[ui2.remoteBotSettings]?.remote ? ui2.remoteBotSettings : null;
- const [remoteBotSettings, remoteLeaving] = useLinger(remoteBotId, DIALOG_EXIT_MS);
- const [memory, memoryLeaving] = useLinger(ui2.memory && roster.byId[ui2.memory] ? ui2.memory : null, DIALOG_EXIT_MS);
- const [menu, menuLeaving] = useLinger(ui2.menu, MENU_EXIT_MS3);
- const [inbox, inboxLeaving] = useLinger(ui2.inbox ?? null, DIALOG_EXIT_MS);
- const [board, boardLeaving] = useLinger(ui2.board ?? null, DIALOG_EXIT_MS);
- return (0, import_react46.createElement)(
- import_react46.Fragment,
- null,
- pane ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null,
- ui2.newChat ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null,
- ui2.palette ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(CommandPalette, { key: ui2.palette.token, start: ui2.palette.query, token: ui2.palette.token, roster, actions, useSessions }), document.body) : null,
- ui2.exchange ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null,
- ui2.voice ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null,
- menu ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null,
- settings ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null,
- remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null,
- memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null,
- inbox ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null,
- board ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null
- );
-}
-
-// src/client/read-page.js
-var READ_LIMITS = {
- chars: 4e5,
- nodes: 15e4,
- depth: 400,
- ms: 1500,
- items: 3e3,
- tableRows: 5e3,
- tableCols: 30,
- frameDepth: 3
-};
-function readPage(limits) {
- const started = performance.now();
- const SKIP = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "head", "title", "meta", "link", "svg", "object", "embed", "audio", "video", "source", "track", "map", "datalist", "option", "optgroup"]);
- const ITEM = 'a[href], button, input:not([type=hidden]), textarea, select, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=switch], [role=radio], [contenteditable=""], [contenteditable=true]';
- const ROLES = { a: "link", button: "button", select: "select", textarea: "textbox", summary: "button" };
- const INPUT_ROLES = { checkbox: "checkbox", radio: "radio", submit: "button", button: "button", reset: "button", image: "button", search: "searchbox", range: "slider", file: "button", color: "button" };
- const NO_VALUE_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio", "submit", "button", "reset", "image", "file", "color"]);
- const SECRET_HINT = /(^|[-_\s])(cvv|cvc|csc|cc-?(num|number|csc|exp)|card-?(num|number)|otp|one-?time-?code|pin)([-_\s]|$)/i;
- const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|flow-root|table-caption|table-row-group|table-header-group|table-footer-group|table-row|-webkit-box)$/;
- const INLINE_BLOCK = /^(inline-block|inline-flex|inline-grid|inline-table|table-cell|-webkit-inline-box)$/;
- const SEMANTIC = /^(ul|ol|table|pre|blockquote|h[1-6]|code|details)$/;
- const items = [];
- const stash = [];
- const stats = { nodes: 0, frames: 0 };
- let chars = 0;
- let stop = "";
- const collapse = (value) => String(value || "").replace(/\s+/g, " ").trim();
- const isSecret = (el) => {
- if (el.localName !== "input" && el.localName !== "textarea") return false;
- const auto = String(el.getAttribute("autocomplete") || "").toLowerCase();
- return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`);
- };
- const keep = (text3, end = "") => {
- if (text3 === "") return "";
- stash.push(text3);
- return `
-
-${stash.length - 1}${end}
-
-`;
- };
- const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)]));
- const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim();
- const block = (text3) => expand(flow(text3));
- const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim();
- const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el);
- const childrenOf = (el) => {
- if (el.shadowRoot) return el.shadowRoot.childNodes;
- if (el.localName === "slot" && typeof el.assignedNodes === "function") {
- const assigned = el.assignedNodes();
- if (assigned.length > 0) return assigned;
+ const oldest2 = Math.min(monthOf(hourMs(first)), addMonths(monthOf(hourMs(view.now)), -12));
+ const ranked2 = (map) => [...map].filter(([, sum]) => sum.total > 0).sort((a, b) => b[1].total - a[1].total);
+ let series2;
+ if (face2) {
+ series2 = [{ key: "all", name: face2.name, fill: soften(face2.paint) }];
+ } else {
+ if (by === "bot") {
+ const bots = ranked2(all2.owners).filter(([at]) => isBot2(at));
+ series2 = bots.slice(0, TOP_SERIES).map(([at]) => ({ key: `o${at}`, name: faces2[at].name, paint: faces2[at].paint, pick: { kind: "owner", key: view.owners[at].key } }));
+ if (bots.length > TOP_SERIES) series2.push({ key: "more", name: t("More Bots"), paint: "var(--bt-us-more)" });
+ if (ranked2(all2.owners).some(([at]) => !isBot2(at))) series2.push({ key: "rest", name: t("Other"), paint: "var(--bt-us-rest)" });
+ } else {
+ const used = ranked2(all2.models);
+ series2 = used.slice(0, TOP_SERIES).map(([at]) => ({ key: `m${at}`, name: models2[at].name, paint: models2[at].paint, pick: { kind: "model", key: view.models[at].key } }));
+ if (used.length > TOP_SERIES) series2.push({ key: "more", name: t("More models"), paint: "var(--bt-us-more)" });
+ }
+ const repeats = /* @__PURE__ */ new Map();
+ for (const entry of series2) {
+ const seen = repeats.get(entry.paint) ?? 0;
+ repeats.set(entry.paint, seen + 1);
+ entry.fill = soften(entry.paint, [1, 0.6, 0.36][seen % 3]);
+ }
}
- return el.childNodes;
- };
- const over = () => {
- if (stop) return true;
- if (chars > limits.chars) stop = `the text passed ${limits.chars} characters`;
- else if (stats.nodes > limits.nodes) stop = `the page has more than ${limits.nodes} nodes`;
- else if (stats.nodes % 512 === 0 && performance.now() - started > limits.ms) stop = `reading took longer than ${limits.ms} ms`;
- return stop !== "";
- };
- const roleOf = (el) => el.getAttribute("role") || ROLES[el.localName] || (el.localName === "input" ? INPUT_ROLES[el.type] || "textbox" : el.isContentEditable ? "textbox" : el.localName);
- const sized = (el) => {
- const box = el.getBoundingClientRect();
- return box.width > 0 && box.height > 0;
- };
- const itemFor = (el, style) => {
- if (items.length >= limits.items || style.visibility !== "visible" || !el.matches(ITEM) || !sized(el)) return null;
- const item = { role: roleOf(el) };
- const secret = isSecret(el);
- if (el.href) item.href = String(el.href);
- if ((el.localName === "input" || el.localName === "textarea") && !secret && !NO_VALUE_TYPES.has(el.type) && el.value) item.value = String(el.value).slice(0, 120);
- if (el.localName === "select" && el.selectedOptions?.length) item.value = collapse([...el.selectedOptions].map((option) => option.label || option.text).join(", ")).slice(0, 120);
- if (el.checked) item.checked = true;
- if (el.disabled) item.disabled = true;
- items.push(item);
- return { item, number: items.length, secret };
- };
- const nameOf = (el, text3, secret) => collapse(
- el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || ""
- ).slice(0, 80);
- const fenced = (code, lang) => {
- const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length));
- const fence = "`".repeat(longest + 1);
- return `${fence}${lang}
-${code.replace(/\n+$/, "")}
-${fence}`;
- };
- const langOf = (el) => {
- const names = `${el.className || ""} ${el.querySelector?.("code")?.className || ""}`;
- return /(?:^|\s)(?:lang|language)-([\w+#.-]+)/.exec(names)?.[1] || "";
- };
- const list = (el, ctx) => {
- const ordered = el.localName === "ol";
- let number = ordered ? Number(el.getAttribute("start") || 1) || 1 : 1;
- const lines = [];
- for (const child of childrenOf(el)) {
- if (over()) break;
- if (child.nodeType !== 1 || child.localName !== "li") {
- const loose = block(render(child, ctx));
- if (loose) lines.push(loose);
- continue;
- }
- const style = styleOf(child);
- if (style.display === "none") continue;
- let body2 = block(children(child, { ...ctx, depth: ctx.depth + 1, shown: style.visibility === "visible" }));
- if (!body2.includes("```")) body2 = body2.replace(/\n{2,}/g, "\n");
- const marker = ordered ? `${number++}. ` : "- ";
- if (body2 === "") continue;
- const pad2 = " ".repeat(marker.length);
- lines.push(body2.split("\n").map((line2, index) => (index === 0 ? marker : line2 ? pad2 : "") + line2).join("\n"));
- }
- return keep(lines.join("\n"));
- };
- const cellText = (cell, ctx) => block(children(cell, ctx)).replace(/\n+/g, "
").replace(/\|/g, "\\|");
- const layoutTable = (table, rows) => {
- const role = table.getAttribute("role");
- if (role === "presentation" || role === "none") return true;
- if (table.querySelector("table")) return true;
- return !rows.some((row) => row.cells.length > 1);
- };
- const tableMarkdown = (table, ctx) => {
- const rows = [...table.rows].filter((row) => styleOf(row).display !== "none");
- const caption = table.caption ? oneLine(children(table.caption, ctx)) : "";
- if (layoutTable(table, rows)) {
- return rows.map((row) => [...row.cells].map((cell) => `
-
-${children(cell, ctx)}
-
-`).join("")).join("");
- }
- const grid = [];
- const carry = [];
- let width = 0;
- for (const row of rows.slice(0, limits.tableRows + 1)) {
- if (over()) break;
- const line2 = [];
- let column = 0;
- const place = () => {
- while (carry[column]?.left > 0) {
- line2[column] = carry[column].text;
- carry[column].left -= 1;
- column += 1;
- }
- };
- for (const cell of row.cells) {
- place();
- if (styleOf(cell).display === "none") continue;
- const text3 = cellText(cell, ctx);
- const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1));
- const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1));
- for (let offset = 0; offset < span; offset += 1) {
- line2[column] = offset === 0 ? text3 : "";
- if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 };
- column += 1;
- }
- }
- place();
- width = Math.max(width, line2.length);
- grid.push(line2);
+ const tipSeries2 = face2 ? PARTS.map(([key, strength]) => ({ key, name: partLabel(key), fill: soften(face2.paint, strength) })) : series2;
+ const fillOf2 = new Map(series2.map((entry) => [entry.key, entry.fill]));
+ const shownKeys = new Set(series2.map((entry) => entry.key));
+ const columns2 = part2.buckets.map((bucket) => {
+ const parts = face2 ? [["all", bucket.sum.total]] : series2.map((entry) => [entry.key, entry.key === "more" ? [...bucket.stacks].filter(([key]) => key !== "rest" && !shownKeys.has(key)).reduce((sum, [, value]) => sum + value, 0) : bucket.stacks.get(entry.key) ?? 0]);
+ const tip = face2 ? PARTS.map(([key]) => [key, bucket.sum[key]]) : parts;
+ return { key: bucket.key, tick: tickOf(range, bucket), title: titleOf(range, bucket), total: bucket.sum.total, parts, tip };
+ });
+ return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 };
+ }, [view, rangeId, span, by, focusId, roster]);
+ const root = (0, import_react38.useRef)(null);
+ (0, import_react38.useEffect)(() => {
+ root.current?.closest(".bt-settings-scroll")?.scrollTo?.({ top: 0 });
+ }, [focusId]);
+ if (!view) {
+ return error ? (0, import_react38.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react38.createElement)(Skeleton2);
+ }
+ if (view.rows.length === 0) {
+ return (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us" },
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-empty" },
+ (0, import_react38.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }),
+ (0, import_react38.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")),
+ (0, import_react38.createElement)("span", null, t("Token usage shows up here after a model is called."))
+ )
+ );
+ }
+ const { faces, models, ownerAt, face, keep, all, part, series, tipSeries, fillOf, columns, ranked, isBot, activeDays, oldest } = shaped;
+ const pick2 = (next) => actions.openSettings("usage", `${next.kind}:${next.key}`);
+ const chooseRange = (id) => {
+ if (id !== "custom") {
+ setRangeId(id);
+ setPicking(false);
+ return;
}
- width = Math.min(width, limits.tableCols);
- if (grid.length === 0 || width === 0) return caption ? `
-
-${caption}
-
-` : "";
- const format = (cells) => `| ${Array.from({ length: width }, (_, index) => cells[index] ?? "").join(" | ")} |`;
- const out = [format(grid[0]), `|${" --- |".repeat(width)}`, ...grid.slice(1).map(format)];
- if (rows.length > limits.tableRows + 1) out.push(`… (table cut: ${rows.length - limits.tableRows - 1} more rows)`);
- return `${caption ? `
-
-Table: ${caption}` : ""}${keep(out.join("\n"))}`;
+ if (span) setRangeId("custom");
+ setPicking((open) => !open);
};
- const frame = (el, ctx) => {
- let doc = null;
- try {
- doc = el.contentDocument;
- } catch {
- doc = null;
- }
- const address = (() => {
- try {
- return doc?.location?.href || el.src || el.getAttribute("src") || "about:blank";
- } catch {
- return el.src || "about:blank";
- }
- })();
- if (!doc) return `
-
-[iframe (cross-origin, not readable): ${address}]
-
-`;
- if (ctx.frames >= limits.frameDepth) return `
-
-[iframe (nested too deep, not read): ${address}]
-
-`;
- stats.frames += 1;
- const title = collapse(el.title || doc.title);
- const body2 = doc.body ? block(children(doc.body, { ...ctx, frames: ctx.frames + 1, depth: ctx.depth + 1, shown: true, pre: false })) : "";
- return keep(`[iframe: ${title ? `${title} (${address})` : address}]
-${body2 || "(no text)"}
-[iframe end]`);
+ const pickSpan = (from, to) => {
+ setSpan({ from, to });
+ setRangeId("custom");
+ setPicking(false);
};
- function children(el, ctx) {
- let out = "";
- for (const child of childrenOf(el)) {
- if (over()) break;
- out += render(child, ctx);
- }
- return out;
+ const rangeOptions = [
+ ...RANGES.map((item) => [item.id, rangeLabel(item.id)]),
+ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon]
+ ];
+ const whole = part.total.total;
+ const ownerRow = ([at, sum], index, total = whole) => (0, import_react38.createElement)(Row3, {
+ key: `o${at}`,
+ kind: faces[at].kind,
+ glyph: faces[at].glyph(32),
+ name: faces[at].name,
+ sub: faces[at].sub,
+ sum,
+ whole: total,
+ index,
+ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint),
+ onClick: () => pick2({ kind: "owner", key: view.owners[at].key })
+ });
+ const modelRow = ([at, sum], index) => (0, import_react38.createElement)(Row3, {
+ key: `m${at}`,
+ kind: "model",
+ glyph: models[at].glyph(32),
+ name: models[at].name,
+ sub: models[at].sub,
+ sum,
+ whole,
+ index,
+ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint),
+ onClick: () => pick2({ kind: "model", key: view.models[at].key })
+ });
+ const nothing = (0, import_react38.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range."));
+ let body;
+ if (face) {
+ const kinds = ranked(part.kinds);
+ const usedModels = ranked(part.models);
+ const owners = ranked(part.owners);
+ body = [
+ (0, import_react38.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }),
+ (0, import_react38.createElement)(
+ "div",
+ { key: "sheet", className: "bt-us-sheet" },
+ (0, import_react38.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }),
+ (0, import_react38.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }),
+ (0, import_react38.createElement)(
+ "div",
+ { key: "pair", className: "bt-us-pair" },
+ ownerAt >= 0 ? (0, import_react38.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react38.createElement)(ListCard, { title: t("Who used it"), count: owners.length, index: 2 }, owners.length > 0 ? owners.map((entry, index) => ownerRow(entry, index)) : nothing),
+ (0, import_react38.createElement)(
+ ListCard,
+ { title: t("What for"), index: 3 },
+ kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react38.createElement)(Row3, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing
+ )
+ ),
+ (0, import_react38.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 })
+ )
+ ];
+ } else {
+ const owners = ranked(all.owners);
+ const bots = owners.filter(([at]) => isBot(at));
+ const rest = owners.filter(([at]) => !isBot(at));
+ const usedModels = ranked(all.models);
+ body = [
+ (0, import_react38.createElement)(
+ "div",
+ { key: "sheet", className: "bt-us-sheet" },
+ (0, import_react38.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }),
+ (0, import_react38.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }),
+ (0, import_react38.createElement)(
+ "div",
+ { key: "pair", className: "bt-us-pair" },
+ (0, import_react38.createElement)(
+ ListCard,
+ { title: t("Bots"), count: bots.length, index: 2 },
+ owners.length > 0 ? null : nothing,
+ bots.map((entry, index) => ownerRow(entry, index)),
+ bots.length > 0 && rest.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-us-split", role: "separator" }) : null,
+ rest.map((entry, index) => ownerRow(entry, bots.length + index))
+ ),
+ (0, import_react38.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing)
+ ),
+ (0, import_react38.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 })
+ )
+ ];
}
- function render(node, ctx) {
- stats.nodes += 1;
- if (node.nodeType === 3) {
- if (!ctx.shown) return "";
- const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
- const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " ");
- chars += text3.length;
- return text3;
- }
- if (node.nodeType !== 1) return "";
- const el = node;
- const tag = el.localName;
- if (SKIP.has(tag) || ctx.depth > limits.depth || over()) return "";
- const style = styleOf(el);
- const display = style.display;
- if (display === "none" || style.contentVisibility === "hidden") return "";
- const shown = style.visibility === "visible";
- const keepsSpace = String(style.whiteSpace || "").startsWith("pre") || style.whiteSpace === "break-spaces";
- const next = { ...ctx, depth: ctx.depth + 1, shown };
- if (display === "contents" && !SEMANTIC.test(tag) && !el.matches(ITEM)) return children(el, next);
- if (style.overflowX !== "visible" || style.overflowY !== "visible") {
- const box = el.getBoundingClientRect();
- if (box.width === 0 || box.height === 0) return "";
- }
- if (tag === "br") return "\n";
- if (tag === "hr") return "\n\n---\n\n";
- if (tag === "img") {
- const alt = collapse(el.getAttribute("alt"));
- return shown && alt ? `[image: ${alt}]` : "";
+ return (0, import_react38.createElement)(
+ "div",
+ { ref: root, className: "bt-us", key: focusId ?? "all" },
+ (0, import_react38.createElement)(
+ "div",
+ { className: "bt-us-top" },
+ (0, import_react38.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }),
+ view.scanning ? (0, import_react38.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null,
+ picking ? (0, import_react38.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null
+ ),
+ error ? (0, import_react38.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
+ body
+ );
+}
+var USAGE_CSS = `
+.bt-us{--bt-us-ease:cubic-bezier(.22,1,.36,1);--bt-us-rest:color-mix(in srgb,var(--bt-ink) 30%,var(--bt-card));--bt-us-more:color-mix(in srgb,var(--bt-ink) 16%,var(--bt-card));display:flex;flex-direction:column;gap:14px;padding-bottom:4px}
+.bt-us-top{position:relative;z-index:4;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+.bt-us-tabs{position:relative;display:inline-flex;flex:none;padding:3px;border-radius:999px;background:var(--bt-hover)}
+.bt-us-tabs button{position:relative;z-index:1;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 12px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:26px;white-space:nowrap;cursor:pointer;transition:color .15s ease}
+.bt-us-tabs button svg{flex:none;width:13px;height:13px;margin-left:-2px}
+.bt-us-pick{position:absolute;top:calc(100% + 8px);left:0;max-width:100%;box-sizing:border-box;padding:14px 16px 12px;border-radius:18px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 22px 50px -18px rgba(0,0,0,.32),0 2px 8px rgba(0,0,0,.05);transform-origin:24px 0;animation:bt-us-drop .2s var(--bt-us-ease)}
+.bt-us-pick-quick{display:flex;gap:6px;margin-bottom:12px}
+.bt-us-pick-quick button{height:26px;padding:0 11px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease}
+.bt-us-pick-quick button:hover{background:var(--bt-active);color:var(--bt-ink)}
+.bt-us-pick-quick button[aria-pressed=true]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:var(--bt-accent)}
+.bt-us-pick-months{display:flex;flex-wrap:wrap;gap:8px 28px}
+.bt-us-month{width:252px;animation:bt-us-fade .22s ease}
+.bt-us-month-head{position:relative;display:flex;align-items:center;justify-content:center;height:28px;margin-bottom:4px;font-size:13px;line-height:18px;font-weight:600}
+.bt-us-month-nav{position:absolute;top:0;width:28px;height:28px;padding:0;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease}
+.bt-us-month-nav[data-side=left]{left:0}
+.bt-us-month-nav[data-side=right]{right:0}
+.bt-us-month-nav:hover:not(:disabled){background:var(--bt-hover);color:var(--bt-ink)}
+.bt-us-month-nav:disabled{opacity:.3;cursor:default}
+.bt-us-weekdays,.bt-us-days{display:grid;grid-template-columns:repeat(7,36px)}
+.bt-us-weekdays span{height:24px;font-size:11px;line-height:24px;text-align:center;color:var(--bt-ink-3)}
+.bt-us-day{position:relative;height:36px;padding:0;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer}
+.bt-us-day::before{content:'';position:absolute;top:4px;bottom:4px;left:0;right:0;transition:background-color .12s ease}
+.bt-us-day>b{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-weight:400;transition:background-color .12s ease,color .12s ease}
+.bt-us-day:hover:not(:disabled)>b{background:var(--bt-hover)}
+.bt-us-day[data-today]>b{color:var(--bt-accent);font-weight:600}
+.bt-us-day[data-has]::after{content:'';position:absolute;z-index:2;left:50%;bottom:6px;width:3px;height:3px;margin-left:-1.5px;border-radius:50%;background:var(--bt-ink-3)}
+.bt-us-day[data-in]::before{background:color-mix(in srgb,var(--bt-accent) 13%,transparent)}
+.bt-us-day[data-in][data-l]::before{left:4px;border-radius:14px 0 0 14px}
+.bt-us-day[data-in][data-r]::before{right:4px;border-radius:0 14px 14px 0}
+.bt-us-day[data-in][data-l][data-r]::before{border-radius:14px}
+.bt-us-day[data-in][data-start]::before{left:50%;border-radius:0}
+.bt-us-day[data-in][data-end]::before{right:50%;border-radius:0}
+.bt-us-day[data-in][data-start][data-r]::before,.bt-us-day[data-in][data-end][data-l]::before,.bt-us-day[data-in][data-start][data-end]::before{display:none}
+.bt-us-day[data-start]>b,.bt-us-day[data-end]>b,.bt-us-day[data-start]:hover>b,.bt-us-day[data-end]:hover>b{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
+.bt-us-day[data-start][data-has]::after,.bt-us-day[data-end][data-has]::after{background:color-mix(in srgb,var(--bt-accent-ink,#fff) 75%,transparent)}
+.bt-us-day:disabled{color:var(--bt-ink-3);opacity:.45;cursor:default}
+.bt-us-pick-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:8px;padding-top:10px;border-top:1px solid var(--bt-line);font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap}
+.bt-us-pick-foot b{color:var(--bt-ink);font-weight:500}
+.bt-us-tabs button:hover,.bt-us-tabs button[aria-pressed=true]{color:var(--bt-ink)}
+.bt-us-tabs:not([data-glide]) button[aria-pressed=true]{background:var(--bt-card)}
+.bt-us-tabs>.bt-thumb{top:3px;bottom:3px;border-radius:999px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08),0 0 0 .5px var(--bt-line-2)}
+.bt-us-note{display:inline-flex;align-items:center;gap:8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+.bt-us-note::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--bt-accent);animation:bt-us-pulse 1.4s ease-in-out infinite}
+.bt-us-sheet{display:flex;flex-direction:column;gap:4px}
+.bt-us-sheet>.bt-us-stats,.bt-us-sheet>.bt-us-card,.bt-us-sheet>.bt-us-pair>.bt-us-card{border-radius:0}
+.bt-us-sheet>:first-child{border-radius:16px 16px 0 0}
+.bt-us-sheet>:last-child{border-radius:0 0 16px 16px}
+.bt-us-stats{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards}
+.bt-us-stat{display:flex;flex-direction:column;justify-content:flex-end;gap:6px;min-width:0;padding:16px 18px}
+.bt-us-stat+.bt-us-stat{border-left:1px solid var(--bt-line)}
+.bt-us-stat-label{overflow:hidden;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;text-overflow:ellipsis}
+.bt-us-stat-value{display:flex;align-items:baseline;gap:6px;min-width:0;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums;white-space:nowrap}
+.bt-us-stat-main .bt-us-stat-value{font-size:34px;line-height:40px;letter-spacing:-.025em}
+.bt-us-stat-value small{font-size:13px;font-weight:400;letter-spacing:0;color:var(--bt-ink-3)}
+.bt-us-delta{align-self:center;margin-left:2px;padding:0 8px;border-radius:999px;background:var(--bt-tag-bg);color:var(--bt-ink-2);font-size:11px;line-height:20px;font-weight:500;letter-spacing:0}
+.bt-us-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 18px 16px;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 45ms)}
+.bt-us-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px}
+.bt-us-card-head h3{display:flex;align-items:baseline;gap:8px;min-width:0;margin:0;font-size:13px;line-height:18px;font-weight:600}
+.bt-us-card-head h3 span,.bt-us-head-note{font-size:12px;font-weight:400;color:var(--bt-ink-3)}
+.bt-us-chart-body{display:flex;flex-direction:column;gap:8px;padding-top:6px}
+.bt-us-plot{position:relative;height:200px;margin-left:40px}
+.bt-us-grid{position:absolute;left:0;right:0;border-top:1px dashed var(--bt-line-2);pointer-events:none}
+.bt-us-grid.bt-us-base{border-top:1px solid var(--bt-line-2)}
+.bt-us-grid span{position:absolute;right:calc(100% + 8px);top:-8px;font-size:10px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
+.bt-us-cols{position:absolute;inset:0;display:flex;align-items:flex-end}
+.bt-us-col{position:relative;flex:1 1 0;min-width:0;height:100%;display:flex;align-items:flex-end;justify-content:center}
+.bt-us-stack{display:flex;flex-direction:column-reverse;gap:2px;width:56%;min-width:4px;max-width:28px;transform-origin:50% 100%;animation:bt-us-grow .6s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 14ms);transition:height .45s var(--bt-us-ease),opacity .18s ease}
+.bt-us-stack>i{flex:1 1 0;min-height:2px;border-radius:5px;transition:flex-grow .45s var(--bt-us-ease)}
+.bt-us-plot[data-hover] .bt-us-col:not([data-on]) .bt-us-stack{opacity:.3}
+.bt-us-stub{width:4px;height:4px;margin-bottom:-2px;border-radius:50%;background:var(--bt-line-2)}
+.bt-us-ticks{display:flex;margin-left:40px;font-size:11px;line-height:14px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
+.bt-us-ticks span{flex:1 1 0;min-width:0;text-align:center;white-space:nowrap}
+.bt-us-ticks span[data-now]{color:var(--bt-ink);font-weight:500}
+.bt-us-tip{position:absolute;top:6px;z-index:3;min-width:160px;max-width:240px;padding:10px 12px;border-radius:12px;border:.5px solid var(--bt-line-2);background:color-mix(in srgb,var(--bt-card) 90%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 14px 34px -12px rgba(0,0,0,.28);font-size:12px;line-height:16px;pointer-events:none;animation:bt-us-pop .16s var(--bt-us-ease)}
+.bt-us-tip-title{color:var(--bt-ink-3)}
+.bt-us-tip-total{margin:2px 0 8px;font-size:15px;line-height:20px;font-weight:600;font-variant-numeric:tabular-nums}
+.bt-us-tip-total:last-child{margin-bottom:0}
+.bt-us-tip-row{display:flex;align-items:center;gap:8px;color:var(--bt-ink-2)}
+.bt-us-tip-row+.bt-us-tip-row{margin-top:5px}
+.bt-us-tip-row span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-us-tip-row b{font-weight:500;color:var(--bt-ink);font-variant-numeric:tabular-nums}
+.bt-us-legend{display:flex;flex-wrap:wrap;gap:6px}
+.bt-us-key{display:inline-flex;align-items:center;gap:6px;min-width:0;height:24px;padding:0 10px 0 8px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:24px}
+button.bt-us-key{cursor:pointer;transition:background-color .12s ease,color .12s ease}
+button.bt-us-key:hover{background:var(--bt-active);color:var(--bt-ink)}
+.bt-us-dot{flex:none;width:8px;height:8px;border-radius:50%}
+.bt-us-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:4px}
+.bt-us-rows{display:flex;flex-direction:column;margin:0 -8px}
+.bt-us-row{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;padding:8px;border:0;border-radius:12px;background:none;color:var(--bt-ink);font:inherit;text-align:left;animation:bt-us-in .4s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 90ms)}
+button.bt-us-row{cursor:pointer;transition:background-color .12s ease}
+button.bt-us-row:hover{background:var(--bt-hover)}
+.bt-us-row-glyph{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px}
+.bt-us-row-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px}
+.bt-us-row-line{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:13px;line-height:18px}
+.bt-us-row-name{flex:0 1 auto;min-width:0;overflow:hidden;font-weight:500;text-overflow:ellipsis;white-space:nowrap}
+.bt-us-row-sub{flex:0 1 auto;min-width:0;overflow:hidden;font-size:12px;color:var(--bt-ink-3);text-overflow:ellipsis;white-space:nowrap}
+.bt-us-track{display:block;height:4px;border-radius:999px;background:var(--bt-hover);overflow:hidden}
+.bt-us-track>span{display:block;height:100%;border-radius:inherit;transform-origin:0 50%;animation:bt-us-fill .7s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 140ms);transition:width .45s var(--bt-us-ease)}
+.bt-us-row-num{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:1px;min-width:52px;font-variant-numeric:tabular-nums}
+.bt-us-row-num b{font-size:13px;line-height:18px;font-weight:600}
+.bt-us-row-num small{font-size:11px;line-height:14px;color:var(--bt-ink-3)}
+.bt-us-split{height:1px;margin:6px 8px;background:var(--bt-line)}
+.bt-us-none{padding:18px 8px;font-size:12px;color:var(--bt-ink-3);text-align:center}
+.bt-us-glyph{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2)}
+.bt-us-model{display:inline-flex;align-items:center;justify-content:center;border-radius:30%;font-weight:600;line-height:1}
+.bt-us-profile{display:flex;align-items:center;gap:20px;padding:6px 4px 2px;animation:bt-us-in .45s var(--bt-us-ease) backwards}
+.bt-us-ring{position:relative;flex:none;width:96px;height:96px}
+.bt-us-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
+.bt-us-ring>svg circle{fill:none;stroke-width:4}
+.bt-us-ring-track{stroke:var(--bt-line)}
+.bt-us-ring-arc{stroke-linecap:round;transition:stroke-dasharray .6s var(--bt-us-ease);animation:bt-us-ring 1s var(--bt-us-ease) .1s backwards}
+.bt-us-ring-face{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
+.bt-us-profile-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
+.bt-us-profile-copy h3{margin:0;overflow:hidden;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;text-overflow:ellipsis;white-space:nowrap}
+.bt-us-profile-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+.bt-us-profile-share{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+.bt-us-heat{gap:8px}
+.bt-us-heat-months,.bt-us-heat-grid{display:grid;grid-template-columns:repeat(${HEAT_WEEKS},1fr);gap:3px}
+.bt-us-heat-months{height:14px;font-size:10px;line-height:14px;color:var(--bt-ink-3)}
+.bt-us-heat-months span{white-space:nowrap}
+.bt-us-heat-plot{position:relative}
+.bt-us-heat-grid{grid-template-rows:repeat(7,auto);grid-auto-flow:column}
+.bt-us-heat-grid .bt-us-cell[data-on]{outline:1.5px solid var(--bt-ink-2);outline-offset:1px}
+.bt-us-heat-tip{min-width:0;white-space:nowrap}
+.bt-us-cell{position:relative;aspect-ratio:1;border-radius:3px;background:var(--bt-hover);overflow:hidden}
+.bt-us-heat-grid .bt-us-cell{animation:bt-us-cell .5s var(--bt-us-ease) backwards;animation-delay:calc(var(--w,0) * 7ms + 120ms)}
+.bt-us-cell>i{position:absolute;inset:0}
+.bt-us-cell[data-today]{outline:1.5px solid var(--bt-ink-3);outline-offset:1px}
+.bt-us-cell[data-future]{visibility:hidden}
+.bt-us-heat-scale{display:flex;align-items:center;justify-content:flex-end;gap:4px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
+.bt-us-heat-scale .bt-us-cell{width:10px}
+.bt-us-skel{border-radius:16px;background:linear-gradient(90deg,var(--bt-hover) 0%,var(--bt-active) 50%,var(--bt-hover) 100%);background-size:200% 100%;animation:bt-us-shine 1.4s ease-in-out infinite}
+.bt-us-skel-sheet>.bt-us-skel:first-child{border-radius:16px 16px 0 0}
+.bt-us-skel-sheet>.bt-us-skel:not(:first-child){border-radius:0}
+.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:first-child{border-radius:0 0 0 16px}
+.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:last-child{border-radius:0 0 16px 0}
+.bt-us-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:72px 16px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
+.bt-us-empty b{margin-top:6px;font-size:15px;font-weight:600;color:var(--bt-ink)}
+@keyframes bt-us-in{from{opacity:0;transform:translateY(6px)}}
+@keyframes bt-us-grow{from{transform:scaleY(0)}}
+@keyframes bt-us-fill{from{transform:scaleX(0)}}
+@keyframes bt-us-pop{from{opacity:0;transform:translateY(3px) scale(.97)}}
+@keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}}
+@keyframes bt-us-fade{from{opacity:0}}
+@keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}}
+@keyframes bt-us-ring{from{stroke-dasharray:0 ${Math.ceil(RING2)}}}
+@keyframes bt-us-pulse{50%{opacity:.25}}
+@keyframes bt-us-shine{from{background-position:200% 0}to{background-position:-200% 0}}
+@media (prefers-reduced-motion:reduce){.bt-us *,.bt-us *::before{animation:none!important;transition:none!important}}
+`;
+
+// src/client/connectors.js
+var import_react39 = require("react");
+var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new";
+var COMPOSIO_KEY_URL = "https://platform.composio.dev/";
+var FEISHU_WEBHOOK_URL = "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot";
+var APPS = [
+ ["gmail", "Gmail"],
+ ["googlecalendar", "Google Calendar"],
+ ["googledrive", "Google Drive"],
+ ["googledocs", "Google Docs"],
+ ["googlesheets", "Google Sheets"],
+ ["outlook", "Outlook"],
+ ["slack", "Slack"],
+ ["microsoft_teams", "Microsoft Teams"],
+ ["discord", "Discord"],
+ ["telegram", "Telegram"],
+ ["whatsapp", "WhatsApp"],
+ ["notion", "Notion"],
+ ["linear", "Linear"],
+ ["jira", "Jira"],
+ ["asana", "Asana"],
+ ["trello", "Trello"],
+ ["todoist", "Todoist"],
+ ["airtable", "Airtable"],
+ ["dropbox", "Dropbox"],
+ ["hubspot", "HubSpot"],
+ ["salesforce", "Salesforce"],
+ ["stripe", "Stripe"],
+ ["zoom", "Zoom"],
+ ["calendly", "Calendly"],
+ ["figma", "Figma"],
+ ["twitter", "X (Twitter)"],
+ ["youtube", "YouTube"]
+];
+var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug;
+var GitHubMark = () => (0, import_react39.createElement)(
+ "svg",
+ { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true },
+ (0, import_react39.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" })
+);
+var FeishuMark = () => (0, import_react39.createElement)(
+ "svg",
+ { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
+ (0, import_react39.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }),
+ (0, import_react39.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }),
+ (0, import_react39.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" })
+);
+var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark };
+var ABOUT = {
+ github: () => t("Repositories, issues, and pull requests"),
+ composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key"),
+ feishu: () => t("Posts into one Feishu group through a custom bot webhook")
+};
+function statusText2(connector) {
+ if (connector.state === "connecting") return t("Connecting…");
+ if (connector.state === "error") return t("Could not connect: {error}", { error: connector.error ?? "" });
+ if (connector.state === "ready" && connector.kind === "channel") {
+ return connector.mode === "app" ? t("Connected: app, over the long connection") : t("Connected: custom bot webhook");
+ }
+ if (connector.state === "ready" && connector.apps?.length > 0) return t("Connected: {apps}", { apps: connector.apps.map(appName).join(", ") });
+ if (connector.state === "ready") return connector.account ? t("Connected as {account}", { account: connector.account }) : t("Connected");
+ return ABOUT[connector.id]?.() ?? "";
+}
+function CredentialField({ credential, value, onChange, onEnter, autoFocus }) {
+ const label = t(credential.label);
+ if (credential.masked) return (0, import_react39.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus });
+ return (0, import_react39.createElement)(
+ "label",
+ { className: "bt-q-field" },
+ (0, import_react39.createElement)("input", {
+ className: "bt-input",
+ type: "text",
+ value,
+ "aria-label": label,
+ placeholder: label,
+ spellCheck: false,
+ autoFocus,
+ onChange: (event) => onChange(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Enter") onEnter?.();
+ }
+ })
+ );
+}
+function AllowedSenders({ connector, actions, run, busy }) {
+ const [openId, setOpenId] = (0, import_react39.useState)("");
+ const allowed = connector.allowed ?? [];
+ const refused = connector.inbound?.refusedSenders ?? [];
+ const allow = (id) => void run(actions.connectorAllow(connector.id, id));
+ return (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-allowed" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")),
+ allowed.length === 0 && refused.length === 0 ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null,
+ allowed.map((entry) => (0, import_react39.createElement)(
+ "div",
+ { key: entry.openId, className: "bt-connector-person" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block"))
+ )),
+ refused.map((entry) => (0, import_react39.createElement)(
+ "div",
+ { key: entry.openId, className: "bt-connector-person" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow"))
+ )),
+ (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form-row" },
+ (0, import_react39.createElement)("input", {
+ className: "bt-input",
+ type: "text",
+ value: openId,
+ spellCheck: false,
+ "aria-label": t("Feishu open_id"),
+ placeholder: t("Paste an open_id to allow"),
+ onChange: (event) => setOpenId(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Enter" && openId.trim() !== "") {
+ allow(openId.trim());
+ setOpenId("");
+ }
+ }
+ }),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => {
+ allow(openId.trim());
+ setOpenId("");
+ } }, t("Allow"))
+ )
+ );
+}
+var APP_STATUS = { ready: () => t("Ready"), "sign-in": () => t("Needs sign-in") };
+function AppChip({ name, status, onRemove }) {
+ const label = APP_STATUS[status]?.();
+ return (0, import_react39.createElement)(
+ "button",
+ { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove },
+ name,
+ label ? (0, import_react39.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null
+ );
+}
+function AppPicker({ apps, onChange, search, statuses = {} }) {
+ const [query, setQuery] = (0, import_react39.useState)("");
+ const [found, setFound] = (0, import_react39.useState)(null);
+ const [failed, setFailed] = (0, import_react39.useState)(null);
+ const names = (0, import_react39.useRef)(new Map(APPS));
+ const asked = (0, import_react39.useRef)(0);
+ (0, import_react39.useEffect)(() => {
+ const text2 = query.trim();
+ if (text2 === "" || search === void 0) {
+ setFound(null);
+ setFailed(null);
+ return void 0;
}
- if (tag === "canvas") {
- const box = el.getBoundingClientRect();
- return box.width >= 200 && box.height >= 100 ? "\n\n[canvas: drawn content, not readable]\n\n" : "";
+ const ask = ++asked.current;
+ const timer = setTimeout(() => {
+ search(text2).then((list) => {
+ if (ask !== asked.current) return;
+ for (const app of list) names.current.set(app.slug, app.name);
+ setFound(list);
+ setFailed(null);
+ }, (failure) => {
+ if (ask !== asked.current) return;
+ setFound(null);
+ setFailed(failure?.message ?? String(failure));
+ });
+ }, 250);
+ return () => clearTimeout(timer);
+ }, [query, search]);
+ const nameOf = (slug) => names.current.get(slug) ?? appName(slug);
+ const picked = new Set(apps);
+ const needle = query.trim().toLowerCase();
+ const local = APPS.filter(([slug, name]) => needle === "" || slug.includes(needle) || name.toLowerCase().includes(needle)).map(([slug, name]) => ({ slug, name }));
+ const results = (found ?? local).filter((app) => !picked.has(app.slug));
+ return (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-apps" },
+ apps.length > 0 ? (0, import_react39.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react39.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null,
+ (0, import_react39.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }),
+ failed ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null,
+ (0, import_react39.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react39.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => {
+ names.current.set(app.slug, app.name);
+ onChange([...apps, app.slug]);
+ } }, app.name)))
+ );
+}
+function AppStatusList({ apps, statuses }) {
+ if (apps.length === 0 || Object.keys(statuses).length === 0) return null;
+ return (0, import_react39.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react39.createElement)(
+ "li",
+ { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)),
+ (0, import_react39.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "")
+ )));
+}
+function ConnectorRow({ connector, roster, actions }) {
+ const [form, setForm] = (0, import_react39.useState)(false);
+ const [bots, setBots] = (0, import_react39.useState)(false);
+ const [token, setToken] = (0, import_react39.useState)("");
+ const [apps, setApps] = (0, import_react39.useState)(() => connector.apps ?? []);
+ const [picking, setPicking] = (0, import_react39.useState)(false);
+ const [busy, setBusy] = (0, import_react39.useState)(false);
+ const [error, setError] = (0, import_react39.useState)(null);
+ const [message, setMessage] = (0, import_react39.useState)("");
+ const run = (task, after) => {
+ setBusy(true);
+ setError(null);
+ return task.then(() => {
+ after?.();
+ }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false));
+ };
+ const gateway = connector.id === "composio";
+ const sender = connector.kind === "sender";
+ const channel = connector.kind === "channel";
+ const modes = connector.modes ?? [];
+ const [mode, setMode] = (0, import_react39.useState)(() => connector.mode ?? modes[0]?.id);
+ const [values, setValues] = (0, import_react39.useState)({});
+ const chosen = modes.find((entry) => entry.id === mode) ?? modes[0];
+ const field = (name) => values[name] ?? "";
+ const setField = (name, value) => setValues((current) => ({ ...current, [name]: value }));
+ const filled = chosen !== void 0 && chosen.credentials.every((credential) => field(credential.name).trim() !== "");
+ const [statuses, setStatuses] = (0, import_react39.useState)({});
+ const appsKey = (connector.apps ?? []).join(",");
+ (0, import_react39.useEffect)(() => {
+ if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) {
+ setStatuses({});
+ return void 0;
}
- if (tag === "iframe" || tag === "frame") {
- const box = el.getBoundingClientRect();
- return tag === "iframe" && (box.width < 2 || box.height < 2) ? "" : frame(el, next);
+ let live = true;
+ actions.connectorAppStatus(connector.id).then((value) => {
+ if (live) setStatuses(value ?? {});
+ }, () => {
+ if (live) setStatuses({});
+ });
+ return () => {
+ live = false;
+ };
+ }, [gateway, connector.state, appsKey, picking]);
+ const search = (0, import_react39.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]);
+ const connect = () => {
+ if (busy) return;
+ if (channel) {
+ if (chosen === void 0 || !filled) return;
+ const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)]));
+ void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => {
+ setValues({});
+ setForm(false);
+ });
+ return;
}
- const found = itemFor(el, style);
- const mark = found ? `[#${found.number}]` : "";
- if (tag === "input" || tag === "textarea" || tag === "select") {
- if (found) found.item.name = nameOf(el, tag === "select" ? "" : el.labels?.[0] ? collapse(el.labels[0].textContent) : "", found.secret);
- return mark ? ` ${mark} ` : "";
+ if (token.trim() === "") return;
+ if (gateway && apps.length === 0) {
+ setError(t("Pick at least one app"));
+ return;
}
- let body2;
- if (tag === "pre") {
- const code = shown ? String(el.innerText || el.textContent || "") : "";
- chars += code.length;
- body2 = code.trim() ? keep(fenced(code, langOf(el))) : "";
- } else if (tag === "table") {
- body2 = tableMarkdown(el, next);
- } else if (tag === "ul" || tag === "ol") {
- body2 = list(el, next);
- } else if (tag === "details" && !el.open) {
- const summary = [...el.children].find((child) => child.localName === "summary");
- body2 = summary ? render(summary, next) : "";
- } else {
- body2 = children(el, { ...next, pre: keepsSpace });
+ void run(actions.connectorConnect(connector.id, token, gateway ? apps : void 0), () => {
+ setToken("");
+ setForm(false);
+ });
+ };
+ const saveApps = () => {
+ if (busy) return;
+ if (apps.length === 0) {
+ setError(t("Pick at least one app"));
+ return;
}
- if (found) found.item.name = nameOf(el, oneLine(body2), found.secret);
- const level = /^h([1-6])$/.exec(tag)?.[1] || (el.getAttribute("role") === "heading" ? el.getAttribute("aria-level") || "2" : "");
- if (level) {
- const title = oneLine(body2);
- return title ? `
+ void run(actions.connectorApps(connector.id, apps), () => setPicking(false));
+ };
+ const on = connector.state !== "off";
+ const canPost = sender || channel && connector.mode === "webhook";
+ const perBot = !sender && (!channel || connector.mode === "app");
+ const Mark = MARKS[connector.id];
+ const row = (0, import_react39.useRef)(null);
+ useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`);
+ return (0, import_react39.createElement)(
+ "div",
+ { ref: row, className: "bt-connector", "data-state": connector.state },
+ (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-head" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react39.createElement)(Mark) : null),
+ (0, import_react39.createElement)(
+ "span",
+ { className: "bt-connector-text" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-name" }, connector.name),
+ (0, import_react39.createElement)("span", { className: "bt-connector-status" }, statusText2(connector))
+ ),
+ (0, import_react39.createElement)(
+ "span",
+ { className: "bt-connector-actions" },
+ connector.state === "error" ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null,
+ on && gateway && !picking ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+ setApps(connector.apps ?? []);
+ setPicking(true);
+ } }, t("Choose apps")) : null,
+ on && perBot ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null,
+ on ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect"))
+ )
+ ),
+ on && gateway && !picking ? (0, import_react39.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null,
+ on && perBot ? (0, import_react39.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null,
+ on && bots ? (0, import_react39.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null,
+ on && channel && connector.mode === "app" ? (0, import_react39.createElement)(AllowedSenders, { connector, actions, run, busy }) : null,
+ on && canPost ? (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form" },
+ (0, import_react39.createElement)("label", { className: "bt-q-field" }, (0, import_react39.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })),
+ (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form-row" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-link" }),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send"))
+ )
+ ) : null,
+ !on && form ? (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form" },
+ channel ? (0, import_react39.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react39.createElement)("button", {
+ key: entry.id,
+ type: "button",
+ className: "bt-state",
+ "aria-pressed": entry.id === chosen?.id,
+ onClick: () => {
+ setMode(entry.id);
+ setValues({});
+ setError(null);
+ }
+ }, t(entry.label)))) : null,
+ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react39.createElement)(CredentialField, {
+ key: credential.name,
+ credential,
+ value: field(credential.name),
+ onChange: (value) => setField(credential.name, value),
+ onEnter: connect,
+ autoFocus: index === 0
+ })) : (0, import_react39.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react39.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })),
+ gateway ? (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null,
+ (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form-row" },
+ (0, import_react39.createElement)(
+ "a",
+ { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" },
+ channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub")
+ ),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+ setForm(false);
+ setToken("");
+ setValues({});
+ setError(null);
+ } }, t("Cancel")),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect"))
+ )
+ ) : null,
+ on && gateway && picking ? (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form" },
+ (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }),
+ (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")),
+ (0, import_react39.createElement)(
+ "div",
+ { className: "bt-connector-form-row" },
+ (0, import_react39.createElement)("span", { className: "bt-connector-link" }),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+ setPicking(false);
+ setError(null);
+ } }, t("Cancel")),
+ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save"))
+ )
+ ) : null,
+ error ? (0, import_react39.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null
+ );
+}
+function ConnectorsPage({ roster, actions }) {
+ const list = roster?.connectors ?? [];
+ return (0, import_react39.createElement)(
+ "section",
+ { className: "bt-set-section" },
+ (0, import_react39.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react39.createElement)("div", { className: "bt-set-row" }, (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react39.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions })))
+ );
+}
+
+// src/client/feedback.js
+var import_react40 = require("react");
+var FeedbackIcon = () => (0, import_react40.createElement)(
+ "svg",
+ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+ (0, import_react40.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }),
+ (0, import_react40.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" })
+);
+function download(name, text2) {
+ const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = name;
+ anchor.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1e3);
+}
+var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false);
+function whenText2(time) {
+ const date = new Date(time);
+ const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString();
+ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" });
+ return today ? clock4 : `${date.toLocaleDateString(dateLocale(), { month: "short", day: "numeric" })} ${clock4}`;
+}
+function entrySummary(entry) {
+ if (entry.source === "turn") return failureLabel(entry.code, entry.text);
+ return entry.text;
+}
+var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "This window" };
+function FeedbackPage({ actions, run, Section, Row: Row4 }) {
+ const [what, setWhat] = (0, import_react40.useState)("");
+ const [report, setReport] = (0, import_react40.useState)(null);
+ const [preview, setPreview] = (0, import_react40.useState)(false);
+ const [note, setNote] = (0, import_react40.useState)("");
+ const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => {
+ setReport(next);
+ return next;
+ });
+ const load = () => fetchReport().then(() => void 0);
+ (0, import_react40.useEffect)(() => {
+ void run(load());
+ }, []);
+ const copyReport = () => run(fetchReport().then(async (next) => {
+ const copied = await copy(next.markdown);
+ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it."));
+ if (!copied) setPreview(true);
+ }));
+ const [issueLink, setIssueLink] = (0, import_react40.useState)(null);
+ const openIssue = () => {
+ const win = window.open("", "_blank");
+ if (win !== null) win.opener = null;
+ run(fetchReport().then(async (next) => {
+ const copied = await copy(next.markdown);
+ const { url, inline } = issueUrl(next.issuesUrl, { what, diagnostics: next.markdown });
+ if (win !== null) {
+ win.location.href = url;
+ setIssueLink(null);
+ setNote(inline ? t("Opened a new GitHub issue with the diagnostics filled in. Check it before you submit.") : copied ? t("Opened a new GitHub issue. The diagnostics are long, so they are on the clipboard: paste them into the issue.") : t("Opened a new GitHub issue. Copy the diagnostics below into it."));
+ } else {
+ setIssueLink(url);
+ setNote(t("The browser blocked the new window."));
+ }
+ if (!inline && !copied) setPreview(true);
+ }));
+ };
+ const downloadLog = () => run(actions.diagnosticsLog().then((text2) => {
+ download("ds-bot-diagnostics.log", text2 || "No entries.\n");
+ setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it."));
+ }));
+ const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log."))));
+ const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : [];
+ const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time));
+ const total = (report?.total ?? 0) + browserErrors.length;
+ const shown = entries2.slice(0, 8);
+ return [
+ (0, import_react40.createElement)(
+ Section,
+ { key: "report", title: t("Report a problem") },
+ (0, import_react40.createElement)(
+ "div",
+ { className: "bt-set-row bt-set-paste bt-fb-what" },
+ (0, import_react40.createElement)(
+ "label",
+ { className: "bt-set-copy", htmlFor: "bt-fb-what" },
+ (0, import_react40.createElement)("span", null, t("What happened?")),
+ (0, import_react40.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue."))
+ ),
+ (0, import_react40.createElement)("textarea", { id: "bt-fb-what", className: "bt-input bt-input-area", value: what, maxLength: WHAT_MAX, placeholder: t("For example: I asked Writer in the group chat and it showed an error."), onChange: (event) => setWhat(event.target.value) })
+ ),
+ (0, import_react40.createElement)(
+ Row4,
+ { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") },
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")),
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy"))
+ ),
+ preview ? (0, import_react40.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react40.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null,
+ (0, import_react40.createElement)(
+ Row4,
+ { label: t("GitHub issue"), hint: t("Opens a new issue in the FlyBot repository with all of this filled in. Nothing is sent until you submit it there.") },
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub"))
+ )
+ ),
+ note ? (0, import_react40.createElement)(
+ "div",
+ { key: "note", className: "bt-note bt-fb-note", role: "status" },
+ note,
+ issueLink ? [" ", (0, import_react40.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null
+ ) : null,
+ (0, import_react40.createElement)(
+ Section,
+ { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") },
+ shown.length === 0 ? (0, import_react40.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react40.createElement)(
+ "div",
+ { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text },
+ (0, import_react40.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }),
+ (0, import_react40.createElement)(
+ "div",
+ { className: "bt-set-copy" },
+ (0, import_react40.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)),
+ (0, import_react40.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · "))
+ ),
+ entry.code ? (0, import_react40.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null
+ )),
+ (0, import_react40.createElement)(
+ Row4,
+ { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") },
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")),
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear"))
+ )
+ )
+ ];
+}
+var TOAST_MS = 6e3;
+function ErrorToast({ useUi, actions }) {
+ const toast = useUi((state) => state.toast);
+ const [leaving, setLeaving] = (0, import_react40.useState)(false);
+ (0, import_react40.useEffect)(() => {
+ if (!toast) return void 0;
+ setLeaving(false);
+ const fade = setTimeout(() => setLeaving(true), TOAST_MS);
+ const gone = setTimeout(() => actions.dismissToast(toast.token), TOAST_MS + 200);
+ return () => {
+ clearTimeout(fade);
+ clearTimeout(gone);
+ };
+ }, [toast?.token]);
+ if (!toast) return null;
+ const info = toast.tone === "info";
+ return (0, import_react40.createElement)(
+ "div",
+ { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 },
+ (0, import_react40.createElement)("span", { className: "bt-toast-text" }, toast.text),
+ info ? null : (0, import_react40.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => {
+ actions.dismissToast(toast.token);
+ actions.openSettings("feedback");
+ } }, t("Details")),
+ (0, import_react40.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react40.createElement)(CloseIcon))
+ );
+}
+var FEEDBACK_CSS = `
+.bt-fb-what{cursor:default}
+.bt-fb-what:hover,.bt-fb-entry:hover,.bt-fb-preview:hover{background:none}
+.bt-fb-what .bt-input-area{min-height:80px}
+.bt-fb-preview{cursor:default;padding:0}
+.bt-fb-preview pre{margin:0;width:100%;max-height:240px;overflow:auto;padding:10px 12px;box-sizing:border-box;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink-2);white-space:pre-wrap;word-break:break-word;outline:none}
+.bt-fb-primary{height:28px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);padding:0 14px;font-size:12px;cursor:pointer;transition:filter .12s ease}
+.bt-fb-primary:hover{filter:brightness(1.08)}
+.bt-fb-primary:active{filter:brightness(.94)}
+.bt-fb-note{margin:-8px 4px 0}
+.bt-fb-entry{cursor:default;align-items:flex-start}
+.bt-fb-dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:#e02135}
+.bt-fb-dot[data-level=warn]{background:#e3a008}
+.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
+.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
+.bt-event-error{color:#e02135}
+.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
+.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
+.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
+.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
+.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
+.bt-toast-close{flex:none}
+@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
+@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
+`;
-${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark}
+// src/client/about.js
+var import_react41 = require("react");
+var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count);
+function botHint(update) {
+ if (update.phase === "installing") return t("Updating FlyBot core…");
+ if (update.phase === "installed") return t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest });
+ if (update.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(update.error ?? "") });
+ if (update.available) return t("FlyBot core {version} is available", { version: update.latest });
+ if (update.enabled === false) return t("Update checks are off.");
+ if (update.source === "path") return t("Installed from a local folder; update it there.");
+ if (update.source === "git" && update.build?.branch !== void 0) return branchHint(update);
+ if (update.source === "git" && update.build?.pinned) return t("Pinned to commit {commit}. Updates come from the maintainer: reinstall FlyBot to get them.", { commit: update.build.commit });
+ if (!update.checkedAt) return null;
+ if (!update.latest && update.source !== "registry") return t("Updates are not offered for this install.");
+ const checked = t("Checked {time}", { time: whenText2(update.checkedAt) });
+ return `${update.latest ? t("Up to date") : t("Could not check for updates.")} · ${checked}`;
+}
+function branchHint({ build, behind, checkedAt }) {
+ const where = [build.branch, build.commit].filter(Boolean).join(" ");
+ if (build.commit === void 0 || !checkedAt) return t("Installed from GitHub ({where}). Updates come from the maintainer: reinstall FlyBot to get them.", { where });
+ const checked = t("Checked {time}", { time: whenText2(checkedAt) });
+ const state = behind === null ? t("Could not compare with {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : behind === 0 ? t("Up to date with {branch}.", { branch: build.branch }) : behind === 1 ? t("1 commit behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : t("{count} commits behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { count: behind, branch: build.branch });
+ return `${state} · ${checked}`;
+}
+var versionLine = (update) => [t("Version {version}", { version: update.current }), [update.build?.branch, update.build?.commit].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
+function dshHint(dsh, shown) {
+ if (dsh.phase === "installing") return t("Updating DSH…");
+ if (dsh.phase === "installed") return t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified });
+ if (dsh.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(dsh.error ?? "") });
+ if (dsh.available === false && dsh.blockedBy) return t("Update FlyBot core {version} first.", { version: dsh.blockedBy });
+ if (shown) return t("DSH {version} is available", { version: dsh.verified });
+ return t("Version {version}", { version: dsh.current });
+}
+var linkProps = (href) => ({ className: "bt-soft bt-soft-sm bt-about-link", href, target: "_blank", rel: "noopener noreferrer" });
+function AboutPage({ useUpdate, actions, run, Section, Row: Row4 }) {
+ const update = useUpdate((value) => value) ?? {};
+ const [checking, setChecking] = (0, import_react41.useState)(false);
+ const [stars, setStars] = (0, import_react41.useState)(null);
+ (0, import_react41.useEffect)(() => {
+ let live = true;
+ actions.repoStars().then((count) => {
+ if (live && Number.isInteger(count)) setStars(count);
+ }, () => {
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+ const check = () => {
+ setChecking(true);
+ void run(actions.checkUpdate()).then(() => {
+ setChecking(false);
+ });
+ };
+ const phase = update.phase ?? "idle";
+ const dsh = update.dsh;
+ const dshPhase = dsh?.phase ?? "idle";
+ const dshNew = showsDshRow(dsh, update.app);
+ const dshShown = dshNew && dsh.available;
+ const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react41.createElement)(
+ "button",
+ { type: "button", className: "bt-fb-primary", disabled: phase === "installing", onClick: () => run(actions.installUpdate()) },
+ phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")
+ ) : (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: checking || update.enabled === false, onClick: check }, checking ? t("Checking…") : t("Check for updates"));
+ const dshControl = !dshNew || dshPhase === "installed" ? null : dsh.kind === "app" ? dsh.available ? (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.openAppUpdates(dsh.verified)) }, t("Open updates")) : null : dsh.available || dshPhase !== "idle" ? (0, import_react41.createElement)(
+ "button",
+ { type: "button", className: "bt-fb-primary", disabled: dshPhase === "installing", onClick: () => run(actions.installDshUpdate()) },
+ dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")
+ ) : null;
+ const repo = update.repoUrl;
+ return [
+ (0, import_react41.createElement)(
+ "div",
+ { key: "hero", className: "bt-about-hero" },
+ (0, import_react41.createElement)("span", { className: "bt-about-mark" }, (0, import_react41.createElement)(FlyBotMark, { size: 52 })),
+ (0, import_react41.createElement)(
+ "div",
+ { className: "bt-about-copy" },
+ (0, import_react41.createElement)("span", { className: "bt-about-name" }, t("FlyBot")),
+ update.current ? (0, import_react41.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null
+ ),
+ repo ? (0, import_react41.createElement)(
+ "a",
+ {
+ className: "bt-star",
+ href: repo,
+ target: "_blank",
+ rel: "noopener noreferrer",
+ title: t("Star FlyBot on GitHub"),
+ "aria-label": stars === null ? t("Star FlyBot on GitHub") : `${t("Star FlyBot on GitHub")} · ${t("{count} stars", { count: stars })}`
+ },
+ (0, import_react41.createElement)("span", { className: "bt-star-main" }, (0, import_react41.createElement)("span", { className: "bt-star-icon" }, (0, import_react41.createElement)(StarLineIcon)), "Star"),
+ stars === null ? null : (0, import_react41.createElement)("span", { className: "bt-star-count" }, starCount(stars))
+ ) : null
+ ),
+ repo ? (0, import_react41.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If FlyBot helps you, a star on GitHub helps more people find it.")) : null,
+ (0, import_react41.createElement)(
+ Section,
+ { key: "updates", title: t("Updates") },
+ (0, import_react41.createElement)(Row4, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react41.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null),
+ dsh?.current ? (0, import_react41.createElement)(Row4, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null
+ ),
+ (0, import_react41.createElement)(
+ Section,
+ { key: "links", title: t("Links") },
+ repo ? (0, import_react41.createElement)(Row4, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react41.createElement)("a", linkProps(repo), (0, import_react41.createElement)(GitHubMark), t("Open"))) : null,
+ update.notesUrl ? (0, import_react41.createElement)(Row4, { label: t("Release notes") }, (0, import_react41.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null,
+ (0, import_react41.createElement)(Row4, { label: t("Report a problem") }, (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open")))
+ )
+ ];
+}
+var ABOUT_CSS = `
+.bt-about-hero{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
+.bt-about-mark{flex:none;display:inline-flex}
+.bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
+.bt-about-name{font-size:16px;line-height:22px;font-weight:600}
+.bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums}
+.bt-about-ask{margin:-10px 4px 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+.bt-star{flex:none;display:inline-flex;align-items:stretch;height:30px;border-radius:999px;border:1px solid var(--bt-line-2,var(--bt-line));overflow:hidden;color:var(--bt-ink);text-decoration:none;font-size:12px;line-height:28px;transition:transform .12s ease,border-color .15s ease}
+.bt-star:hover{border-color:color-mix(in srgb,#e3a008 55%,var(--bt-line))}
+.bt-star:active{transform:scale(.96)}
+.bt-star-main{display:inline-flex;align-items:center;gap:6px;padding:0 12px 0 10px;background:var(--bt-hover);font-weight:500}
+.bt-star-icon{display:inline-flex;color:var(--bt-ink-2);transition:color .15s ease}
+.bt-star-icon path{transition:fill .15s ease}
+.bt-star:hover .bt-star-icon{color:#e3a008;animation:bt-star-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+.bt-star:hover .bt-star-icon path{fill:currentColor}
+.bt-star-count{display:inline-flex;align-items:center;padding:0 11px;border-left:1px solid var(--bt-line-2,var(--bt-line));color:var(--bt-ink-2);font-variant-numeric:tabular-nums;animation:bt-star-count-in ${SPRING_POP.duration}ms ${SPRING_POP.easing},bt-veil-in .2s ease-out}
+@keyframes bt-star-pop{from{scale:.6;rotate:-30deg}}
+@keyframes bt-star-count-in{from{translate:-6px 0}}
+.bt-about-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none}
+.bt-about-link svg{width:14px;height:14px}
+.bt-fb-primary:disabled{opacity:.6;cursor:default;filter:none}
+@media (prefers-reduced-motion:reduce){.bt-star:hover .bt-star-icon,.bt-star-count{animation:none}}
+`;
-` : mark;
- }
- if (tag === "code" && !ctx.pre) {
- const code = oneLine(body2);
- if (!code) return mark;
- const ticks = code.includes("`") ? "``" : "`";
- return `${ticks}${code}${ticks}${mark}`;
- }
- if (tag === "blockquote") {
- const quoted = block(body2);
- return quoted ? keep(quoted.split("\n").map((line2) => line2 ? `> ${line2}` : ">").join("\n")) : "";
- }
- const end = body2.trimEnd().length;
- const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2;
- if (ctx.pre && !BLOCK_DISPLAY.test(display)) return marked;
- if (keepsSpace && BLOCK_DISPLAY.test(display) && tag !== "pre") {
- return keep(expand(marked).replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").replace(/^\n+|\s+$/g, ""), "");
+// src/client/settings.js
+var import_react42 = require("react");
+var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
+var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
+var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
+function SetSection({ title, children }) {
+ return (0, import_react42.createElement)("section", { className: "bt-set-section" }, (0, import_react42.createElement)("h3", null, title), (0, import_react42.createElement)("div", { className: "bt-set-card" }, children));
+}
+function SetRow({ label, hint, children }) {
+ return (0, import_react42.createElement)(
+ "div",
+ { className: "bt-set-row" },
+ (0, import_react42.createElement)("div", { className: "bt-set-copy" }, (0, import_react42.createElement)("span", null, label), hint ? (0, import_react42.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+ children ? (0, import_react42.createElement)("div", { className: "bt-set-control" }, children) : null
+ );
+}
+var NOTIFY_KINDS = [
+ ["approval", "Waiting for approval", "Once more if it still waits after 15 min."],
+ ["finished", "Finished"],
+ ["failed", "Failed"]
+];
+function GeneralPage({ roster, actions, run }) {
+ const prefs = roster.prefs ?? {};
+ const themes = allThemes();
+ const current = themes[prefs.theme] ? prefs.theme : "deepseek";
+ const [custom, setCustom] = (0, import_react42.useState)(prefs.accent ?? "#4d6bfe");
+ const timer = (0, import_react42.useRef)(0);
+ (0, import_react42.useEffect)(() => () => clearTimeout(timer.current), []);
+ const pickCustom = (value) => {
+ setCustom(value);
+ clearTimeout(timer.current);
+ timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350);
+ };
+ return [
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "look", title: t("Appearance") },
+ (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Theme"), hint: t("Colors for the whole team, on every device.") },
+ (0, import_react42.createElement)(
+ "select",
+ { className: "bt-select", "aria-label": t("Theme"), value: current, onChange: (event) => run(actions.setPrefs({ theme: event.target.value })) },
+ Object.entries(themes).map(([id, theme]) => (0, import_react42.createElement)("option", { key: id, value: id }, t(theme.label)))
+ )
+ ),
+ (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") },
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-swatches" },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-swatch bt-swatch-default", title: t("Theme accent"), "aria-label": t("Theme accent"), "aria-pressed": !prefs.accent, onClick: () => run(actions.setPrefs({ accent: null })) }),
+ ACCENT_INKS.map((ink) => (0, import_react42.createElement)("button", {
+ key: ink,
+ type: "button",
+ className: "bt-swatch",
+ title: t(capitalize2(ink)),
+ "aria-label": t(capitalize2(ink)),
+ "aria-pressed": prefs.accent === INKS[ink][0],
+ style: { background: INKS[ink][0] },
+ onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] }))
+ })),
+ (0, import_react42.createElement)(
+ "label",
+ { className: "bt-swatch bt-swatch-custom", title: t("Custom accent"), "data-on": Boolean(prefs.accent) && !ACCENT_INKS.some((ink) => INKS[ink][0] === prefs.accent) },
+ (0, import_react42.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) })
+ )
+ )
+ )
+ ),
+ // One switch for every system notification (#353), then one per kind (#469).
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "notify", title: t("Notifications") },
+ (0, import_react42.createElement)(SwitchRow, {
+ label: t("In other chats"),
+ hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."),
+ on: prefs.notifications !== false,
+ onChange: (on) => run(actions.setPrefs({ notifications: on }))
+ }),
+ NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react42.createElement)(SwitchRow, {
+ key: kind,
+ label: t(label),
+ hint: hint ? t(hint) : void 0,
+ disabled: prefs.notifications === false,
+ on: prefs.notify?.[kind] !== false,
+ onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } }))
+ }))
+ )
+ ];
+}
+function MainBotsSection({ roster, actions, run }) {
+ const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean);
+ const [first, ...others] = mains;
+ const own = roster.bots.filter((bot) => !bot.remote);
+ const candidates = own.filter((bot) => !isMainOf(roster, bot.id));
+ return (0, import_react42.createElement)(
+ SetSection,
+ { title: t("Main Bot") },
+ (0, import_react42.createElement)(
+ SetRow,
+ {
+ label: t("Main Bot"),
+ hint: first ? t("First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.", { name: first.name }) : void 0
+ },
+ (0, import_react42.createElement)("select", {
+ className: "bt-select",
+ "aria-label": t("Main Bot"),
+ value: first?.id ?? "",
+ onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {}))
+ }, first ? null : (0, import_react42.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react42.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
+ ),
+ others.map((bot) => (0, import_react42.createElement)(
+ SetRow,
+ { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t("Move to first")),
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role"))
+ )),
+ candidates.length > 0 ? (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Add a Main Bot"), hint: t("Main Bots can create and change Bots, and run every group chat.") },
+ (0, import_react42.createElement)("select", {
+ className: "bt-select",
+ "aria-label": t("Add a Main Bot"),
+ value: "",
+ onChange: (event) => {
+ if (event.target.value) void run(actions.setMain(event.target.value, true));
+ }
+ }, (0, import_react42.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react42.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
+ ) : null
+ );
+}
+var MOTIONS = [["quiet", "Quiet"], ["normal", "Normal"], ["lively", "Lively"]];
+function MotionSection({ roster, actions, run }) {
+ const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal";
+ return (0, import_react42.createElement)(
+ SetSection,
+ { title: t("Animation") },
+ (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Bot animation") },
+ (0, import_react42.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react42.createElement)("button", {
+ key: id,
+ type: "button",
+ "aria-pressed": motion === id,
+ onClick: () => {
+ if (motion !== id) run(actions.setPrefs({ motion: id }));
+ }
+ }, t(label))))
+ )
+ );
+}
+function BotsPage({ roster, actions, run }) {
+ const [creating, setCreating] = (0, import_react42.useState)(false);
+ return [
+ (0, import_react42.createElement)(MainBotsSection, { key: "main", roster, actions, run }),
+ (0, import_react42.createElement)(MotionSection, { key: "motion", roster, actions, run }),
+ creating ? (0, import_react42.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react42.createElement)(
+ "div",
+ { key: "new", className: "bt-actions" },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react42.createElement)(PlusIcon), t("New Bot"))
+ ),
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) },
+ roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react42.createElement)(
+ "button",
+ { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) },
+ (0, import_react42.createElement)(
+ "span",
+ { className: "bt-set-bot" },
+ (0, import_react42.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }),
+ (0, import_react42.createElement)("span", { className: "bt-row-title" }, bot.name),
+ bot.role ? (0, import_react42.createElement)("span", { className: "bt-tag" }, bot.role) : null,
+ isMainOf(roster, bot.id) ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null,
+ bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
+ ),
+ (0, import_react42.createElement)("span", { className: "bt-chevron" }, (0, import_react42.createElement)(ChevronRightIcon))
+ ))
+ )
+ ];
+}
+function GroupsPage({ roster, actions, run }) {
+ const [creating, setCreating] = (0, import_react42.useState)(false);
+ return [
+ creating ? (0, import_react42.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react42.createElement)(
+ "div",
+ { key: "new", className: "bt-actions" },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", disabled: roster.bots.length < 2, title: roster.bots.length < 2 ? t("A group chat needs at least two Bots") : void 0, onClick: () => setCreating(true) }, (0, import_react42.createElement)(PlusIcon), t("Create group chat"))
+ ),
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) },
+ roster.rooms.length === 0 ? (0, import_react42.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => {
+ const admin = roster.byId[room2.admin];
+ return (0, import_react42.createElement)(
+ "button",
+ { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) },
+ (0, import_react42.createElement)(
+ "span",
+ { className: "bt-set-bot" },
+ (0, import_react42.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
+ (0, import_react42.createElement)("span", { className: "bt-row-title" }, room2.name),
+ (0, import_react42.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })),
+ admin ? (0, import_react42.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react42.createElement)(AdminStar), admin.name) : null,
+ room2.hidden ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
+ ),
+ (0, import_react42.createElement)("span", { className: "bt-chevron" }, (0, import_react42.createElement)(ChevronRightIcon))
+ );
+ })
+ )
+ ];
+}
+function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) {
+ const [error, setError] = (0, import_react42.useState)("");
+ const close = (0, import_react42.useCallback)(() => actions.closeSettings(), []);
+ useEscape(close);
+ const run = (promise) => Promise.resolve(promise).then(() => {
+ setError("");
+ return true;
+ }).catch((failure) => {
+ setError(failure?.message ?? String(failure));
+ return false;
+ });
+ const bot = page === "bots" && id ? roster.byId[id] : void 0;
+ const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
+ const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` }));
+ const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]);
+ const shown = pages.some(([key]) => key === page) ? page : "general";
+ const pluginPage = extra.find(({ key }) => key === shown)?.entry;
+ const usageFocus = shown === "usage" && id ? id : null;
+ const label = t(pages.find(([key]) => key === shown)[1]);
+ const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null;
+ const nav = useGlide(shown, { axis: "y" });
+ const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`;
+ const depth = bot || room2 ? 1 : 0;
+ const last = (0, import_react42.useRef)({ key: pageKey, depth, move: "up" });
+ if (last.current.key !== pageKey) {
+ last.current = { key: pageKey, depth, move: depth > last.current.depth ? "in" : depth < last.current.depth ? "out" : "up" };
+ }
+ const [usageOpened, setUsageOpened] = (0, import_react42.useState)(shown === "usage");
+ if (shown === "usage" && !usageOpened) setUsageOpened(true);
+ const errorLine = error ? (0, import_react42.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
+ return (0, import_react42.createElement)(
+ "div",
+ { className: "bt-settings-layer", "data-leaving": leaving || void 0 },
+ (0, import_react42.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 },
+ (0, import_react42.createElement)(
+ "nav",
+ { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") },
+ (0, import_react42.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }),
+ pages.map(([key, text2]) => (0, import_react42.createElement)("button", {
+ key,
+ type: "button",
+ className: "bt-settings-item",
+ "aria-current": shown === key ? "page" : void 0,
+ onClick: () => actions.openSettings(key)
+ }, (0, import_react42.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))),
+ // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot
+ // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421).
+ (0, import_react42.createElement)("button", {
+ type: "button",
+ className: "bt-settings-item bt-settings-shell",
+ title: t("Opens DeepSeek Harness's own settings (models, providers, permissions) in a separate window"),
+ "aria-label": t("Harness settings, opens DeepSeek Harness's own settings window"),
+ onClick: () => {
+ close();
+ openHarnessSettings();
+ }
+ }, (0, import_react42.createElement)(SlidersIcon), t("Harness settings"), (0, import_react42.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗"))
+ ),
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-settings-main" },
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-settings-head" },
+ back ? (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react42.createElement)(ChevronLeftIcon)) : null,
+ (0, import_react42.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label)
+ ),
+ shown === "usage" ? null : (0, import_react42.createElement)(
+ "div",
+ { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move },
+ errorLine,
+ shown === "general" ? (0, import_react42.createElement)(GeneralPage, { roster, actions, run }) : null,
+ bot ? (0, import_react42.createElement)("div", { className: "bt-settings-form" }, (0, import_react42.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null,
+ shown === "bots" && !bot ? (0, import_react42.createElement)(BotsPage, { roster, actions, run }) : null,
+ room2 ? (0, import_react42.createElement)("div", { className: "bt-settings-form" }, (0, import_react42.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null,
+ shown === "groups" && !room2 ? (0, import_react42.createElement)(GroupsPage, { roster, actions, run }) : null,
+ shown === "projects" ? (0, import_react42.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null,
+ shown === "connectors" ? (0, import_react42.createElement)(ConnectorsPage, { roster, actions }) : null,
+ shown === "workflows" ? (0, import_react42.createElement)(WorkflowsPage, { call: call2, roster }) : null,
+ pluginPage ? (0, import_react42.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
+ shown === "capabilities" ? (0, import_react42.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
+ shown === "schedules" ? (0, import_react42.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
+ shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
+ shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null,
+ shown === "about" ? (0, import_react42.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null
+ ),
+ usageOpened ? (0, import_react42.createElement)(
+ "div",
+ { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true },
+ shown === "usage" ? errorLine : null,
+ (0, import_react42.createElement)(UsagePage, { roster, actions, focusId: usageFocus })
+ ) : null
+ ),
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react42.createElement)(CloseIcon))
+ )
+ );
+}
+var openProjectBoard = (id) => {
+ if (typeof window !== "undefined") window.location.hash = `#project=${id}`;
+};
+var projectMembers = (roster) => [
+ ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })),
+ ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
+];
+function ProjectsList({ projects, roster, call: call4, reload }) {
+ const members = projectMembers(roster);
+ const bind = async (choice, project) => {
+ const [kind, id] = String(choice ?? "").split(":");
+ if (!kind || !id) return;
+ await call4("project-bind", { kind, id, projectId: project.id });
+ await reload?.();
+ };
+ const unbind = async (member) => {
+ await call4("project-bind", { kind: member.kind, id: member.id, projectId: null });
+ await reload?.();
+ };
+ const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0);
+ return [
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "projects", title: t("Projects · {count}", { count: projects.length }) },
+ projects.length === 0 ? (0, import_react42.createElement)("div", { className: "bt-set-row" }, (0, import_react42.createElement)("div", { className: "bt-set-copy" }, (0, import_react42.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react42.createElement)(
+ SetRow,
+ {
+ key: project.id,
+ label: project.name,
+ hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir
+ },
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-project-row-controls" },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
+ (0, import_react42.createElement)(
+ "select",
+ {
+ className: "bt-select",
+ "aria-label": t("Bind to {name}", { name: project.name }),
+ value: "",
+ onChange: (event) => {
+ void bind(event.target.value, project);
+ }
+ },
+ (0, import_react42.createElement)("option", { value: "" }, t("Bind a Bot or group")),
+ members.map((member) => (0, import_react42.createElement)(
+ "option",
+ { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` },
+ `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}`
+ ))
+ )
+ )
+ ))
+ ),
+ bound2.length === 0 ? null : (0, import_react42.createElement)(
+ SetSection,
+ { key: "bindings", title: t("Bindings") },
+ bound2.map((member) => (0, import_react42.createElement)(
+ SetRow,
+ {
+ key: `${member.kind}:${member.id}`,
+ label: member.name,
+ hint: member.kind === "group" ? t("Group") : t("Bot")
+ },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ void unbind(member);
+ } }, t("Unbind"))
+ ))
+ )
+ ];
+}
+function ProjectsPage({ roster, actions, run, call: call4 }) {
+ const [state, setState] = (0, import_react42.useState)({ projects: [], error: null });
+ const [name, setName] = (0, import_react42.useState)("");
+ const [workDir, setWorkDir] = (0, import_react42.useState)(null);
+ const reload = (0, import_react42.useCallback)(async () => {
+ const listing = await call4("projects");
+ setState({ projects: listing?.projects ?? [], error: null });
+ }, [call4]);
+ (0, import_react42.useEffect)(() => {
+ void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) }));
+ }, [reload]);
+ const create = async () => {
+ const trimmed3 = name.trim();
+ if (trimmed3 === "") return;
+ const body = { name: trimmed3 };
+ if (workDir !== null && workDir !== "") body.workDir = workDir;
+ try {
+ await call4("project-create", body);
+ setName("");
+ setWorkDir(null);
+ await reload();
+ } catch (error) {
+ setState({ projects: state.projects, error: error?.message ?? String(error) });
}
- if (BLOCK_DISPLAY.test(display)) return `
+ };
+ return [
+ state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
+ (0, import_react42.createElement)(
+ SetSection,
+ { key: "new", title: t("New Project") },
+ (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Name") },
+ (0, import_react42.createElement)("input", {
+ className: "bt-input",
+ "aria-label": t("Project name"),
+ value: name,
+ placeholder: t("e.g. Website"),
+ onChange: (event) => setName(event.target.value)
+ })
+ ),
+ (0, import_react42.createElement)(
+ SetRow,
+ { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") },
+ (0, import_react42.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })
+ ),
+ (0, import_react42.createElement)(
+ "div",
+ { className: "bt-actions" },
+ (0, import_react42.createElement)("button", {
+ type: "button",
+ className: "bt-primary bt-with-icon",
+ disabled: name.trim() === "",
+ onClick: () => {
+ void create();
+ }
+ }, (0, import_react42.createElement)(PlusIcon), t("New Project"))
+ )
+ ),
+ // The list is rendered here rather than as a child element: it holds no state of its own,
+ // and the page keeps one reload path for both halves.
+ ...ProjectsList({ projects: state.projects, roster, call: call4, reload })
+ ];
+}
-${marked}
+// src/client/context-menu.js
+var import_react43 = require("react");
+function ContextMenu({ menu, roster, actions, leaving = false }) {
+ useEscape(() => actions.closeMenu());
+ (0, import_react43.useEffect)(() => {
+ const onDown = (event) => {
+ if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu();
+ };
+ window.addEventListener("mousedown", onDown);
+ return () => window.removeEventListener("mousedown", onDown);
+ }, []);
+ const structure = useSidebarStructure();
+ const ref = (0, import_react43.useRef)(null);
+ const [place, setPlace] = (0, import_react43.useState)({ left: menu.x, top: menu.y });
+ (0, import_react43.useLayoutEffect)(() => {
+ const box = ref.current?.getBoundingClientRect();
+ if (!box) return;
+ setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
+ }, [menu.x, menu.y]);
+ if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react43.createElement)(AgentMenu, { menu, roster, actions, leaving });
+ const bot = roster.byId[menu.id];
+ const room2 = roster.roomsById[menu.id];
+ const entry = bot ?? room2;
+ if (entry === void 0) return null;
+ const isMainBot = isMainOf(roster, menu.id);
+ const isTile = roster.mainBotId === menu.id;
+ const mainCount = (roster.mainBotIds ?? []).length;
+ const groups = bot?.remote ? [
+ [
+ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
+ ],
+ [
+ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)]
+ ],
+ pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]),
+ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]]
+ ] : [
+ [
+ isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
+ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
+ ],
+ [
+ // One way to edit a Bot (#385): its name is edited in Edit Bot too.
+ bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null,
+ room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null,
+ room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null,
+ bot ? [t("Duplicate"), DuplicateIcon, async () => {
+ const copy2 = await actions.duplicateBot(bot.id);
+ actions.openSession(copy2.id);
+ }] : null,
+ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null,
+ bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null
+ ],
+ bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [],
+ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]],
+ [
+ isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })],
+ isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true]
+ ]
+ ];
+ const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0);
+ return (0, import_react43.createElement)(
+ "div",
+ { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 },
+ shown.map((group, index) => (0, import_react43.createElement)(
+ import_react43.Fragment,
+ { key: index },
+ index > 0 ? (0, import_react43.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
+ group.map(([label, Icon, run, danger]) => (0, import_react43.createElement)("button", {
+ key: label,
+ type: "button",
+ role: "menuitem",
+ className: danger ? "bt-danger" : void 0,
+ onClick: () => {
+ actions.closeMenu();
+ void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
+ }
+ }, (0, import_react43.createElement)(Icon), label))
+ ))
+ );
+}
+function AgentMenu({ menu, roster, actions, leaving }) {
+ const ref = (0, import_react43.useRef)(null);
+ const [place, setPlace] = (0, import_react43.useState)({ left: menu.x, top: menu.y });
+ (0, import_react43.useLayoutEffect)(() => {
+ const box = ref.current?.getBoundingClientRect();
+ if (!box) return;
+ setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
+ }, [menu.x, menu.y]);
+ const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId];
+ if (agent === void 0) return null;
+ const groups = menu.kind === "agent" ? [
+ [
+ [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })]
+ ],
+ [
+ [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true]
+ ]
+ ] : [
+ [
+ [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)]
+ ],
+ [
+ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true]
+ ]
+ ];
+ return (0, import_react43.createElement)(
+ "div",
+ { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 },
+ groups.map((group, index) => (0, import_react43.createElement)(
+ import_react43.Fragment,
+ { key: index },
+ index > 0 ? (0, import_react43.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
+ group.map(([label, Icon, run, danger]) => (0, import_react43.createElement)("button", {
+ key: label,
+ type: "button",
+ role: "menuitem",
+ className: danger ? "bt-danger" : void 0,
+ onClick: () => {
+ actions.closeMenu();
+ void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error));
+ }
+ }, (0, import_react43.createElement)(Icon), label))
+ ))
+ );
+}
-`;
- if (INLINE_BLOCK.test(display)) return ` ${marked} `;
- if (tag === "li") return ` · ${marked}`;
- return marked;
- }
- const body = document.body;
- let text2 = "";
- if (body) {
- text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " ");
- }
- if (text2.length > limits.chars) {
- text2 = text2.slice(0, limits.chars);
- stop ||= `the text passed ${limits.chars} characters`;
- }
- const page = {
- url: location.href,
- title: document.title,
- lang: document.documentElement.lang || "",
- description: document.querySelector('meta[name="description"]')?.content || "",
- selection: String(getSelection() || "").slice(0, 4e3),
- format: "markdown",
- text: text2,
- items,
- stats: { ...stats, ms: Math.round(performance.now() - started) }
- };
- if (stop) page.truncated = stop;
- return page;
+// src/client/pane-width.js
+var DEFAULT_WIDTH = 520;
+var DETAILS_WIDTH = 380;
+var DETAILS_MAX = 520;
+var MIN_WIDTH = 360;
+var MIN_CHAT = 420;
+var MIN_DETAILS = 240;
+function panelWidthRange(view, viewport) {
+ const room2 = Math.max(0, viewport - MIN_CHAT);
+ if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)];
+ const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2));
+ return [low, Math.max(low, Math.min(DETAILS_MAX, room2))];
}
-var readPageScript = (limits = READ_LIMITS) => `(${readPage})(${JSON.stringify(limits)})`;
-var READ_PAGE_SCRIPT = readPageScript();
-// src/client/bot-browsers.js
-var STORE_KEY = STORAGE.browsers;
-var MAX_LIVE = 6;
-var READ_TIMEOUT_MS = 1e4;
-var PREVIEW_TIMEOUT_MS = 3e3;
-var RESIZE_LATER_MS = 200;
-var SEARCH_URL = "https://www.bing.com/search?q=";
-var PREVIEW_SCRIPT = `(() => {
- const meta = key => document.querySelector('meta[name="' + key + '"],meta[property="' + key + '"]')?.content ?? ''
- const paragraph = [...document.querySelectorAll('main p, article p, p')].map(node => node.innerText ?? '').find(text => text.trim().length > 40) ?? ''
- const text = meta('description') || meta('og:description') || paragraph || document.body?.innerText || ''
- return text.replace(/\\s+/g, ' ').trim().slice(0, 200)
-})()`;
-var EMPTY = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, live: false, excerpt: "" };
-var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.internal)$/i;
-var BLOCKED = "Cloud metadata addresses do not open here.";
-var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname);
-function browserAddress(input) {
- const text2 = String(input ?? "").trim();
- if (text2 === "") return null;
- if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null;
- if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null;
- const host = text2.split(/[/?#]/)[0];
- const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host);
- const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host));
- if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href;
- return `${SEARCH_URL}${encodeURIComponent(text2)}`;
+// src/client/storage.js
+var STORAGE = {
+ unread: "ds-bot:unread",
+ reactions: "ds-bot:reactions",
+ seen: "ds-bot:seen",
+ friendLines: "ds-bot:friend-lines",
+ prefs: "ds-bot:prefs",
+ surface: "ds-bot:surface",
+ agentSessions: "ds-bot:agent-sessions",
+ browserWidth: "ds-bot:browser-width",
+ browsers: "ds-bot.browser.v2"
+};
+var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"];
+var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"];
+var LEGACY_THEMES = { grok: "mono" };
+function legacyKeys(key) {
+ if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS;
+ const name = key.slice(key.indexOf(":") + 1);
+ return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`);
}
-function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) {
- const views = /* @__PURE__ */ new Map();
- const owner = /* @__PURE__ */ new Map();
- const tabs = createSource2({});
- const pages = createSource2({});
- let saved = {};
- try {
- saved = JSON.parse(storage?.getItem(STORE_KEY) ?? "{}") ?? {};
- } catch {
- saved = {};
+var migrateBrowsers = (storage) => {
+ if (storage.getItem(STORAGE.browsers) !== null) {
+ for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key);
+ return;
}
- let layer;
- let shown = null;
- let rect = null;
- let visible = false;
- let resizeLater = 0;
- let disposed = false;
- let serial = 0;
- const savedTab = (botId, tabId) => saved[botId]?.tabs?.find((entry) => entry.id === tabId);
- const publish2 = (tabId, patch) => pages.set((state) => {
- const botId = owner.get(tabId);
- return { ...state, [tabId]: { ...EMPTY, ...savedTab(botId, tabId), ...state[tabId], ...patch } };
- });
- const writeSaved = () => {
- try {
- storage?.setItem(STORE_KEY, JSON.stringify(saved));
- } catch {
- }
- };
- const persist = (botId) => {
- const state = tabs.getSnapshot()[botId];
- if (state === void 0) {
- if (saved[botId] === void 0) return;
- const { [botId]: _, ...rest } = saved;
- saved = rest;
- } else {
- const snapshot = pages.getSnapshot();
- saved = {
- ...saved,
- [botId]: {
- tabs: state.ids.map((id) => ({ id, url: snapshot[id]?.url ?? "", title: snapshot[id]?.title ?? "" })),
- active: state.active
- }
- };
- }
- writeSaved();
- };
- const ensureTabs = (botId) => {
- if (tabs.getSnapshot()[botId] !== void 0) return;
- const entries2 = saved[botId]?.tabs ?? [];
- const ids = entries2.map((entry) => entry.id);
- const active = ids.includes(saved[botId]?.active) ? saved[botId].active : ids.at(-1) ?? null;
- for (const entry of entries2) owner.set(entry.id, botId);
- tabs.set((state) => ({ ...state, [botId]: { ids, active } }));
- for (const entry of entries2) publish2(entry.id, { url: entry.url ?? "", title: entry.title ?? "", live: false });
- };
- const ensureLayer = () => {
- if (layer?.isConnected) return layer;
- layer = doc.createElement("div");
- layer.className = "bt-browser-layer";
- Object.assign(layer.style, { position: "fixed", inset: "0", zIndex: "41", pointerEvents: "none" });
- doc.body.append(layer);
- return layer;
- };
- const place = (entry) => {
- const box = rect ?? { left: 0, top: 0, width: 800, height: 600 };
- Object.assign(entry.element.style, {
- left: `${box.left}px`,
- top: `${box.top}px`,
- width: `${box.width}px`,
- height: `${box.height}px`,
- visibility: shown === entry.tabId && visible && rect ? "visible" : "hidden"
- });
- };
- async function preview(tabId) {
- const entry = views.get(tabId);
- if (entry === void 0 || !entry.ready || !/^https?:/i.test(entry.element.getURL())) return false;
- let timer;
- const timeout = new Promise((resolve) => {
- timer = setTimeout(() => resolve(""), PREVIEW_TIMEOUT_MS);
- });
+ for (const key of LEGACY_BROWSER_KEYS) {
try {
- const excerpt = await Promise.race([entry.element.executeJavaScript(PREVIEW_SCRIPT), timeout]);
- if (typeof excerpt !== "string" || excerpt === "" || views.get(tabId) !== entry) return false;
- publish2(tabId, { excerpt });
- return true;
+ const value = storage.getItem(key);
+ if (value === null) continue;
+ const previous = JSON.parse(value) ?? {};
+ const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => {
+ const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" };
+ return [botId, { tabs: [tab], active: tab.id }];
+ }));
+ storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
+ for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
+ return;
} catch {
- return false;
- } finally {
- clearTimeout(timer);
}
}
- const observe = (entry) => {
- const { element } = entry;
- if (!entry.ready) return;
- const url = element.getURL();
- if (url.startsWith("about:blank#")) return;
- if (entry.fresh) {
- element.clearHistory();
- entry.fresh = false;
- }
- const title = element.getTitle();
- publish2(entry.tabId, { url, title, loading: element.isLoading(), canGoBack: element.canGoBack(), canGoForward: element.canGoForward(), live: true });
- persist(entry.botId);
- };
- async function create(tabId) {
- const botId = owner.get(tabId);
- const reservation = await bridge.acquire(`bot:${botId}`);
- if (disposed) {
- await bridge.release(reservation.lease).catch(() => {
- });
- return void 0;
- }
- const element = doc.createElement("webview");
- element.dataset.sidebarBrowserFrame = "webview";
- Object.assign(element.style, { position: "fixed", display: "flex", border: "0", pointerEvents: "auto", background: "#fff" });
- element.dataset.btBrowser = tabId;
- element.setAttribute("name", reservation.lease);
- element.setAttribute("partition", reservation.partition);
- element.setAttribute("allowpopups", "");
- element.setAttribute("src", `about:blank#${reservation.lease}`);
- const entry = { botId, tabId, element, lease: reservation.lease, ready: false, fresh: true, pending: pages.getSnapshot()[tabId]?.url || savedTab(botId, tabId)?.url, usedAt: Date.now() };
- const lifetime = new AbortController();
- entry.stop = () => lifetime.abort();
- const { signal } = lifetime;
- const unsubscribe = bridge.onOpenRequested(reservation.lease, (url) => void openTab(botId, url));
- signal.addEventListener("abort", unsubscribe, { once: true });
- element.addEventListener("dom-ready", () => {
- entry.ready = true;
- observe(entry);
- if (entry.pending) {
- const url = entry.pending;
- entry.pending = void 0;
- void element.loadURL(url).catch(() => {
- });
+};
+function migrateStorage(storage = globalThis.localStorage) {
+ for (const key of Object.values(STORAGE)) {
+ if (key === STORAGE.browsers) continue;
+ for (const legacy of legacyKeys(key)) {
+ try {
+ const value = storage.getItem(legacy);
+ if (value !== null && storage.getItem(key) === null) storage.setItem(key, value);
+ storage.removeItem(legacy);
+ } catch {
}
- }, { signal });
- for (const name of ["did-navigate", "did-navigate-in-page", "did-start-loading", "did-stop-loading", "page-title-updated"]) {
- element.addEventListener(name, () => observe(entry), { signal });
}
- element.addEventListener("did-stop-loading", () => {
- void preview(tabId);
- }, { signal });
- element.addEventListener("page-favicon-updated", (event) => {
- const favicon = event.favicons?.[0];
- if (typeof favicon === "string" && favicon.startsWith("data:")) publish2(tabId, { favicon });
- }, { signal });
- element.addEventListener("did-start-navigation", (event) => {
- if (!event.isMainFrame) return;
- if (blockedAddress(event.url)) {
- element.stop();
- publish2(tabId, { loading: false, error: BLOCKED });
- return;
- }
- publish2(tabId, { loading: true, error: null });
- }, { signal });
- element.addEventListener("did-fail-load", (event) => {
- if (event.isMainFrame && event.errorCode !== -3) publish2(tabId, { loading: false, error: event.errorDescription || `Error ${event.errorCode}` });
- }, { signal });
- element.addEventListener("render-process-gone", () => {
- if (views.get(tabId) === void 0) return;
- void release(tabId);
- publish2(tabId, { loading: false, error: "The page crashed." });
- }, { signal });
- views.set(tabId, entry);
- ensureLayer().append(element);
- place(entry);
- publish2(tabId, { live: true, loading: Boolean(entry.pending) });
- return entry;
}
- const opening = /* @__PURE__ */ new Map();
- function open(tabId) {
- const entry = views.get(tabId);
- if (entry) return Promise.resolve(entry);
- let job = opening.get(tabId);
- if (job === void 0) {
- job = create(tabId).catch((error) => {
- console.warn("[ds-bot] browser acquire", error);
- publish2(tabId, { loading: false, error: String(error?.message ?? error) });
- return void 0;
- }).finally(() => opening.delete(tabId));
- opening.set(tabId, job);
- void job.then(() => trim());
+ try {
+ migrateBrowsers(storage);
+ } catch {
+ }
+ try {
+ const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null");
+ if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) {
+ storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] }));
}
- return job;
+ } catch {
}
- function trim() {
- const idle = [...views.values()].filter((entry) => entry.tabId !== shown).sort((a, b) => a.usedAt - b.usedAt);
- while (views.size > MAX_LIVE && idle.length > 0) void release(idle.shift().tabId);
+}
+
+// src/client/details-drawer.js
+var import_react44 = require("react");
+var TAB_LABELS = { details: "Details", browser: "Browser" };
+var hostOf = (url) => {
+ try {
+ return new URL(url).hostname.replace(/^www\./, "");
+ } catch {
+ return "";
}
- async function release(tabId) {
- const entry = views.get(tabId);
- if (entry === void 0) return;
- views.delete(tabId);
- entry.stop();
- entry.element.remove();
- publish2(tabId, { live: false, loading: false });
- await bridge.release(entry.lease).catch((error) => console.warn("[ds-bot] browser release", error));
+};
+var shortUrl = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
+var EMPTY_TABS = { ids: [], active: null };
+var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" };
+var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]);
+function PermissionRow({ bot, roster, actions, run }) {
+ const [pending, setPending] = (0, import_react44.useState)(null);
+ const catalog = roster.permissions;
+ const saved = bot.permission ?? catalog?.defaultPreset;
+ (0, import_react44.useEffect)(() => {
+ if (pending === saved) setPending(null);
+ }, [pending, saved]);
+ if (!catalog?.options?.length) return null;
+ const current = pending ?? saved;
+ const pick2 = (value) => {
+ if (value === current && bot.permission !== void 0) return;
+ setPending(value);
+ void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null));
+ };
+ return (0, import_react44.createElement)(
+ "div",
+ { className: "bt-drawer-permission" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Permissions")),
+ (0, import_react44.createElement)(
+ Segmented,
+ { label: t("Permissions"), value: current },
+ catalog.options.map((option) => (0, import_react44.createElement)("button", {
+ key: option.value,
+ type: "button",
+ "aria-pressed": option.value === current,
+ title: option.description,
+ onClick: () => pick2(option.value),
+ className: DANGER.has(option.value) ? "bt-perm-danger" : void 0
+ }, PERMISSION_LABELS[option.value] ? t(PERMISSION_LABELS[option.value]) : option.name))
+ )
+ );
+}
+function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) {
+ const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS);
+ const pages = useBrowserPages((map) => map);
+ (0, import_react44.useEffect)(() => {
+ actions.browserEnsure?.(bot.id);
+ }, [bot.id]);
+ if (roster.browser !== true || !actions.browserShow) {
+ return (0, import_react44.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app."));
}
- function openTab(botId, url) {
- ensureTabs(botId);
- const tabId = `t${Date.now().toString(36)}-${++serial}`;
- owner.set(tabId, botId);
- tabs.set((state) => {
- const own = state[botId] ?? { ids: [], active: null };
- return { ...state, [botId]: { ids: [...own.ids, tabId], active: tabId } };
- });
- publish2(tabId, { url: "", title: "", live: false });
- if (typeof url === "string" && url !== "") void navigate(tabId, url);
- persist(botId);
- return tabId;
+ if (state.ids.length === 0) {
+ return (0, import_react44.createElement)(
+ "div",
+ { className: "bt-browser-empty-mark" },
+ (0, import_react44.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }),
+ (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
+ (0, import_react44.createElement)("div", { className: "bt-muted" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })),
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser"))
+ );
}
- async function closeTab(botId, tabId) {
- const state = tabs.getSnapshot()[botId];
- if (state === void 0 || !state.ids.includes(tabId)) return;
- await opening.get(tabId)?.catch(() => {
- });
- if (shown === tabId) await show(null);
- await release(tabId);
- tabs.set((snapshot) => {
- const current = snapshot[botId];
- if (current === void 0 || !current.ids.includes(tabId)) return snapshot;
- const at = current.ids.indexOf(tabId);
- const ids = current.ids.filter((id) => id !== tabId);
- const active = current.active === tabId ? ids[Math.min(at, ids.length - 1)] ?? null : current.active;
- const next = { ...snapshot };
- if (ids.length === 0) delete next[botId];
- else next[botId] = { ids, active };
- return next;
- });
- owner.delete(tabId);
- pages.set((snapshot) => {
- if (snapshot[tabId] === void 0) return snapshot;
- const { [tabId]: _, ...rest } = snapshot;
- return rest;
+ return (0, import_react44.createElement)(
+ "div",
+ { className: "bt-bcards" },
+ state.ids.map((tabId, index) => {
+ const page = pages[tabId] ?? {};
+ const label = page.title || shortUrl(page.url) || t("New tab");
+ return (0, import_react44.createElement)(
+ "div",
+ { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } },
+ (0, import_react44.createElement)(
+ "button",
+ { type: "button", className: "bt-bcard-shot", "aria-label": t("Open {title}", { title: label }), title: page.url, onClick: () => actions.browserPick(sessionId, tabId) },
+ page.excerpt ? (0, import_react44.createElement)(
+ "span",
+ { className: "bt-bcard-preview" },
+ (0, import_react44.createElement)("span", { className: "bt-bcard-ptitle" }, label),
+ (0, import_react44.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt)
+ ) : (0, import_react44.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react44.createElement)(GlobeIcon), (0, import_react44.createElement)("span", null, label))
+ ),
+ (0, import_react44.createElement)("button", {
+ type: "button",
+ className: "bt-bcard-x",
+ "aria-label": t("Close tab"),
+ title: t("Close tab"),
+ onClick: () => {
+ void actions.browserCloseTab(bot.id, tabId);
+ }
+ }, "×"),
+ (0, import_react44.createElement)(
+ "div",
+ { className: "bt-bcard-row" },
+ (0, import_react44.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }),
+ (0, import_react44.createElement)("span", { className: "bt-bcard-title" }, label),
+ page.url ? (0, import_react44.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null
+ )
+ );
+ }),
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react44.createElement)(PlusIcon), t("New tab"))
+ );
+}
+function About({ text: text2, title = "About" }) {
+ const [open, setOpen] = (0, import_react44.useState)(false);
+ const long = text2.split("\n").length > 5 || text2.length > 240;
+ const body = (0, import_react44.useRef)(null);
+ useMorph(body, open);
+ return (0, import_react44.createElement)(
+ "div",
+ null,
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t(title)),
+ (0, import_react44.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2),
+ long ? (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null
+ );
+}
+function SharedRoomApprovals({ room: room2 }) {
+ const [items, setItems] = (0, import_react44.useState)(room2.pendingApprovals ?? []);
+ const [busy, setBusy] = (0, import_react44.useState)("");
+ const face = room2.origin === "face-to-face";
+ (0, import_react44.useEffect)(() => {
+ const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length;
+ if (haveItems) setItems(room2.pendingApprovals);
+ if (room2.viewerRole !== "invited-owner" && !face) return void 0;
+ let alive = true;
+ call("social/status", {}).then((value) => {
+ if (!alive) return;
+ if (room2.viewerRole === "invited-owner" && !haveItems) {
+ setItems(Array.isArray(value?.approvals) ? value.approvals : []);
+ }
+ }, () => {
});
- persist(botId);
- }
- function activate(botId, tabId) {
- ensureTabs(botId);
- const state = tabs.getSnapshot()[botId];
- if (state === void 0 || !state.ids.includes(tabId) || state.active === tabId) return;
- tabs.set((snapshot) => ({ ...snapshot, [botId]: { ...state, active: tabId } }));
- persist(botId);
- }
- const activeTab = (botId) => tabs.getSnapshot()[botId]?.active ?? null;
- async function show(tabId, box = null) {
- if (tabId !== null && tabId === shown && visible && box !== null) {
- rect = box;
- const entry2 = views.get(tabId);
- if (entry2) place(entry2);
- clearTimeout(resizeLater);
- resizeLater = setTimeout(() => {
- for (const other of views.values()) place(other);
- }, RESIZE_LATER_MS);
+ return () => {
+ alive = false;
+ };
+ }, [room2.id, room2.viewerRole, room2.pendingCount, face]);
+ if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null;
+ const decide = async (item, decision) => {
+ setBusy(item.id);
+ try {
+ await call("social/decide-approval", { approvalId: item.id, decision });
+ setItems((list) => list.filter((entry) => entry.id !== item.id));
+ } finally {
+ setBusy("");
+ }
+ };
+ return (0, import_react44.createElement)(
+ "div",
+ { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react44.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))),
+ items.map((item) => (0, import_react44.createElement)(
+ "div",
+ { key: item.id, className: "bt-set-row" },
+ (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")),
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")),
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject"))
+ ))
+ );
+}
+function FaceMembers({ room: room2, roster, actions }) {
+ const people2 = facePeople(room2, roster);
+ const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot });
+ return (0, import_react44.createElement)(
+ "div",
+ null,
+ (0, import_react44.createElement)(FaceLead, { room: room2, roster, pick: pick2 }),
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })),
+ (0, import_react44.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react44.createElement)(FacePersonRow, {
+ key: person.key,
+ person,
+ roster,
+ className: "bt-member",
+ onOpenBot: (id) => actions.openSession(id),
+ lead: room2.face?.lead?.handle
+ })))
+ );
+}
+function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) {
+ const live = room2.presentedParticipants ?? room2.participants ?? [];
+ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
+ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
+ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+ const { entries: entries2, now } = useMemberWork(room2, useWork);
+ const invited = room2.viewerRole === "invited-owner";
+ const title = roomTitle(room2);
+ const leaveMine = () => {
+ const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId);
+ if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] });
+ };
+ const face = room2.origin === "face-to-face";
+ return [
+ (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }),
+ invited && room2.sharedFrom && !face ? (0, import_react44.createElement)(
+ "div",
+ { key: "from", className: "bt-shared-from" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, title),
+ (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })),
+ room2.sharedFrom.invitedAt ? (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null,
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
+ ) : face ? null : room2.notice ? (0, import_react44.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react44.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")),
+ face ? null : (0, import_react44.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }),
+ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react44.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react44.createElement)(
+ "div",
+ { key: "members" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })),
+ (0, import_react44.createElement)("div", { className: "bt-members", role: "list" }, [
+ ...humans.map((person) => (0, import_react44.createElement)(
+ "div",
+ { key: person.id, role: "listitem" },
+ (0, import_react44.createElement)(
+ "div",
+ { className: "bt-member" },
+ (0, import_react44.createElement)("span", { className: "bt-member-name" }, (0, import_react44.createElement)("span", null, person.displayName || person.id)),
+ (0, import_react44.createElement)("span", { className: "bt-chip" }, t("Human")),
+ person.role === "admin" ? (0, import_react44.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
+ )
+ )),
+ ...remotes.map((person) => (0, import_react44.createElement)(
+ "div",
+ { key: person.handle || person.id, role: "listitem" },
+ (0, import_react44.createElement)(
+ "div",
+ { className: "bt-member" },
+ (0, import_react44.createElement)(
+ "span",
+ { className: "bt-member-name" },
+ (0, import_react44.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
+ (0, import_react44.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person))
+ ),
+ face ? null : (0, import_react44.createElement)("span", { className: "bt-chip" }, t("Friend's Bot"))
+ )
+ )),
+ ...members.map((member) => {
+ const entry = entries2.find((item) => item.id === member.id);
+ return (0, import_react44.createElement)(
+ "div",
+ { key: member.id, role: "listitem" },
+ (0, import_react44.createElement)(
+ "div",
+ { className: "bt-member" },
+ (0, import_react44.createElement)(
+ "button",
+ { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) },
+ (0, import_react44.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }),
+ (0, import_react44.createElement)("span", null, member.name)
+ ),
+ room2.admin === member.id ? (0, import_react44.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
+ ),
+ entry ? (0, import_react44.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null
+ );
+ })
+ ])
+ ),
+ face ? null : (0, import_react44.createElement)(
+ "div",
+ { key: "mode" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Who answers")),
+ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone")))
+ ),
+ face || !usage ? null : (0, import_react44.createElement)(
+ "div",
+ { key: "usage" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")),
+ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, room2.name), (0, import_react44.createElement)("span", null, usage))
+ )
+ ];
+}
+function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
+ const owner = roster.byId[sessionId];
+ const part = owner ? currentPart(owner) : sessionId;
+ const isMainBot = isMainOf(roster, owner?.id);
+ const [tab, setTab] = (0, import_react44.useState)("details");
+ const room2 = roster.roomsById[sessionId];
+ const [view, setView] = (0, import_react44.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview");
+ const [editing, setEditing] = (0, import_react44.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId])));
+ const [draftName, setDraftName] = (0, import_react44.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : ""));
+ const [copied, setCopied] = (0, import_react44.useState)(false);
+ const [error, setError] = (0, import_react44.useState)("");
+ const glide = useGlide(tab);
+ const last = (0, import_react44.useRef)({ view, tab, move: void 0 });
+ if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" };
+ else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" };
+ const move = last.current.move;
+ const side = move === "tab" ? void 0 : move;
+ const close = () => actions.closeOverlay();
+ useEscape(close);
+ const bot = owner;
+ const running = useSessionStatus((map) => map.get(part)?.running === true);
+ const doing = useActivity((map) => map[bot?.id ?? sessionId]);
+ const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster)));
+ const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []]));
+ const run = (promise) => Promise.resolve(promise).then(() => {
+ setError("");
+ return true;
+ }).catch((failure) => {
+ setError(failure?.message ?? String(failure));
+ return false;
+ });
+ const share = () => {
+ const text2 = transcript;
+ if (!text2) {
+ actions.notify?.(t("Nothing to copy yet"));
return;
}
- shown = tabId;
- if (box) rect = box;
- visible = tabId !== null && box !== null;
- for (const entry2 of views.values()) place(entry2);
- if (tabId === null) return;
- const entry = await open(tabId);
- if (entry === void 0 || shown !== tabId) return;
- entry.usedAt = Date.now();
- place(entry);
- }
- async function navigate(tabId, input) {
- const url = browserAddress(input);
- if (url === null || blockedAddress(url)) {
- publish2(tabId, { error: url === null ? "Only http and https addresses open here." : BLOCKED });
- return false;
- }
- const entry = await open(tabId);
- if (entry === void 0) return false;
- publish2(tabId, { url, loading: true, error: null });
- persist(owner.get(tabId));
- if (!entry.ready) entry.pending = url;
- else void entry.element.loadURL(url).catch(() => {
+ void copiedToClipboard(text2).then((written) => {
+ if (!written) {
+ actions.reportError?.(new Error(t("Could not copy the conversation")), "share");
+ return;
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1600);
+ actions.notify?.(t("Conversation copied as Markdown"));
});
- return true;
+ };
+ const errorLine = error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
+ if ((bot || room2) && view === "settings" && !bot?.remote) {
+ return [
+ (0, import_react44.createElement)(
+ "div",
+ { key: "head", className: "bt-subhead", "data-move": move },
+ (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react44.createElement)(ChevronLeftIcon)),
+ (0, import_react44.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")),
+ hasStrip ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react44.createElement)(CloseIcon))
+ ),
+ (0, import_react44.createElement)(
+ "div",
+ { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move },
+ errorLine,
+ bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close })
+ )
+ ];
}
- async function forget(botId) {
- ensureTabs(botId);
- for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) {
- await opening.get(tabId)?.catch(() => {
- });
- if (shown === tabId) await show(null);
- await release(tabId);
- owner.delete(tabId);
- pages.set((snapshot) => {
- if (snapshot[tabId] === void 0) return snapshot;
- const { [tabId]: _, ...rest } = snapshot;
- return rest;
- });
- }
- tabs.set((snapshot) => {
- if (snapshot[botId] === void 0) return snapshot;
- const { [botId]: _, ...rest } = snapshot;
- return rest;
- });
- if (saved[botId] !== void 0) {
- const { [botId]: _, ...rest } = saved;
- saved = rest;
- writeSaved();
- }
+ const top = (0, import_react44.createElement)(
+ "div",
+ { className: "bt-drawer-top", "data-move": side },
+ bot || room2 ? (0, import_react44.createElement)("button", {
+ type: "button",
+ className: "bt-round",
+ "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
+ title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
+ "data-info-row": "settings",
+ onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings")
+ }, (0, import_react44.createElement)(GearIcon)) : null,
+ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react44.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react44.createElement)(GlobeIcon)) : null,
+ bot || room2 ? (0, import_react44.createElement)("button", {
+ type: "button",
+ className: "bt-round",
+ // #1347: the accessible name says what the click does, which is copy — "Share conversation"
+ // promised a sharing step that never came. It still says "Copied" after a copy, so a screen
+ // reader hears the same confirmation the toast gives.
+ "aria-label": copied ? t("Copied conversation") : t("Copy conversation"),
+ title: copied ? t("Copied") : t("Copy conversation as Markdown"),
+ onClick: share
+ }, copied ? (0, import_react44.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react44.createElement)(ShareIcon)) : null,
+ hasStrip ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react44.createElement)(CloseIcon))
+ );
+ if (!bot && !room2) {
+ return [
+ (0, import_react44.createElement)(import_react44.Fragment, { key: "top" }, top),
+ (0, import_react44.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react44.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team.")))
+ ];
}
- return {
- tabs,
- pages,
- /** Restore a Bot's saved strip (or an empty one) so the panel can render it. */
- ensureTabs,
- openTab,
- closeTab,
- activate,
- preview,
- show,
- navigate,
- command(tabId, name) {
- const entry = views.get(tabId);
- if (!entry?.ready) return;
- if (name === "back" && entry.element.canGoBack()) entry.element.goBack();
- else if (name === "forward" && entry.element.canGoForward()) entry.element.goForward();
- else if (name === "reload") entry.element.reload();
- else if (name === "stop") entry.element.stop();
- },
- focus(tabId) {
- views.get(tabId)?.element.focus();
- },
- /** Pages a read can reach: each Bot's active tab, live and showing a document. */
- openPages() {
- const open2 = {};
- const snapshot = pages.getSnapshot();
- for (const [botId, state] of Object.entries(tabs.getSnapshot())) {
- const tabId = state.active;
- const entry = tabId ? views.get(tabId) : void 0;
- const page = tabId ? snapshot[tabId] : void 0;
- if (entry?.ready && page?.url) open2[botId] = { url: page.url, title: page.title };
- }
- return open2;
- },
- /** A Bot's read reaches its active tab. */
- async read(botId) {
- const tabId = activeTab(botId);
- const entry = tabId ? views.get(tabId) : void 0;
- if (!entry?.ready) throw new Error("This Bot has no browser page open.");
- let timer;
- const timeout = new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error("The page took too long to read.")), READ_TIMEOUT_MS);
- });
- try {
- return await Promise.race([entry.element.executeJavaScript(READ_PAGE_SCRIPT), timeout]);
- } finally {
- clearTimeout(timer);
- }
- },
- noteRead(botId) {
- const tabId = activeTab(botId);
- if (tabId) publish2(tabId, { readAt: Date.now() });
- },
- release,
- forget,
- /** Forget every Bot outside `botIds`, as when Bots were deleted from another window. */
- retain(botIds) {
- const keep = new Set(botIds);
- const known = /* @__PURE__ */ new Set([...Object.keys(tabs.getSnapshot()), ...Object.keys(saved), ...owner.values()]);
- for (const entry of views.values()) known.add(entry.botId);
- return Promise.all([...known].filter((botId) => !keep.has(botId)).map(forget));
- },
- dispose() {
- disposed = true;
- clearTimeout(resizeLater);
- for (const entry of views.values()) void release(entry.tabId);
- layer?.remove();
- }
+ const current = bot?.name ?? room2.name;
+ const saveName = async () => {
+ setEditing(false);
+ const next = draftName.trim();
+ if (next === current || room2 && !room2.named && next === "") return;
+ if (bot && next) await run(actions.updateBot(bot.id, { name: next }));
+ if (room2) await run(actions.updateRoom(room2.id, { name: next }));
};
+ const subtitle = bot ? bot.role : roomSubtitle(room2, roster);
+ const tabs = bot ? ["details", "browser"] : ["details"];
+ return [
+ (0, import_react44.createElement)(import_react44.Fragment, { key: "top" }, top),
+ (0, import_react44.createElement)(
+ "div",
+ { key: "id", className: "bt-drawer-id", "data-move": side },
+ bot ? (0, import_react44.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react44.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+ bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react44.createElement)("input", {
+ className: "bt-drawer-name-input",
+ autoFocus: true,
+ value: draftName,
+ "aria-label": bot ? t("Bot name") : t("Group name"),
+ placeholder: room2 ? t("Name after members") : void 0,
+ onChange: (event) => setDraftName(event.target.value),
+ onBlur: saveName,
+ onKeyDown: (event) => {
+ if (event.key === "Enter") void saveName();
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setEditing(false);
+ }
+ }
+ }) : (0, import_react44.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => {
+ setDraftName(room2 && !room2.named ? "" : current);
+ setEditing(true);
+ } }, current),
+ subtitle ? (0, import_react44.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react44.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null
+ ),
+ bot?.remote ? null : (0, import_react44.createElement)(
+ "div",
+ { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side },
+ (0, import_react44.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+ tabs.map((key) => (0, import_react44.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))
+ ),
+ (0, import_react44.createElement)(
+ "div",
+ { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move },
+ errorLine,
+ room2 && tab === "details" ? (0, import_react44.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null,
+ bot && bot.remote && tab === "details" ? [
+ bot.remote.handle ? (0, import_react44.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null,
+ (0, import_react44.createElement)(FlyBotDetails, { key: "social", bot, roster, actions })
+ ] : null,
+ bot && !bot.remote && tab === "details" ? [
+ (0, import_react44.createElement)(
+ "div",
+ { key: "model", className: "bt-drawer-model" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Model")),
+ (0, import_react44.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }),
+ modelOf(bot, roster) ? null : (0, import_react44.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name }))
+ ),
+ (0, import_react44.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }),
+ (0, import_react44.createElement)(
+ "div",
+ { key: "folder", className: "bt-drawer-model" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Working folder")),
+ (0, import_react44.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) })
+ ),
+ bot.instructions ? (0, import_react44.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react44.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")),
+ roster.memory === false ? null : (0, import_react44.createElement)(MemoryRow, { key: "memory", bot, actions }),
+ usage ? (0, import_react44.createElement)(
+ "div",
+ { key: "usage" },
+ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")),
+ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, bot.name), (0, import_react44.createElement)("span", null, usage))
+ ) : null,
+ (0, import_react44.createElement)(ScheduleSummary, { key: "routines", bot, actions })
+ ] : null,
+ bot && !bot.remote && tab === "browser" ? (0, import_react44.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null
+ )
+ ];
+}
+
+// src/client/browser-pane.js
+var import_react45 = require("react");
+var import_react_dom10 = require("react-dom");
+var PANEL_LINGER_MS = 300;
+var WIDTH_KEY = STORAGE.browserWidth;
+var READ_FLASH_MS = 2400;
+var EMPTY_PAGE = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, excerpt: "" };
+var EMPTY_TABS2 = { ids: [], active: null };
+var SPRING_LEAD2 = springEasing(576, 39.4);
+var SPRING_TRAIL2 = springEasing(144, 19.7);
+var icon2 = (paths) => function BrowserIcon() {
+ return (0, import_react45.createElement)(
+ "svg",
+ { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+ paths.map((d, index) => (0, import_react45.createElement)("path", { key: index, d }))
+ );
+};
+var GlobeIcon = icon2(["M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M3 10h14", "M10 3c2 2.2 2.9 4.5 2.9 7S12 14.8 10 17c-2-2.2-2.9-4.5-2.9-7S8 5.2 10 3z"]);
+var BackIcon = icon2(["M12.5 4.5L7 10l5.5 5.5"]);
+var ForwardIcon = icon2(["M7.5 4.5L13 10l-5.5 5.5"]);
+var ReloadIcon = icon2(["M15.5 10a5.5 5.5 0 1 1-1.6-3.9", "M15.5 3.5v3.5H12"]);
+var StopIcon = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
+var CloseIcon2 = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
+var PlusTabIcon = icon2(["M10 5v10M5 10h10"]);
+var LockIcon2 = icon2(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]);
+function Favicon({ src, className }) {
+ const [failed, setFailed] = (0, import_react45.useState)(null);
+ if (!src || failed === src) return (0, import_react45.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react45.createElement)(GlobeIcon));
+ return (0, import_react45.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) });
}
-var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
-function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.randomUUID(), retryMs = 1e3 }) {
- let stopped = false;
- let poll;
- let reported = "";
- const answered = /* @__PURE__ */ new Map();
- const answer = async ({ id, botId }) => {
- let job = answered.get(id);
- if (job === void 0) {
- job = (async () => {
- try {
- const result = { id, ok: true, page: await browsers.read(botId) };
- browsers.noteRead(botId);
- return result;
- } catch (error) {
- return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
- }
- })();
- answered.set(id, job);
- if (answered.size > 200) answered.delete(answered.keys().next().value);
+var savedWidth = () => {
+ try {
+ const value = Number(localStorage.getItem(WIDTH_KEY));
+ return Number.isFinite(value) && value >= MIN_DETAILS ? value : null;
+ } catch {
+ return null;
+ }
+};
+var fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high)));
+var widthRange = (view, viewport) => panelWidthRange(view, viewport);
+var room = { rule: null, selector: "" };
+var roomRule = () => {
+ const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected;
+ if (live && room.selector) return room.rule;
+ const column = document.querySelector('[class*="_centerCol"]');
+ const selector = column ? [...column.classList].filter((name) => name.includes("_centerCol")).map((name) => `.${CSS.escape(name)}`).join(",") : "";
+ if (live && !selector) return room.rule;
+ const declarations2 = room.rule?.style.cssText ?? "";
+ room.rule?.parentStyleSheet?.ownerNode?.remove();
+ const style = document.createElement("style");
+ style.dataset.dshBot = "chat-room";
+ style.textContent = `${selector || '[class*="_centerCol"]'}{${declarations2}}`;
+ document.head.append(style);
+ room.rule = style.sheet.cssRules[0];
+ room.selector = selector;
+ return room.rule;
+};
+var roomHold = 0;
+var chatRoom = {
+ /** The column's right margin in px, or null for the shell's own. */
+ set(width) {
+ const { style } = roomRule();
+ if (width === null) style.removeProperty("margin-right");
+ else style.setProperty("margin-right", `${width}px`, "important");
+ },
+ /** While on, a margin change lands at once instead of gliding. */
+ hold(on) {
+ cancelAnimationFrame(roomHold);
+ if (on) {
+ roomRule().style.setProperty("transition", "none", "important");
+ return;
}
- await post("browser-result", await job).catch((error) => console.warn("[ds-bot] browser result", error));
- };
- const loop = async () => {
- let failures = 0;
- while (!stopped) {
- poll = new AbortController();
- const open = browsers.openPages();
- reported = JSON.stringify(open);
- try {
- const requests = await post("browser-wait", { clientId, open }, poll.signal);
- failures = 0;
- for (const request of Array.isArray(requests) ? requests : []) void answer(request);
- } catch {
- if (stopped) return;
- if (poll.signal.aborted) continue;
- failures += 1;
- await sleep(Math.min(3e4, retryMs * 2 ** Math.min(failures, 5)));
- }
+ roomHold = requestAnimationFrame(() => {
+ roomHold = requestAnimationFrame(() => roomRule().style.removeProperty("transition"));
+ });
+ },
+ /** The columns themselves, for a drag's per-frame writes. */
+ columns() {
+ roomRule();
+ return room.selector ? [...document.querySelectorAll(room.selector)] : [];
+ }
+};
+var shortAddress = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
+var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
+var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab");
+function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) {
+ const pages = useBrowserPages((map) => map);
+ const wrap = (0, import_react45.useRef)(null);
+ const ind = (0, import_react45.useRef)(null);
+ const spot = (0, import_react45.useRef)(null);
+ const under = (0, import_react45.useRef)(null);
+ (0, import_react45.useLayoutEffect)(() => {
+ const box = wrap.current;
+ const capsule = ind.current;
+ if (!box || !capsule) return void 0;
+ const node = view === "details" ? box.querySelector(".bt-ptab-bot") : box.querySelector(`[data-tab="${active}"]`);
+ under.current = node;
+ if (!node) {
+ capsule.style.opacity = "0";
+ spot.current = null;
+ return void 0;
}
- };
- const offPages = browsers.pages.subscribe(() => {
- if (JSON.stringify(browsers.openPages()) !== reported) poll?.abort();
+ if (view === "details") box.scrollLeft = 0;
+ else node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
+ const to = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
+ const from = spot.current;
+ spot.current = to;
+ capsule.style.opacity = "1";
+ capsule.style.left = `${to.left}px`;
+ capsule.style.right = `${to.right}px`;
+ if (from === null || from.left === to.left && from.right === to.right || reducedMotion2()) return void 0;
+ const ahead = to.left > from.left ? "right" : "left";
+ const behind = ahead === "right" ? "left" : "right";
+ const lead = capsule.animate([{ [ahead]: `${from[ahead]}px` }, { [ahead]: `${to[ahead]}px` }], { duration: SPRING_LEAD2.duration, easing: SPRING_LEAD2.easing });
+ const trail = capsule.animate([{ [behind]: `${from[behind]}px` }, { [behind]: `${to[behind]}px` }], { duration: SPRING_TRAIL2.duration, easing: SPRING_TRAIL2.easing });
+ return () => {
+ lead.cancel();
+ trail.cancel();
+ };
});
- void loop();
- return () => {
- stopped = true;
- offPages();
- poll?.abort();
- };
-}
-
-// src/client/mobile.js
-var MOBILE_MAX_WIDTH = 640;
-var MOBILE_CSS = `
-@media (max-width:${MOBILE_MAX_WIDTH}px){
-.bt-held-actions{max-width:100%}
-.bt-held-card{padding:10px 12px}
-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px}
-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer}
-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default}
-.bt-held-retry{min-height:32px}
-.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))}
-.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)}
-.bt-flybot-pointer{max-width:100%;min-width:0}
-.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none}
-.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
-.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px}
-.bt-inbox-card{flex-wrap:wrap;row-gap:8px}
-.bt-inbox-copy{flex-basis:calc(100% - 38px)}
-/* In progress and Done only. A Needs you ask is the whole text: inbox.js unsets this clamp on those five statuses (#1177). */
-.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
-.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px}
-.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px}
-}
-`;
-
-// src/client/panes.js
-var PANE_MIN_WIDTH_CSS = `
-.bt-panel{min-width:0}
-.bt-panel-view{min-width:0}
-.bt-drawer-body{min-width:0;overflow-wrap:anywhere}
-.bt-drawer-body :is(.bt-muted,.bt-note,.bt-section-title,.bt-drawer-role,.bt-drawer-permission){min-width:0;overflow-wrap:anywhere}
-.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
-.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
-[class*="_centerCol"]{min-width:0}
-html,body,#root{overflow-x:clip}
-[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
-[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
-`;
-var COMPOSER = "[data-composer-input], [data-composer-card]";
-function clipScroll(el) {
- if (el && el.scrollLeft > 0) el.scrollLeft = 0;
-}
-function appFrameOf(doc) {
- if (typeof doc.querySelector !== "function") return null;
- const col = doc.querySelector('[class*="_centerCol"]');
- const frame = col?.parentElement;
- if (!frame) return null;
- const name = frame.className;
- const text2 = typeof name === "string" ? name : name?.baseVal ?? "";
- return text2.includes("_frame") ? frame : null;
-}
-function levelShell(doc, from) {
- const page = doc.scrollingElement || doc.documentElement;
- clipScroll(page);
- clipScroll(doc.documentElement);
- clipScroll(doc.body);
- clipScroll(appFrameOf(doc));
- for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at);
-}
-function composerOf(node) {
- let el = node;
- if (el && el.nodeType !== 1) el = el.parentElement;
- if (!el || typeof el.closest !== "function") return null;
- return el.closest(COMPOSER);
+ (0, import_react45.useEffect)(() => {
+ const box = wrap.current;
+ if (!box) return void 0;
+ const observer = new ResizeObserver(() => {
+ const node = under.current;
+ const capsule = ind.current;
+ if (!node?.isConnected || !capsule || spot.current === null) return;
+ spot.current = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
+ capsule.style.left = `${spot.current.left}px`;
+ capsule.style.right = `${spot.current.right}px`;
+ });
+ observer.observe(box);
+ return () => observer.disconnect();
+ }, []);
+ return (0, import_react45.createElement)(
+ "div",
+ { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") },
+ (0, import_react45.createElement)(
+ "div",
+ { className: "bt-ptabs-in", ref: wrap },
+ (0, import_react45.createElement)(
+ "button",
+ {
+ type: "button",
+ role: "tab",
+ "aria-selected": view === "details",
+ className: "bt-ptab bt-ptab-bot",
+ title: bot.name,
+ onClick: () => onView("details")
+ },
+ (0, import_react45.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }),
+ view === "details" ? (0, import_react45.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null
+ ),
+ ids.map((tabId) => {
+ const page = pages[tabId] ?? EMPTY_PAGE;
+ const on = view === "browser" && tabId === active;
+ return (0, import_react45.createElement)(
+ "button",
+ {
+ key: tabId,
+ type: "button",
+ role: "tab",
+ "aria-selected": on,
+ "data-tab": tabId,
+ className: "bt-ptab",
+ title: page.url || t("New tab"),
+ onClick: () => {
+ actions.browserActivate(bot.id, tabId);
+ onView("browser");
+ },
+ onAuxClick: (event) => {
+ if (event.button === 1) {
+ event.preventDefault();
+ void actions.browserCloseTab(bot.id, tabId);
+ }
+ }
+ },
+ (0, import_react45.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }),
+ (0, import_react45.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)),
+ (0, import_react45.createElement)("span", {
+ className: "bt-ptab-x",
+ role: "button",
+ "aria-label": t("Close tab"),
+ title: t("Close tab"),
+ onClick: (event) => {
+ event.stopPropagation();
+ void actions.browserCloseTab(bot.id, tabId);
+ }
+ }, "×")
+ );
+ }),
+ (0, import_react45.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true })
+ ),
+ // New tab and Close stay outside the scrolling tabs, so a narrow panel keeps both.
+ (0, import_react45.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react45.createElement)(PlusTabIcon)),
+ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn bt-ptab-close", "aria-label": t("Close panel"), title: t("Close panel"), onClick: () => actions.closePanel() }, (0, import_react45.createElement)(CloseIcon2))
+ );
}
-function watchShellLevel(doc) {
- const later = (from) => {
- const reset = () => levelShell(doc, from);
- reset();
- if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
- };
- const onFocus = (event) => later(composerOf(event.target) || void 0);
- const onInput = (event) => {
- const composer = composerOf(event.target);
- if (composer) later(composer);
- };
- const onScroll = (event) => {
- const target = event.target;
- if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc);
- };
- doc.addEventListener("focusin", onFocus);
- doc.addEventListener("input", onInput);
- doc.addEventListener("scroll", onScroll, true);
- return () => {
- doc.removeEventListener("focusin", onFocus);
- doc.removeEventListener("input", onInput);
- doc.removeEventListener("scroll", onScroll, true);
+function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) {
+ const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE;
+ const frame = (0, import_react45.useRef)(null);
+ const input = (0, import_react45.useRef)(null);
+ const [draft, setDraft] = (0, import_react45.useState)(null);
+ const showGuest = landed && !leaving && Boolean(page.url) && !page.error;
+ (0, import_react45.useLayoutEffect)(() => {
+ const element = frame.current;
+ if (!element || leaving || !tabId) {
+ void actions.browserShow(null);
+ return void 0;
+ }
+ let raf = 0;
+ const measure = () => {
+ cancelAnimationFrame(raf);
+ raf = 0;
+ const box = element.getBoundingClientRect();
+ void actions.browserShow(tabId, showGuest ? { left: box.left, top: box.top, width: box.width, height: box.height } : null);
+ };
+ const schedule = () => {
+ if (!raf) raf = requestAnimationFrame(measure);
+ };
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(element);
+ window.addEventListener("resize", schedule);
+ return () => {
+ cancelAnimationFrame(raf);
+ observer.disconnect();
+ window.removeEventListener("resize", schedule);
+ };
+ }, [tabId, showGuest, leaving]);
+ (0, import_react45.useEffect)(() => {
+ setDraft(null);
+ if (landed && !page.url) input.current?.focus();
+ }, [tabId, landed]);
+ const go = (event) => {
+ event.preventDefault();
+ const text2 = draft ?? "";
+ if (text2.trim() === "") return;
+ setDraft(null);
+ input.current?.blur();
+ void actions.browserNavigate(tabId, text2).then((opened) => {
+ if (opened) actions.browserFocus(tabId);
+ });
};
-}
-
-// src/client/project-board.js
-var import_react47 = require("react");
-var PROJECT_BOARD_CSS = `
-/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the
- bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the
- Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */
-.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
-.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))}
-.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto}
-.bt-project-board{padding:16px 18px}
-.bt-project-group{margin:0 0 14px}
-.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
-.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
-.bt-project-row[data-column="done"]{opacity:.7}
-.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
-.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
-.bt-project-empty{padding:22px 4px;opacity:.7}
-/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new
- component language: a heading, a list, and a monospace hash the reader can compare. */
-.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
-.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
-.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55}
-.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
-.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
-.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
-.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-missing{font-size:11px;opacity:.6}
-.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
-/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
- carries. A severe card is the only one painted louder, because it is the only one that waits. */
-.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
-.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
-.bt-project-recipients{font-size:11px;opacity:.65}
-.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
-.bt-project-holder{font-size:11px;opacity:.7}
-.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
-.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
-.bt-project-metrics{margin:0 0 14px}
-.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
-.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px}
-.bt-project-metric-value{font-size:18px;font-weight:600}
-.bt-project-metric-label{font-size:12px;font-weight:600}
-.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3}
-.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7}
-.bt-project-running{margin:0 0 14px}
-.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0}
-.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em}
-.bt-project-running-row[role="button"]{cursor:pointer}
-.bt-project-running-row[data-overdue="true"]{color:#c2410c}
-.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8}
-.bt-project-counts b{font-weight:600}
-.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default}
-.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto}
-.bt-project-state{font-size:11px;margin-left:auto}
-.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b}
-.bt-project-reason{font-size:11px;opacity:.7}
-
-/* #1855: the finished cards are one group of their own, closed until the reader opens it — a
- Project with months of history reads as the work in flight, not as its archive. */
-.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
-.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none}
-.bt-project-done>summary::-webkit-details-marker{display:none}
-.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6}
-.bt-project-done[open]>summary::before{content:'\\25BE'}
-.bt-project-done[open]>summary{margin:0 0 8px}
-
-/* #1749: management panels. Same surface as the board, one form row per control, and a status
- line that quotes the host's own answer — a refused save reads where the reader pressed it. */
-.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
-.bt-project-panel h3{margin:0}
-.bt-project-field{display:flex;flex-direction:column;gap:4px}
-.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
-.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
-.bt-project-field textarea{min-height:88px;resize:vertical}
-.bt-project-row-actions{display:flex;gap:8px;align-items:center}
-.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
-.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
-.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6}
-.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px}
-.bt-project-note{font-size:12px;opacity:.65;margin:0}
-
-/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool.
- One quiet block above the cards, and a Bot row is a button because a reader is looking a
- person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */
-.bt-project-team{margin:0 0 14px}
-.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
-.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
-.bt-project-team-row[data-open-bot]{cursor:pointer}
-.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
-.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px}
-.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
-.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
-.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
-`;
-var PROJECT_SOURCES = ["bot", "group", "workflow"];
-var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" };
-var text = (value) => typeof value === "string" ? value.trim() : "";
-var DONE_COLUMN = "done";
-var boardOfRow = (row, task) => {
- if (task === void 0) return null;
- if (row.source === "group") {
- const id = text(task.origin?.roomId);
- return id === "" ? null : { kind: "group", id };
- }
- if (row.source === "bot") {
- const id = text(task.ownerBotId);
- return id === "" ? null : { kind: "bot", id };
+ const editing = draft !== null;
+ const secure = /^https:/i.test(page.url);
+ return (0, import_react45.createElement)(
+ "div",
+ { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) },
+ (0, import_react45.createElement)(
+ "div",
+ { className: "bt-browser-bar" },
+ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back"), title: t("Back"), disabled: !page.canGoBack, onClick: () => actions.browserCommand(tabId, "back") }, (0, import_react45.createElement)(BackIcon)),
+ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Forward"), title: t("Forward"), disabled: !page.canGoForward, onClick: () => actions.browserCommand(tabId, "forward") }, (0, import_react45.createElement)(ForwardIcon)),
+ (0, import_react45.createElement)("button", {
+ type: "button",
+ className: "bt-icon-btn",
+ disabled: !page.url,
+ "aria-label": page.loading ? t("Stop") : t("Reload"),
+ title: page.loading ? t("Stop") : t("Reload"),
+ onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload")
+ }, (0, import_react45.createElement)(page.loading ? StopIcon : ReloadIcon)),
+ (0, import_react45.createElement)(
+ "form",
+ { className: "bt-browser-address", onSubmit: go, "data-editing": editing || void 0 },
+ (0, import_react45.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react45.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)),
+ (0, import_react45.createElement)("input", {
+ ref: input,
+ type: "text",
+ spellCheck: false,
+ autoComplete: "off",
+ "aria-label": t("Address"),
+ placeholder: t("Search or enter address"),
+ value: editing ? draft : shortAddress(page.url),
+ onFocus: (event) => {
+ setDraft(page.url);
+ const target = event.target;
+ requestAnimationFrame(() => target.select());
+ },
+ onBlur: () => setDraft(null),
+ onChange: (event) => setDraft(event.target.value),
+ onKeyDown: (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setDraft(null);
+ event.currentTarget.blur();
+ }
+ }
+ })
+ ),
+ page.loading ? (0, import_react45.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null
+ ),
+ (0, import_react45.createElement)(
+ "div",
+ { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 },
+ page.error ? (0, import_react45.createElement)(
+ "div",
+ { className: "bt-browser-empty" },
+ (0, import_react45.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")),
+ (0, import_react45.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)),
+ page.url ? (0, import_react45.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => {
+ void actions.browserNavigate(tabId, page.url);
+ } }, t("Try again")) : null
+ ) : !page.url ? (0, import_react45.createElement)(
+ "div",
+ { className: "bt-browser-empty" },
+ (0, import_react45.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }),
+ (0, import_react45.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
+ (0, import_react45.createElement)("div", { className: "bt-browser-empty-text" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name }))
+ ) : null
+ )
+ );
+}
+function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
+ const bot = roster.byId[pane.id];
+ const canBrowse = Boolean(bot && roster.browser === true && actions.browserShow);
+ const view = pane.view === "browser" && canBrowse ? "browser" : "details";
+ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2);
+ const active = tabState.active;
+ const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE);
+ const root = (0, import_react45.useRef)(null);
+ const fly = (0, import_react45.useRef)(null);
+ const [landed, setLanded] = (0, import_react45.useState)(false);
+ const [settling, setSettling] = (0, import_react45.useState)(false);
+ const [width, setWidth] = (0, import_react45.useState)(savedWidth);
+ const [viewport, setViewport] = (0, import_react45.useState)(() => window.innerWidth);
+ const [dragging, setDragging] = (0, import_react45.useState)(false);
+ const [resizing, setResizing] = (0, import_react45.useState)(false);
+ const instant = dragging || resizing;
+ const [reading, setReading] = (0, import_react45.useState)(false);
+ const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport));
+ (0, import_react45.useLayoutEffect)(() => {
+ if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH);
+ }, [view, width]);
+ (0, import_react45.useLayoutEffect)(() => {
+ chatRoom.hold(instant);
+ }, [instant]);
+ const [last, setLast] = (0, import_react45.useState)({ view, width: panelWidth });
+ if (last.view !== view || last.width !== panelWidth) {
+ setLast({ view, width: panelWidth });
+ if (last.view !== view) setSettling(last.width !== panelWidth && !instant);
}
- return null;
-};
-var decisionOf = (file) => {
- if (file === null || typeof file !== "object") return null;
- const path = text(file.path);
- if (path === "") return null;
- return {
- key: `decision:${path}`,
- path,
- name: text(file.name) || path.slice(path.lastIndexOf("/") + 1),
- date: text(file.date),
- title: text(file.title),
- sha256: text(file.sha256),
- shortHash: text(file.sha256).slice(0, 12)
- };
-};
-var meetingOf = (meeting, status) => {
- if (meeting === null || typeof meeting !== "object") return null;
- const id = text(meeting.id);
- if (id === "") return null;
- return {
- key: `meeting:${id}`,
- id,
- date: text(meeting.date),
- question: text(meeting.question) || text(meeting.title),
- owner: text(meeting.owner) || null,
- status,
- path: text(meeting.path),
- blockingQuestion: text(meeting.blockingQuestion) || null,
- // #1831: the level and recipients the host answered, and the refusal to advance a severe card.
- // Nothing is inferred here: a record written before the slice carries an ordinary card and no
- // recipients, exactly as the host reads it.
- level: text(meeting.level) || "ordinary",
- recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [],
- advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null },
- // #1861: the host's participants with who each position speaks for, so the row can say a
- // Project Chief or an agent held it for someone rather than for themselves.
- participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : []
- };
-};
-var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : [];
-function projectBoardRecord({ decisions, meetings } = {}) {
- const dir = text(decisions?.dir) || null;
- const files = listOf(decisions?.files);
- const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : [];
- const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : [];
- return {
- decisions: {
- dir,
- files,
- count: files.length,
- empty: files.length === 0,
- emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`)
- },
- meetings: {
- decided,
- pending,
- count: decided.length + pending.length,
- empty: decided.length === 0 && pending.length === 0,
- emptyText: text(meetings?.reason) || "No meeting of this Project is recorded."
+ const shownView = (0, import_react45.useRef)(view);
+ (0, import_react45.useLayoutEffect)(() => {
+ const switched = settling && shownView.current !== view;
+ shownView.current = view;
+ if (switched) chatRoom.hold(true);
+ chatRoom.set(leaving ? null : panelWidth);
+ if (switched) chatRoom.hold(false);
+ }, [view, panelWidth, leaving]);
+ (0, import_react45.useEffect)(() => () => {
+ chatRoom.set(null);
+ chatRoom.hold(false);
+ }, []);
+ (0, import_react45.useEffect)(() => {
+ let timer;
+ const onResize = () => {
+ setResizing(true);
+ setViewport(window.innerWidth);
+ clearTimeout(timer);
+ timer = setTimeout(() => setResizing(false), 160);
+ };
+ window.addEventListener("resize", onResize);
+ return () => {
+ clearTimeout(timer);
+ window.removeEventListener("resize", onResize);
+ };
+ }, []);
+ (0, import_react45.useEffect)(() => {
+ if (!settling) return void 0;
+ const timer = setTimeout(() => setSettling(false), SPRING_SHAPE.duration + 40);
+ return () => clearTimeout(timer);
+ }, [settling, view]);
+ (0, import_react45.useEffect)(() => {
+ const timer = setTimeout(() => setLanded(true), 360);
+ return () => clearTimeout(timer);
+ }, []);
+ (0, import_react45.useEffect)(() => {
+ if (canBrowse) actions.browserEnsure(bot.id);
+ }, [bot?.id, canBrowse]);
+ (0, import_react45.useEffect)(() => {
+ if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details");
+ }, [view, active, tabState.ids.length]);
+ (0, import_react45.useEffect)(() => {
+ if (view !== "browser" || leaving) void actions.browserShow?.(null);
+ }, [view, leaving]);
+ (0, import_react45.useEffect)(() => () => {
+ void actions.browserShow?.(null);
+ }, []);
+ (0, import_react45.useEffect)(() => {
+ if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0;
+ setReading(true);
+ const timer = setTimeout(() => setReading(false), READ_FLASH_MS);
+ return () => clearTimeout(timer);
+ }, [page.readAt]);
+ const beginSwitch = (next) => {
+ if (reducedMotion2()) return;
+ const panel = root.current;
+ const outgoing = panel?.querySelector(".bt-panel-view");
+ if (outgoing) {
+ const ghostEl = document.createElement("div");
+ ghostEl.className = "bt-panel-ghost";
+ ghostEl.style.width = `${outgoing.offsetWidth}px`;
+ ghostEl.append(outgoing.cloneNode(true));
+ outgoing.parentElement.append(ghostEl);
+ void ghostEl.animate([
+ { opacity: 1, transform: "scale(1)" },
+ { opacity: 0, transform: "scale(0.985)" }
+ ], { duration: 160, easing: "ease-out", fill: "forwards" }).finished.catch(() => {
+ }).finally(() => ghostEl.remove());
}
+ const source = next === "browser" ? panel?.querySelector(".bt-drawer-id .bt-mark") : panel?.querySelector(".bt-ptab-bot .bt-mark");
+ if (source) fly.current = { node: source.cloneNode(true), from: source.getBoundingClientRect() };
};
-}
-function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) {
- const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== "";
- const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
- const list = [];
- const archived = [];
- const seen = /* @__PURE__ */ new Set();
- for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) {
- if (!isRow(row)) continue;
- const id = text(row.id);
- if (seen.has(id)) continue;
- seen.add(id);
- const column = text(row.column) || "not-started";
- (column === DONE_COLUMN ? archived : list).push(row);
+ const switchView = (next) => {
+ if (next === view || leaving) return;
+ beginSwitch(next);
+ actions.setPanelView(next);
+ };
+ const panelActions = { ...actions };
+ for (const name of ["openBrowser", "browserNewTab", "browserPick"]) {
+ if (actions[name]) panelActions[name] = (...args) => {
+ if (view !== "browser") beginSwitch("browser");
+ return actions[name](...args);
+ };
}
- const projectRow = (row) => {
- const task = byId.get(text(row.id));
- return {
- id: text(row.id),
- key: `task:${text(row.id)}`,
- column: text(row.column) || "not-started",
- assignee: text(row.assignee) || null,
- runId: text(row.runId) || null,
- nodeId: text(row.nodeId) || null,
- title: text(task?.title) || text(task?.note) || null,
- priority: text(task?.priority) || null,
- audit: text(row.audit) || null,
- // #1831: who is doing the card, and who is speaking for whom. Both are host truth;
- // a card carrying neither renders exactly as before.
- holder: text(row.holder) || null,
- speaksFor: text(row.speaksFor) || null,
- board: boardOfRow(row, task)
+ (0, import_react45.useLayoutEffect)(() => {
+ const move = fly.current;
+ if (!move) return;
+ fly.current = null;
+ const target = view === "browser" ? root.current?.querySelector(".bt-ptab-bot .bt-mark") : root.current?.querySelector(".bt-drawer-id .bt-mark");
+ if (!target || move.from.width === 0) return;
+ const growing = root.current.getAnimations({ subtree: true }).filter((animation) => animation.animationName === "bt-strip-in");
+ const at = growing.map((animation) => animation.currentTime);
+ for (const animation of growing) animation.currentTime = animation.effect.getComputedTiming().endTime;
+ const to = target.getBoundingClientRect();
+ growing.forEach((animation, index) => {
+ animation.currentTime = at[index];
+ });
+ const clone = move.node;
+ Object.assign(clone.style, {
+ position: "fixed",
+ left: `${move.from.left}px`,
+ top: `${move.from.top}px`,
+ width: `${move.from.width}px`,
+ height: `${move.from.height}px`,
+ margin: "0",
+ transformOrigin: "0 0",
+ zIndex: "80",
+ pointerEvents: "none",
+ visibility: ""
+ });
+ document.body.append(clone);
+ target.style.visibility = "hidden";
+ const scale = to.width / move.from.width;
+ const anim = clone.animate([
+ { transform: "translate(0px, 0px) scale(1)" },
+ { transform: `translate(${to.left - move.from.left}px, ${to.top - move.from.top}px) scale(${scale})` }
+ ], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, fill: "forwards" });
+ void anim.finished.catch(() => {
+ }).finally(() => {
+ clone.remove();
+ target.style.visibility = "";
+ });
+ }, [view, pane.id]);
+ const startResize = (event) => {
+ if (event.button !== 0 || leaving) return;
+ event.preventDefault();
+ const panel = root.current;
+ const grip = event.currentTarget;
+ grip.setPointerCapture?.(event.pointerId);
+ const range = widthRange(view, window.innerWidth);
+ const from = { x: event.clientX, width: panelWidth };
+ let next = panelWidth;
+ setDragging(true);
+ setSettling(false);
+ chatRoom.hold(true);
+ const columns = chatRoom.columns();
+ for (const column of columns) column.style.setProperty("transition", "none", "important");
+ const onMove = (move) => {
+ next = fitWidth(from.width + from.x - move.clientX, range);
+ panel.style.width = `${next}px`;
+ if (columns.length === 0) chatRoom.set(next);
+ for (const column of columns) column.style.setProperty("margin-right", `${next}px`, "important");
+ };
+ const ends = ["pointerup", "pointercancel", "lostpointercapture"];
+ const onEnd = (end) => {
+ if (end.type === "pointerup") onMove(end);
+ grip.removeEventListener("pointermove", onMove);
+ for (const name of ends) grip.removeEventListener(name, onEnd);
+ chatRoom.set(next);
+ for (const column of columns) {
+ column.style.removeProperty("margin-right");
+ column.style.removeProperty("transition");
+ }
+ panel.style.setProperty("--bt-panel-w", `${next}px`);
+ panel.style.removeProperty("width");
+ setDragging(false);
+ if (next === from.width) return;
+ setWidth(next);
+ try {
+ localStorage.setItem(WIDTH_KEY, String(next));
+ } catch {
+ }
};
+ grip.addEventListener("pointermove", onMove);
+ for (const name of ends) grip.addEventListener(name, onEnd);
};
- const groups = PROJECT_SOURCES.map((source) => ({
- source,
- label: SOURCE_LABELS[source],
- rows: list.filter((row) => row.source === source).map(projectRow)
- })).filter((group) => group.rows.length > 0);
- const countOf = (value) => value === void 0 || value === null ? null : value;
- const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => {
- const id = text(item.id);
- const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source);
- const task = byId.get(id);
- return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) };
- });
- const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
- const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" }));
- const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null }));
- const queueGroups = [
- { group: "waiting", label: "Waiting", items: waiting },
- { group: "inflight", label: "In flight", items: inflight },
- { group: "blocked", label: "Blocked", items: blocked }
- ].filter((entry) => entry.items.length > 0);
- const rawCounts = queues?.counts ?? null;
- return {
- projectId: text(project?.id) || null,
- title: text(project?.name) || "Project",
- canManage: canManage === true,
- groups,
- // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a
- // finished card is still a card — only its place on the board changed.
- done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) },
- queues: queueGroups,
- counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) },
- count: list.length,
- empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0,
- emptyText: "No cards yet.",
- record: projectBoardRecord({ decisions, meetings }),
- metrics: metricsView(metrics, { tasks })
- };
-}
-function projectRowNode(row, onOpen) {
- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
- return (0, import_react47.createElement)(
- "div",
+ const hasStrip = canBrowse && tabState.ids.length > 0;
+ return (0, import_react45.createElement)(
+ "aside",
{
- key: row.key,
- className: "bt-project-row",
- "data-task-id": row.id,
- "data-column": row.column,
- "data-priority": row.priority ?? void 0,
- role: open === void 0 ? void 0 : "button",
- tabIndex: open === void 0 ? void 0 : 0,
- onClick: open,
- onKeyDown: open === void 0 ? void 0 : (event) => {
- if (event.key === "Enter" || event.key === " ") open();
+ ref: root,
+ className: "bt-panel",
+ "data-view": view,
+ "aria-label": view === "browser" && bot ? t("{name}'s browser", { name: bot.name }) : t("Conversation details"),
+ "data-leaving": leaving || void 0,
+ "aria-hidden": leaving || void 0,
+ "data-settling": settling || void 0,
+ "data-instant": instant || void 0,
+ "data-dragging": dragging || void 0,
+ style: { "--bt-panel-w": `${panelWidth}px` },
+ onAnimationEnd: (event) => {
+ if (event.target === event.currentTarget) setLanded(true);
}
},
- (0, import_react47.createElement)("span", { className: "bt-project-column" }, t(row.column)),
- (0, import_react47.createElement)(
- "span",
- { className: "bt-project-title-wrap" },
- (0, import_react47.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
- // The audit text the row already carries (#1730): shown inside the card detail, never a
- // second read. A row without one renders exactly as before.
- row.audit === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-audit" }, row.audit)
- ),
- // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
- // says so, so the three identities (Bot, Assistant, user) never read as one.
- row.holder === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`),
- row.speaksFor === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })),
- row.priority === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-priority" }, row.priority),
- row.runId === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
+ leaving ? null : (0, import_react45.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }),
+ // Over the whole window while dragging: the cursor stays a resize cursor and the
+ // pages underneath (the chat, the guest) see no pointer.
+ dragging ? (0, import_react_dom10.createPortal)((0, import_react45.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null,
+ hasStrip ? (0, import_react45.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null,
+ view === "browser" && bot ? (0, import_react45.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react45.createElement)(
+ "div",
+ { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" },
+ (0, import_react45.createElement)(DetailsDrawer, {
+ sessionId: pane.id,
+ startRenaming: pane.renaming,
+ startTab: pane.tab,
+ hasStrip,
+ roster,
+ actions: panelActions,
+ useSessions,
+ useSessionStatus,
+ useActivity,
+ useWork,
+ useBrowserTabs,
+ useBrowserPages
+ })
+ )
);
}
-function ProjectBoardRows({ view, onOpen }) {
- if (view.empty) return (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText));
- return view.groups.map((group) => (0, import_react47.createElement)(
- "section",
- { key: group.source, className: "bt-project-group", "data-source": group.source },
- (0, import_react47.createElement)("h4", null, t(group.label)),
- group.rows.map((row) => projectRowNode(row, onOpen))
- ));
+var BROWSER_CSS = `
+.bt-panel{--bt-panel-view-fade:${SPRING_SHAPE.duration}ms}
+.bt-ptabs{position:relative;display:flex;align-items:center;height:44px;flex:none;border-bottom:.5px solid var(--bt-line);padding:0 6px;gap:0;animation:bt-strip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
+@keyframes bt-strip-in{from{height:0;opacity:0}}
+.bt-ptabs-in{position:relative;display:flex;align-items:center;gap:2px;flex:1;min-width:0;height:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;padding:4px 2px;scroll-padding-left:48px;mask-image:linear-gradient(to right,#000,#000 calc(100% - 10px),transparent)}
+.bt-ptabs-in>.bt-ptab-bot{position:sticky;left:0;z-index:2;background:var(--bt-main)}
+.bt-ptabs-in>.bt-ptab-bot[aria-selected=true]{background:transparent}
+.bt-ptabs-in::-webkit-scrollbar{display:none}
+.bt-ptab{position:relative;z-index:1;display:inline-flex;align-items:center;gap:6px;height:32px;max-width:180px;padding:0 8px;flex:none;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12.5px;cursor:pointer;white-space:nowrap;transition:color .12s ease}
+.bt-ptab:hover{color:var(--bt-ink)}
+.bt-ptab[aria-selected=true]{color:var(--bt-ink)}
+.bt-ptab-bot{padding:0 7px}
+.bt-ptab-bot .bt-mark{flex:none}
+.bt-ptab-name{max-width:96px;overflow:hidden;text-overflow:ellipsis;font-weight:500;animation:bt-ptab-label .18s ease}
+@keyframes bt-ptab-label{from{opacity:0;filter:blur(4px);max-width:0}}
+.bt-ptab-fav{width:14px;height:14px;flex:none;border-radius:3px;object-fit:cover;color:var(--bt-ink-3)}
+.bt-ptab-globe{display:inline-flex}
+.bt-ptab-globe svg{width:14px;height:14px}
+.bt-ptab-title{max-width:140px;overflow:hidden;text-overflow:ellipsis}
+.bt-ptab-x{flex:none;width:16px;height:16px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--bt-ink-3);opacity:0;transition:opacity .12s ease,background-color .12s ease}
+.bt-ptab:hover .bt-ptab-x,.bt-ptab[aria-selected=true] .bt-ptab-x{opacity:1}
+.bt-ptab-x:hover{background:var(--bt-active);color:var(--bt-ink)}
+.bt-ptab-new{position:relative;z-index:1;flex:none;width:28px;height:32px;border:0;border-radius:9px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
+.bt-ptab-new:hover{color:var(--bt-ink)}
+.bt-ptab-new svg{width:15px;height:15px}
+.bt-ptab-ind{position:absolute;top:4px;bottom:4px;border-radius:9px;background:var(--bt-hover);opacity:0;pointer-events:none;z-index:0;transition:opacity .15s ease}
+.bt-ptab-close{flex:none;margin-left:2px}
+.bt-panel-ghost{position:absolute;top:44px;right:0;bottom:0;overflow:hidden;pointer-events:none;z-index:2}
+.bt-panel-ghost>.bt-panel-view{position:absolute;inset:0}
+.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column;animation:bt-panel-view-in .22s cubic-bezier(.16,1,.3,1)}
+@keyframes bt-panel-view-in{from{opacity:0}}
+/* While the panel changes width its views keep their final width on the right edge, so
+ only the panel edge moves and nothing inside reflows frame by frame. */
+.bt-panel[data-settling]{overflow:hidden}
+.bt-panel[data-settling]>.bt-ptabs,.bt-panel[data-settling]>.bt-panel-view{flex-shrink:0;align-self:flex-end;box-sizing:border-box;width:calc(var(--bt-panel-w,320px) - .5px)}
+.bt-panel-browser{position:relative}
+.bt-browser-bar{position:relative;display:flex;align-items:center;gap:2px;height:52px;padding:0 10px 0 12px;flex:none;border-bottom:.5px solid var(--bt-line)}
+.bt-browser-bar .bt-icon-btn:disabled{opacity:.35;cursor:default;background:none}
+.bt-browser-address{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:32px;margin:0 4px;padding:0 12px;border-radius:999px;background:var(--bt-hover);border:.5px solid transparent;transition:background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
+.bt-browser-address:hover{background:var(--bt-active)}
+.bt-browser-address[data-editing]{background:var(--bt-main);border-color:var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04)}
+.bt-browser-scheme{display:inline-flex;color:var(--bt-ink-3);flex:none}
+.bt-browser-scheme svg{width:14px;height:14px}
+.bt-browser-address input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;padding:0;text-overflow:ellipsis}
+.bt-browser-address input::placeholder{color:var(--bt-ink-3)}
+.bt-browser-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;pointer-events:none}
+.bt-browser-progress::after{content:"";position:absolute;inset:0;width:40%;background:var(--bt-accent,#4D6BFE);border-radius:2px;animation:bt-browser-load 1.1s cubic-bezier(.4,0,.2,1) infinite}
+@keyframes bt-browser-load{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
+.bt-browser-frame{position:relative;flex:1;min-height:0;background:var(--bt-main)}
+.bt-browser-frame[data-guest]{background:#fff}
+.bt-browser-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:32px;text-align:center;animation:bt-foot-in .3s ease}
+.bt-browser-empty-title{font-size:15px;line-height:22px;font-weight:500;margin-top:6px}
+.bt-browser-empty-text{max-width:300px;font-size:13px;line-height:19px;color:var(--bt-ink-2);overflow-wrap:anywhere}
+.bt-browser-retry{margin-top:6px;height:32px;padding:0 14px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer}
+.bt-browser-retry:hover{background:var(--bt-hover)}
+.bt-bcards{display:flex;flex-direction:column;gap:12px}
+.bt-bcard{position:relative;border:.5px solid var(--bt-line-2);border-radius:12px;overflow:hidden;background:var(--bt-main);animation:bt-bcard-in .24s cubic-bezier(.16,1,.3,1) both;transition:border-color .12s ease,box-shadow .12s ease}
+@keyframes bt-bcard-in{from{opacity:0;filter:blur(4px);transform:translateY(4px)}}
+.bt-bcard[data-active]{border-color:var(--bt-accent,#4D6BFE);box-shadow:0 0 0 1px var(--bt-accent,#4D6BFE)}
+.bt-bcard-shot{position:relative;display:block;width:100%;aspect-ratio:16/10;border:0;background:var(--bt-hover);padding:0;cursor:pointer}
+.bt-bcard-blank{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--bt-ink-3);font-size:12px}
+.bt-bcard-blank svg{width:20px;height:20px}
+.bt-bcard-preview{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:14px 16px;text-align:left;background:linear-gradient(to bottom,var(--bt-main),var(--bt-hover));overflow:hidden}
+.bt-bcard-ptitle{font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.bt-bcard-excerpt{font-size:12px;line-height:17px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
+.bt-bcard-row{display:flex;align-items:center;gap:7px;padding:8px 10px;min-width:0}
+.bt-bcard-fav{width:14px;height:14px;flex:none;border-radius:3px;color:var(--bt-ink-3)}
+.bt-bcard-row svg.bt-bcard-fav{width:14px;height:14px}
+.bt-bcard-title{flex:1;min-width:0;font-size:12.5px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink)}
+.bt-bcard-host{flex:none;font-size:11px;line-height:15px;color:var(--bt-ink-3);max-width:38%;overflow:hidden;text-overflow:ellipsis}
+.bt-bcard-x{position:absolute;top:6px;right:6px;width:22px;height:22px;border:0;border-radius:7px;background:color-mix(in srgb,var(--bt-main) 88%,transparent);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;opacity:0;transition:opacity .12s ease;backdrop-filter:blur(4px)}
+.bt-bcard:hover .bt-bcard-x{opacity:1}
+.bt-bcard-new{display:flex;align-items:center;justify-content:center;gap:6px;height:40px;border:.5px dashed var(--bt-line-2);border-radius:12px;background:none;color:var(--bt-ink-3);font:inherit;font-size:12.5px;cursor:pointer}
+.bt-bcard-new:hover{color:var(--bt-ink);border-color:var(--bt-ink-3)}
+.bt-bcard-new svg{width:14px;height:14px}
+.bt-browser-empty-mark{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center}
+@media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}}
+`;
+
+// src/client/remote-bot-settings.js
+var import_react46 = require("react");
+var REMOTE_BOT_SETTINGS_CSS = `
+.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
+.bt-remote-sheet-layer[data-leaving]{pointer-events:none}
+.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto}
+.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
+.bt-remote-sheet:focus{outline:none}
+.bt-remote-sheet{position:relative;z-index:1;pointer-events:auto;width:min(320px,calc(100vw - 32px));max-height:min(70vh,420px);margin:0 auto 0;border-radius:14px;background:var(--bt-card,#fff);border:.5px solid var(--bt-line-2);box-shadow:0 12px 40px rgba(20,20,20,.16);display:flex;flex-direction:column;overflow:hidden;animation:bt-remote-sheet-in .2s ease-out}
+.bt-remote-sheet-layer[data-leaving] .bt-remote-sheet{animation:bt-remote-sheet-out .16s ease-in forwards}
+.bt-remote-sheet-head{display:flex;align-items:center;gap:10px;padding:12px 12px 8px}
+.bt-remote-sheet-who{display:flex;flex-direction:column;min-width:0;gap:1px;flex:1 1 auto}
+.bt-remote-sheet-name{font-size:16px;line-height:22px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-remote-sheet-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.bt-remote-sheet-body{padding:0 12px 14px;overflow:auto}
+@keyframes bt-remote-sheet-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
+@keyframes bt-remote-sheet-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(8px)}}
+@media (min-width:520px){
+.bt-remote-sheet-layer{align-items:center;padding:24px}
+.bt-remote-sheet{margin:0}
}
-function ProjectDoneGroup({ view, onOpen }) {
- if (view.done.rows.length === 0) return null;
+`;
+function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) {
+ const close = () => actions.closeSettings();
+ useEscape(close);
+ const dialog = (0, import_react46.useRef)(null);
+ (0, import_react46.useEffect)(() => {
+ const before = document.activeElement;
+ dialog.current?.focus?.({ preventScroll: true });
+ return () => {
+ if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true });
+ };
+ }, []);
+ const onKeyDown = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ close();
+ }
+ };
+ if (!bot?.remote) return null;
+ const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot");
+ return (0, import_react46.createElement)(
+ "div",
+ { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" },
+ (0, import_react46.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
+ (0, import_react46.createElement)(
+ "div",
+ { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown },
+ (0, import_react46.createElement)(
+ "div",
+ { className: "bt-remote-sheet-head" },
+ (0, import_react46.createElement)(BotMark, { bot, size: 40, live: true }),
+ (0, import_react46.createElement)(
+ "div",
+ { className: "bt-remote-sheet-who" },
+ (0, import_react46.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name),
+ (0, import_react46.createElement)("span", { className: "bt-remote-sheet-sub" }, who)
+ ),
+ (0, import_react46.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react46.createElement)(CloseIcon))
+ ),
+ (0, import_react46.createElement)(
+ "div",
+ { className: "bt-remote-sheet-body" },
+ bot.remote.handle ? (0, import_react46.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null,
+ (0, import_react46.createElement)(FlyBotDetails, { bot, roster, actions })
+ )
+ )
+ );
+}
+
+// src/client/overlays.js
+var import_react47 = require("react");
+var import_react_dom11 = require("react-dom");
+var DIALOG_EXIT_MS = 160;
+var MENU_EXIT_MS3 = 120;
+function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) {
+ const roster = useRoster((value) => value);
+ const ui2 = useUi((value) => value);
+ useRegistry((value) => value);
+ const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null);
+ const previous = (0, import_react47.useRef)(current);
+ (0, import_react47.useEffect)(() => {
+ if (previous.current === current) return;
+ previous.current = current;
+ if (current) actions.followPanel?.(current);
+ }, [current]);
+ const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null;
+ const [kept, setKept] = (0, import_react47.useState)(open);
+ (0, import_react47.useEffect)(() => {
+ if (open) {
+ setKept(open);
+ return void 0;
+ }
+ const timer = setTimeout(() => setKept(null), PANEL_LINGER_MS);
+ return () => clearTimeout(timer);
+ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]);
+ const pane = open ?? kept;
+ const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS);
+ const remoteBotId = ui2.remoteBotSettings && roster.byId[ui2.remoteBotSettings]?.remote ? ui2.remoteBotSettings : null;
+ const [remoteBotSettings, remoteLeaving] = useLinger(remoteBotId, DIALOG_EXIT_MS);
+ const [memory, memoryLeaving] = useLinger(ui2.memory && roster.byId[ui2.memory] ? ui2.memory : null, DIALOG_EXIT_MS);
+ const [menu, menuLeaving] = useLinger(ui2.menu, MENU_EXIT_MS3);
+ const [inbox, inboxLeaving] = useLinger(ui2.inbox ?? null, DIALOG_EXIT_MS);
+ const [board, boardLeaving] = useLinger(ui2.board ?? null, DIALOG_EXIT_MS);
return (0, import_react47.createElement)(
- "details",
- { className: "bt-project-group bt-project-done", "data-group": "done" },
- (0, import_react47.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`),
- view.done.rows.map((row) => projectRowNode(row, onOpen))
+ import_react47.Fragment,
+ null,
+ pane ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null,
+ ui2.newChat ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null,
+ ui2.palette ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(CommandPalette, { key: ui2.palette.token, start: ui2.palette.query, token: ui2.palette.token, roster, actions, useSessions }), document.body) : null,
+ ui2.exchange ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null,
+ ui2.voice ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null,
+ menu ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null,
+ settings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null,
+ remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null,
+ memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null,
+ inbox ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null,
+ board ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null
);
}
-function waitedLabel(ms) {
- if (!Number.isFinite(ms) || ms < 0) return null;
- const minutes = Math.floor(ms / 6e4);
- if (minutes < 60) return `${minutes}m`;
- const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h`;
- return `${Math.floor(hours / 24)}d`;
-}
-var METRIC_CARDS = [
- { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." },
- { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." },
- { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." },
- { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." },
- { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." },
- { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." },
- { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." }
-];
-var METRIC_DASH = "—";
-var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]);
-var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3;
-var metricText = (key, value) => {
- if (!Number.isFinite(value)) return METRIC_DASH;
- if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH;
- return String(value);
+
+// src/client/read-page.js
+var READ_LIMITS = {
+ chars: 4e5,
+ nodes: 15e4,
+ depth: 400,
+ ms: 1500,
+ items: 3e3,
+ tableRows: 5e3,
+ tableCols: 30,
+ frameDepth: 3
};
-function metricsView(metrics, { tasks } = {}) {
- const source = metrics !== null && typeof metrics === "object" ? metrics : null;
- const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {};
- const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
- const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => {
- const id = text(row.id);
- const task = byId.get(id);
- return {
- id,
- key: "task:" + id,
- title: text(row.title) || text(task?.title) || text(task?.note) || id,
- status: text(row.status),
- who: text(row.who) || null,
- sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null,
- source: text(row.source) || null,
- lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null,
- overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS,
- board: boardOfRow({ source: text(row.source) }, task)
- };
- });
- return {
- available: source?.ok === true,
- reason: text(source?.reason) || null,
- cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })),
- running,
- empty: source === null,
- emptyText: "No metrics read yet."
+function readPage(limits) {
+ const started = performance.now();
+ const SKIP = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "head", "title", "meta", "link", "svg", "object", "embed", "audio", "video", "source", "track", "map", "datalist", "option", "optgroup"]);
+ const ITEM = 'a[href], button, input:not([type=hidden]), textarea, select, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=switch], [role=radio], [contenteditable=""], [contenteditable=true]';
+ const ROLES = { a: "link", button: "button", select: "select", textarea: "textbox", summary: "button" };
+ const INPUT_ROLES = { checkbox: "checkbox", radio: "radio", submit: "button", button: "button", reset: "button", image: "button", search: "searchbox", range: "slider", file: "button", color: "button" };
+ const NO_VALUE_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio", "submit", "button", "reset", "image", "file", "color"]);
+ const SECRET_HINT = /(^|[-_\s])(cvv|cvc|csc|cc-?(num|number|csc|exp)|card-?(num|number)|otp|one-?time-?code|pin)([-_\s]|$)/i;
+ const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|flow-root|table-caption|table-row-group|table-header-group|table-footer-group|table-row|-webkit-box)$/;
+ const INLINE_BLOCK = /^(inline-block|inline-flex|inline-grid|inline-table|table-cell|-webkit-inline-box)$/;
+ const SEMANTIC = /^(ul|ol|table|pre|blockquote|h[1-6]|code|details)$/;
+ const items = [];
+ const stash = [];
+ const stats = { nodes: 0, frames: 0 };
+ let chars = 0;
+ let stop = "";
+ const collapse = (value) => String(value || "").replace(/\s+/g, " ").trim();
+ const isSecret = (el) => {
+ if (el.localName !== "input" && el.localName !== "textarea") return false;
+ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase();
+ return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`);
+ };
+ const keep = (text3, end = "") => {
+ if (text3 === "") return "";
+ stash.push(text3);
+ return `
+
+${stash.length - 1}${end}
+
+`;
+ };
+ const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)]));
+ const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim();
+ const block = (text3) => expand(flow(text3));
+ const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim();
+ const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el);
+ const childrenOf = (el) => {
+ if (el.shadowRoot) return el.shadowRoot.childNodes;
+ if (el.localName === "slot" && typeof el.assignedNodes === "function") {
+ const assigned = el.assignedNodes();
+ if (assigned.length > 0) return assigned;
+ }
+ return el.childNodes;
+ };
+ const over = () => {
+ if (stop) return true;
+ if (chars > limits.chars) stop = `the text passed ${limits.chars} characters`;
+ else if (stats.nodes > limits.nodes) stop = `the page has more than ${limits.nodes} nodes`;
+ else if (stats.nodes % 512 === 0 && performance.now() - started > limits.ms) stop = `reading took longer than ${limits.ms} ms`;
+ return stop !== "";
+ };
+ const roleOf = (el) => el.getAttribute("role") || ROLES[el.localName] || (el.localName === "input" ? INPUT_ROLES[el.type] || "textbox" : el.isContentEditable ? "textbox" : el.localName);
+ const sized = (el) => {
+ const box = el.getBoundingClientRect();
+ return box.width > 0 && box.height > 0;
+ };
+ const itemFor = (el, style) => {
+ if (items.length >= limits.items || style.visibility !== "visible" || !el.matches(ITEM) || !sized(el)) return null;
+ const item = { role: roleOf(el) };
+ const secret = isSecret(el);
+ if (el.href) item.href = String(el.href);
+ if ((el.localName === "input" || el.localName === "textarea") && !secret && !NO_VALUE_TYPES.has(el.type) && el.value) item.value = String(el.value).slice(0, 120);
+ if (el.localName === "select" && el.selectedOptions?.length) item.value = collapse([...el.selectedOptions].map((option) => option.label || option.text).join(", ")).slice(0, 120);
+ if (el.checked) item.checked = true;
+ if (el.disabled) item.disabled = true;
+ items.push(item);
+ return { item, number: items.length, secret };
+ };
+ const nameOf = (el, text3, secret) => collapse(
+ el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || ""
+ ).slice(0, 80);
+ const fenced = (code, lang) => {
+ const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length));
+ const fence = "`".repeat(longest + 1);
+ return `${fence}${lang}
+${code.replace(/\n+$/, "")}
+${fence}`;
+ };
+ const langOf = (el) => {
+ const names = `${el.className || ""} ${el.querySelector?.("code")?.className || ""}`;
+ return /(?:^|\s)(?:lang|language)-([\w+#.-]+)/.exec(names)?.[1] || "";
+ };
+ const list = (el, ctx) => {
+ const ordered = el.localName === "ol";
+ let number = ordered ? Number(el.getAttribute("start") || 1) || 1 : 1;
+ const lines = [];
+ for (const child of childrenOf(el)) {
+ if (over()) break;
+ if (child.nodeType !== 1 || child.localName !== "li") {
+ const loose = block(render(child, ctx));
+ if (loose) lines.push(loose);
+ continue;
+ }
+ const style = styleOf(child);
+ if (style.display === "none") continue;
+ let body2 = block(children(child, { ...ctx, depth: ctx.depth + 1, shown: style.visibility === "visible" }));
+ if (!body2.includes("```")) body2 = body2.replace(/\n{2,}/g, "\n");
+ const marker = ordered ? `${number++}. ` : "- ";
+ if (body2 === "") continue;
+ const pad2 = " ".repeat(marker.length);
+ lines.push(body2.split("\n").map((line2, index) => (index === 0 ? marker : line2 ? pad2 : "") + line2).join("\n"));
+ }
+ return keep(lines.join("\n"));
+ };
+ const cellText = (cell, ctx) => block(children(cell, ctx)).replace(/\n+/g, "
").replace(/\|/g, "\\|");
+ const layoutTable = (table, rows) => {
+ const role = table.getAttribute("role");
+ if (role === "presentation" || role === "none") return true;
+ if (table.querySelector("table")) return true;
+ return !rows.some((row) => row.cells.length > 1);
+ };
+ const tableMarkdown = (table, ctx) => {
+ const rows = [...table.rows].filter((row) => styleOf(row).display !== "none");
+ const caption = table.caption ? oneLine(children(table.caption, ctx)) : "";
+ if (layoutTable(table, rows)) {
+ return rows.map((row) => [...row.cells].map((cell) => `
+
+${children(cell, ctx)}
+
+`).join("")).join("");
+ }
+ const grid = [];
+ const carry = [];
+ let width = 0;
+ for (const row of rows.slice(0, limits.tableRows + 1)) {
+ if (over()) break;
+ const line2 = [];
+ let column = 0;
+ const place = () => {
+ while (carry[column]?.left > 0) {
+ line2[column] = carry[column].text;
+ carry[column].left -= 1;
+ column += 1;
+ }
+ };
+ for (const cell of row.cells) {
+ place();
+ if (styleOf(cell).display === "none") continue;
+ const text3 = cellText(cell, ctx);
+ const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1));
+ const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1));
+ for (let offset = 0; offset < span; offset += 1) {
+ line2[column] = offset === 0 ? text3 : "";
+ if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 };
+ column += 1;
+ }
+ }
+ place();
+ width = Math.max(width, line2.length);
+ grid.push(line2);
+ }
+ width = Math.min(width, limits.tableCols);
+ if (grid.length === 0 || width === 0) return caption ? `
+
+${caption}
+
+` : "";
+ const format = (cells) => `| ${Array.from({ length: width }, (_, index) => cells[index] ?? "").join(" | ")} |`;
+ const out = [format(grid[0]), `|${" --- |".repeat(width)}`, ...grid.slice(1).map(format)];
+ if (rows.length > limits.tableRows + 1) out.push(`… (table cut: ${rows.length - limits.tableRows - 1} more rows)`);
+ return `${caption ? `
+
+Table: ${caption}` : ""}${keep(out.join("\n"))}`;
+ };
+ const frame = (el, ctx) => {
+ let doc = null;
+ try {
+ doc = el.contentDocument;
+ } catch {
+ doc = null;
+ }
+ const address = (() => {
+ try {
+ return doc?.location?.href || el.src || el.getAttribute("src") || "about:blank";
+ } catch {
+ return el.src || "about:blank";
+ }
+ })();
+ if (!doc) return `
+
+[iframe (cross-origin, not readable): ${address}]
+
+`;
+ if (ctx.frames >= limits.frameDepth) return `
+
+[iframe (nested too deep, not read): ${address}]
+
+`;
+ stats.frames += 1;
+ const title = collapse(el.title || doc.title);
+ const body2 = doc.body ? block(children(doc.body, { ...ctx, frames: ctx.frames + 1, depth: ctx.depth + 1, shown: true, pre: false })) : "";
+ return keep(`[iframe: ${title ? `${title} (${address})` : address}]
+${body2 || "(no text)"}
+[iframe end]`);
};
+ function children(el, ctx) {
+ let out = "";
+ for (const child of childrenOf(el)) {
+ if (over()) break;
+ out += render(child, ctx);
+ }
+ return out;
+ }
+ function render(node, ctx) {
+ stats.nodes += 1;
+ if (node.nodeType === 3) {
+ if (!ctx.shown) return "";
+ const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
+ const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " ");
+ chars += text3.length;
+ return text3;
+ }
+ if (node.nodeType !== 1) return "";
+ const el = node;
+ const tag = el.localName;
+ if (SKIP.has(tag) || ctx.depth > limits.depth || over()) return "";
+ const style = styleOf(el);
+ const display = style.display;
+ if (display === "none" || style.contentVisibility === "hidden") return "";
+ const shown = style.visibility === "visible";
+ const keepsSpace = String(style.whiteSpace || "").startsWith("pre") || style.whiteSpace === "break-spaces";
+ const next = { ...ctx, depth: ctx.depth + 1, shown };
+ if (display === "contents" && !SEMANTIC.test(tag) && !el.matches(ITEM)) return children(el, next);
+ if (style.overflowX !== "visible" || style.overflowY !== "visible") {
+ const box = el.getBoundingClientRect();
+ if (box.width === 0 || box.height === 0) return "";
+ }
+ if (tag === "br") return "\n";
+ if (tag === "hr") return "\n\n---\n\n";
+ if (tag === "img") {
+ const alt = collapse(el.getAttribute("alt"));
+ return shown && alt ? `[image: ${alt}]` : "";
+ }
+ if (tag === "canvas") {
+ const box = el.getBoundingClientRect();
+ return box.width >= 200 && box.height >= 100 ? "\n\n[canvas: drawn content, not readable]\n\n" : "";
+ }
+ if (tag === "iframe" || tag === "frame") {
+ const box = el.getBoundingClientRect();
+ return tag === "iframe" && (box.width < 2 || box.height < 2) ? "" : frame(el, next);
+ }
+ const found = itemFor(el, style);
+ const mark = found ? `[#${found.number}]` : "";
+ if (tag === "input" || tag === "textarea" || tag === "select") {
+ if (found) found.item.name = nameOf(el, tag === "select" ? "" : el.labels?.[0] ? collapse(el.labels[0].textContent) : "", found.secret);
+ return mark ? ` ${mark} ` : "";
+ }
+ let body2;
+ if (tag === "pre") {
+ const code = shown ? String(el.innerText || el.textContent || "") : "";
+ chars += code.length;
+ body2 = code.trim() ? keep(fenced(code, langOf(el))) : "";
+ } else if (tag === "table") {
+ body2 = tableMarkdown(el, next);
+ } else if (tag === "ul" || tag === "ol") {
+ body2 = list(el, next);
+ } else if (tag === "details" && !el.open) {
+ const summary = [...el.children].find((child) => child.localName === "summary");
+ body2 = summary ? render(summary, next) : "";
+ } else {
+ body2 = children(el, { ...next, pre: keepsSpace });
+ }
+ if (found) found.item.name = nameOf(el, oneLine(body2), found.secret);
+ const level = /^h([1-6])$/.exec(tag)?.[1] || (el.getAttribute("role") === "heading" ? el.getAttribute("aria-level") || "2" : "");
+ if (level) {
+ const title = oneLine(body2);
+ return title ? `
+
+${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark}
+
+` : mark;
+ }
+ if (tag === "code" && !ctx.pre) {
+ const code = oneLine(body2);
+ if (!code) return mark;
+ const ticks = code.includes("`") ? "``" : "`";
+ return `${ticks}${code}${ticks}${mark}`;
+ }
+ if (tag === "blockquote") {
+ const quoted = block(body2);
+ return quoted ? keep(quoted.split("\n").map((line2) => line2 ? `> ${line2}` : ">").join("\n")) : "";
+ }
+ const end = body2.trimEnd().length;
+ const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2;
+ if (ctx.pre && !BLOCK_DISPLAY.test(display)) return marked;
+ if (keepsSpace && BLOCK_DISPLAY.test(display) && tag !== "pre") {
+ return keep(expand(marked).replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").replace(/^\n+|\s+$/g, ""), "");
+ }
+ if (BLOCK_DISPLAY.test(display)) return `
+
+${marked}
+
+`;
+ if (INLINE_BLOCK.test(display)) return ` ${marked} `;
+ if (tag === "li") return ` · ${marked}`;
+ return marked;
+ }
+ const body = document.body;
+ let text2 = "";
+ if (body) {
+ text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " ");
+ }
+ if (text2.length > limits.chars) {
+ text2 = text2.slice(0, limits.chars);
+ stop ||= `the text passed ${limits.chars} characters`;
+ }
+ const page = {
+ url: location.href,
+ title: document.title,
+ lang: document.documentElement.lang || "",
+ description: document.querySelector('meta[name="description"]')?.content || "",
+ selection: String(getSelection() || "").slice(0, 4e3),
+ format: "markdown",
+ text: text2,
+ items,
+ stats: { ...stats, ms: Math.round(performance.now() - started) }
+ };
+ if (stop) page.truncated = stop;
+ return page;
}
-function ProjectMetrics({ view }) {
- return (0, import_react47.createElement)(
- "section",
- { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" },
- view.available || view.reason === null ? null : (0, import_react47.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-metric-cards" },
- view.cards.map((card) => (0, import_react47.createElement)(
- "div",
- { key: card.key, className: "bt-project-metric", "data-metric": card.key },
- (0, import_react47.createElement)("span", { className: "bt-project-metric-value" }, card.value),
- (0, import_react47.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)),
- (0, import_react47.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption))
- ))
- )
- );
-}
-function ProjectRunningTable({ view, onOpen }) {
- if (view.running.length === 0) return (0, import_react47.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react47.createElement)("h4", null, t("Running tasks")), (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running.")));
- return (0, import_react47.createElement)(
- "section",
- { className: "bt-project-running", "data-running": String(view.running.length) },
- (0, import_react47.createElement)("h4", null, t("Running tasks")),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-running-head" },
- (0, import_react47.createElement)("span", null, t("Task")),
- (0, import_react47.createElement)("span", null, t("Status")),
- (0, import_react47.createElement)("span", null, t("Who")),
- (0, import_react47.createElement)("span", null, t("Claimed for")),
- (0, import_react47.createElement)("span", null, t("Source")),
- (0, import_react47.createElement)("span", null, t("Last activity"))
- ),
- view.running.map((row) => {
- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
- return (0, import_react47.createElement)(
- "div",
- {
- key: row.key,
- className: "bt-project-running-row",
- "data-task-id": row.id,
- "data-overdue": row.overdue ? "true" : "false",
- role: open === void 0 ? void 0 : "button",
- tabIndex: open === void 0 ? void 0 : 0,
- onClick: open
- },
- (0, import_react47.createElement)("span", { className: "bt-project-running-title" }, row.title),
- (0, import_react47.createElement)("span", { className: "bt-project-running-status" }, t(row.status)),
- (0, import_react47.createElement)("span", null, row.who ?? METRIC_DASH),
- (0, import_react47.createElement)("span", null, metricText("since", row.sinceMs)),
- (0, import_react47.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)),
- (0, import_react47.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale()))
- );
- })
- );
-}
-function ProjectQueueGroups({ view, onOpen }) {
- return view.queues.map((entry) => (0, import_react47.createElement)(
- "section",
- { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group },
- (0, import_react47.createElement)("h4", null, t(entry.label)),
- entry.items.map((row) => {
- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
- const note = entry.group === "waiting" ? (0, import_react47.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react47.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react47.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react47.createElement)("span", { className: "bt-project-reason" }, row.reason);
- return (0, import_react47.createElement)(
- "div",
- {
- key: row.key,
- className: "bt-project-row",
- "data-task-id": row.id,
- "data-state": row.state ?? void 0,
- role: open === void 0 ? void 0 : "button",
- tabIndex: open === void 0 ? void 0 : 0,
- onClick: open,
- onKeyDown: open === void 0 ? void 0 : (event) => {
- if (event.key === "Enter" || event.key === " ") open();
- }
- },
- (0, import_react47.createElement)(
- "span",
- { className: "bt-project-title-wrap" },
- (0, import_react47.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`)
- ),
- note
- );
- })
- ));
-}
-var countText = (value) => value === null ? t("unknown") : String(value);
-function ProjectBoardRecord({ record }) {
- const { decisions, meetings } = record;
- const decisionRows = decisions.files.map((file) => (0, import_react47.createElement)(
- "div",
- { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 },
- (0, import_react47.createElement)("span", { className: "bt-project-date" }, file.date),
- (0, import_react47.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
- (0, import_react47.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
- ));
- const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", ");
- const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", ");
- const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting));
- const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level));
- const decidedRows = meetings.decided.map((meeting) => (0, import_react47.createElement)(
- "div",
- { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" },
- (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date),
- (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.question),
- meeting.owner === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`),
- proxyRow(meeting),
- // A decided meeting whose file is gone still reads as decided; the missing path says so
- // instead of the row disappearing with the file.
- meeting.path === "" ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react47.createElement)("span", { className: "bt-project-path" }, meeting.path)
- ));
- const pendingRows = meetings.pending.map((meeting) => (0, import_react47.createElement)(
- "div",
- { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level },
- (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date),
- (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion),
- levelOf(meeting),
- recipientText(meeting.recipients) === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)),
- meeting.advance.allowed ? null : (0, import_react47.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason),
- proxyRow(meeting),
- (0, import_react47.createElement)("span", { className: "bt-project-status" }, meeting.status)
- ));
- return (0, import_react47.createElement)(
- "section",
- { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) },
- (0, import_react47.createElement)("h4", null, t("Decisions")),
- decisions.empty ? (0, import_react47.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows,
- (0, import_react47.createElement)("h4", null, t("Meetings")),
- meetings.empty ? (0, import_react47.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows,
- meetings.pending.length === 0 ? null : (0, import_react47.createElement)("h5", null, t("Not decided")),
- meetings.pending.length === 0 ? null : pendingRows
- );
+var readPageScript = (limits = READ_LIMITS) => `(${readPage})(${JSON.stringify(limits)})`;
+var READ_PAGE_SCRIPT = readPageScript();
+
+// src/client/bot-browsers.js
+var STORE_KEY = STORAGE.browsers;
+var MAX_LIVE = 6;
+var READ_TIMEOUT_MS = 1e4;
+var PREVIEW_TIMEOUT_MS = 3e3;
+var RESIZE_LATER_MS = 200;
+var SEARCH_URL = "https://www.bing.com/search?q=";
+var PREVIEW_SCRIPT = `(() => {
+ const meta = key => document.querySelector('meta[name="' + key + '"],meta[property="' + key + '"]')?.content ?? ''
+ const paragraph = [...document.querySelectorAll('main p, article p, p')].map(node => node.innerText ?? '').find(text => text.trim().length > 40) ?? ''
+ const text = meta('description') || meta('og:description') || paragraph || document.body?.innerText || ''
+ return text.replace(/\\s+/g, ' ').trim().slice(0, 200)
+})()`;
+var EMPTY = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, live: false, excerpt: "" };
+var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.internal)$/i;
+var BLOCKED = "Cloud metadata addresses do not open here.";
+var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname);
+function browserAddress(input) {
+ const text2 = String(input ?? "").trim();
+ if (text2 === "") return null;
+ if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null;
+ if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null;
+ const host = text2.split(/[/?#]/)[0];
+ const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host);
+ const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host));
+ if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href;
+ return `${SEARCH_URL}${encodeURIComponent(text2)}`;
}
-var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : "";
-var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : [];
-function projectTeamView(project) {
- const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {};
- const owner = [];
- const addOwner = (id) => {
- const clean = text(id);
- if (clean !== "" && !owner.includes(clean)) owner.push(clean);
+function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) {
+ const views = /* @__PURE__ */ new Map();
+ const owner = /* @__PURE__ */ new Map();
+ const tabs = createSource2({});
+ const pages = createSource2({});
+ let saved = {};
+ try {
+ saved = JSON.parse(storage?.getItem(STORE_KEY) ?? "{}") ?? {};
+ } catch {
+ saved = {};
+ }
+ let layer;
+ let shown = null;
+ let rect = null;
+ let visible = false;
+ let resizeLater = 0;
+ let disposed = false;
+ let serial = 0;
+ const savedTab = (botId, tabId) => saved[botId]?.tabs?.find((entry) => entry.id === tabId);
+ const publish2 = (tabId, patch) => pages.set((state) => {
+ const botId = owner.get(tabId);
+ return { ...state, [tabId]: { ...EMPTY, ...savedTab(botId, tabId), ...state[tabId], ...patch } };
+ });
+ const writeSaved = () => {
+ try {
+ storage?.setItem(STORE_KEY, JSON.stringify(saved));
+ } catch {
+ }
};
- addOwner(project?.ownerUserId);
- for (const id of idList(project?.ownerUserIds)) addOwner(id);
- const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id));
- const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id));
+ const persist = (botId) => {
+ const state = tabs.getSnapshot()[botId];
+ if (state === void 0) {
+ if (saved[botId] === void 0) return;
+ const { [botId]: _, ...rest } = saved;
+ saved = rest;
+ } else {
+ const snapshot = pages.getSnapshot();
+ saved = {
+ ...saved,
+ [botId]: {
+ tabs: state.ids.map((id) => ({ id, url: snapshot[id]?.url ?? "", title: snapshot[id]?.title ?? "" })),
+ active: state.active
+ }
+ };
+ }
+ writeSaved();
+ };
+ const ensureTabs = (botId) => {
+ if (tabs.getSnapshot()[botId] !== void 0) return;
+ const entries2 = saved[botId]?.tabs ?? [];
+ const ids = entries2.map((entry) => entry.id);
+ const active = ids.includes(saved[botId]?.active) ? saved[botId].active : ids.at(-1) ?? null;
+ for (const entry of entries2) owner.set(entry.id, botId);
+ tabs.set((state) => ({ ...state, [botId]: { ids, active } }));
+ for (const entry of entries2) publish2(entry.id, { url: entry.url ?? "", title: entry.title ?? "", live: false });
+ };
+ const ensureLayer = () => {
+ if (layer?.isConnected) return layer;
+ layer = doc.createElement("div");
+ layer.className = "bt-browser-layer";
+ Object.assign(layer.style, { position: "fixed", inset: "0", zIndex: "41", pointerEvents: "none" });
+ doc.body.append(layer);
+ return layer;
+ };
+ const place = (entry) => {
+ const box = rect ?? { left: 0, top: 0, width: 800, height: 600 };
+ Object.assign(entry.element.style, {
+ left: `${box.left}px`,
+ top: `${box.top}px`,
+ width: `${box.width}px`,
+ height: `${box.height}px`,
+ visibility: shown === entry.tabId && visible && rect ? "visible" : "hidden"
+ });
+ };
+ async function preview(tabId) {
+ const entry = views.get(tabId);
+ if (entry === void 0 || !entry.ready || !/^https?:/i.test(entry.element.getURL())) return false;
+ let timer;
+ const timeout = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(""), PREVIEW_TIMEOUT_MS);
+ });
+ try {
+ const excerpt = await Promise.race([entry.element.executeJavaScript(PREVIEW_SCRIPT), timeout]);
+ if (typeof excerpt !== "string" || excerpt === "" || views.get(tabId) !== entry) return false;
+ publish2(tabId, { excerpt });
+ return true;
+ } catch {
+ return false;
+ } finally {
+ clearTimeout(timer);
+ }
+ }
+ const observe = (entry) => {
+ const { element } = entry;
+ if (!entry.ready) return;
+ const url = element.getURL();
+ if (url.startsWith("about:blank#")) return;
+ if (entry.fresh) {
+ element.clearHistory();
+ entry.fresh = false;
+ }
+ const title = element.getTitle();
+ publish2(entry.tabId, { url, title, loading: element.isLoading(), canGoBack: element.canGoBack(), canGoForward: element.canGoForward(), live: true });
+ persist(entry.botId);
+ };
+ async function create(tabId) {
+ const botId = owner.get(tabId);
+ const reservation = await bridge.acquire(`bot:${botId}`);
+ if (disposed) {
+ await bridge.release(reservation.lease).catch(() => {
+ });
+ return void 0;
+ }
+ const element = doc.createElement("webview");
+ element.dataset.sidebarBrowserFrame = "webview";
+ Object.assign(element.style, { position: "fixed", display: "flex", border: "0", pointerEvents: "auto", background: "#fff" });
+ element.dataset.btBrowser = tabId;
+ element.setAttribute("name", reservation.lease);
+ element.setAttribute("partition", reservation.partition);
+ element.setAttribute("allowpopups", "");
+ element.setAttribute("src", `about:blank#${reservation.lease}`);
+ const entry = { botId, tabId, element, lease: reservation.lease, ready: false, fresh: true, pending: pages.getSnapshot()[tabId]?.url || savedTab(botId, tabId)?.url, usedAt: Date.now() };
+ const lifetime = new AbortController();
+ entry.stop = () => lifetime.abort();
+ const { signal } = lifetime;
+ const unsubscribe = bridge.onOpenRequested(reservation.lease, (url) => void openTab(botId, url));
+ signal.addEventListener("abort", unsubscribe, { once: true });
+ element.addEventListener("dom-ready", () => {
+ entry.ready = true;
+ observe(entry);
+ if (entry.pending) {
+ const url = entry.pending;
+ entry.pending = void 0;
+ void element.loadURL(url).catch(() => {
+ });
+ }
+ }, { signal });
+ for (const name of ["did-navigate", "did-navigate-in-page", "did-start-loading", "did-stop-loading", "page-title-updated"]) {
+ element.addEventListener(name, () => observe(entry), { signal });
+ }
+ element.addEventListener("did-stop-loading", () => {
+ void preview(tabId);
+ }, { signal });
+ element.addEventListener("page-favicon-updated", (event) => {
+ const favicon = event.favicons?.[0];
+ if (typeof favicon === "string" && favicon.startsWith("data:")) publish2(tabId, { favicon });
+ }, { signal });
+ element.addEventListener("did-start-navigation", (event) => {
+ if (!event.isMainFrame) return;
+ if (blockedAddress(event.url)) {
+ element.stop();
+ publish2(tabId, { loading: false, error: BLOCKED });
+ return;
+ }
+ publish2(tabId, { loading: true, error: null });
+ }, { signal });
+ element.addEventListener("did-fail-load", (event) => {
+ if (event.isMainFrame && event.errorCode !== -3) publish2(tabId, { loading: false, error: event.errorDescription || `Error ${event.errorCode}` });
+ }, { signal });
+ element.addEventListener("render-process-gone", () => {
+ if (views.get(tabId) === void 0) return;
+ void release(tabId);
+ publish2(tabId, { loading: false, error: "The page crashed." });
+ }, { signal });
+ views.set(tabId, entry);
+ ensureLayer().append(element);
+ place(entry);
+ publish2(tabId, { live: true, loading: Boolean(entry.pending) });
+ return entry;
+ }
+ const opening = /* @__PURE__ */ new Map();
+ function open(tabId) {
+ const entry = views.get(tabId);
+ if (entry) return Promise.resolve(entry);
+ let job = opening.get(tabId);
+ if (job === void 0) {
+ job = create(tabId).catch((error) => {
+ console.warn("[ds-bot] browser acquire", error);
+ publish2(tabId, { loading: false, error: String(error?.message ?? error) });
+ return void 0;
+ }).finally(() => opening.delete(tabId));
+ opening.set(tabId, job);
+ void job.then(() => trim());
+ }
+ return job;
+ }
+ function trim() {
+ const idle = [...views.values()].filter((entry) => entry.tabId !== shown).sort((a, b) => a.usedAt - b.usedAt);
+ while (views.size > MAX_LIVE && idle.length > 0) void release(idle.shift().tabId);
+ }
+ async function release(tabId) {
+ const entry = views.get(tabId);
+ if (entry === void 0) return;
+ views.delete(tabId);
+ entry.stop();
+ entry.element.remove();
+ publish2(tabId, { live: false, loading: false });
+ await bridge.release(entry.lease).catch((error) => console.warn("[ds-bot] browser release", error));
+ }
+ function openTab(botId, url) {
+ ensureTabs(botId);
+ const tabId = `t${Date.now().toString(36)}-${++serial}`;
+ owner.set(tabId, botId);
+ tabs.set((state) => {
+ const own = state[botId] ?? { ids: [], active: null };
+ return { ...state, [botId]: { ids: [...own.ids, tabId], active: tabId } };
+ });
+ publish2(tabId, { url: "", title: "", live: false });
+ if (typeof url === "string" && url !== "") void navigate(tabId, url);
+ persist(botId);
+ return tabId;
+ }
+ async function closeTab(botId, tabId) {
+ const state = tabs.getSnapshot()[botId];
+ if (state === void 0 || !state.ids.includes(tabId)) return;
+ await opening.get(tabId)?.catch(() => {
+ });
+ if (shown === tabId) await show(null);
+ await release(tabId);
+ tabs.set((snapshot) => {
+ const current = snapshot[botId];
+ if (current === void 0 || !current.ids.includes(tabId)) return snapshot;
+ const at = current.ids.indexOf(tabId);
+ const ids = current.ids.filter((id) => id !== tabId);
+ const active = current.active === tabId ? ids[Math.min(at, ids.length - 1)] ?? null : current.active;
+ const next = { ...snapshot };
+ if (ids.length === 0) delete next[botId];
+ else next[botId] = { ids, active };
+ return next;
+ });
+ owner.delete(tabId);
+ pages.set((snapshot) => {
+ if (snapshot[tabId] === void 0) return snapshot;
+ const { [tabId]: _, ...rest } = snapshot;
+ return rest;
+ });
+ persist(botId);
+ }
+ function activate(botId, tabId) {
+ ensureTabs(botId);
+ const state = tabs.getSnapshot()[botId];
+ if (state === void 0 || !state.ids.includes(tabId) || state.active === tabId) return;
+ tabs.set((snapshot) => ({ ...snapshot, [botId]: { ...state, active: tabId } }));
+ persist(botId);
+ }
+ const activeTab = (botId) => tabs.getSnapshot()[botId]?.active ?? null;
+ async function show(tabId, box = null) {
+ if (tabId !== null && tabId === shown && visible && box !== null) {
+ rect = box;
+ const entry2 = views.get(tabId);
+ if (entry2) place(entry2);
+ clearTimeout(resizeLater);
+ resizeLater = setTimeout(() => {
+ for (const other of views.values()) place(other);
+ }, RESIZE_LATER_MS);
+ return;
+ }
+ shown = tabId;
+ if (box) rect = box;
+ visible = tabId !== null && box !== null;
+ for (const entry2 of views.values()) place(entry2);
+ if (tabId === null) return;
+ const entry = await open(tabId);
+ if (entry === void 0 || shown !== tabId) return;
+ entry.usedAt = Date.now();
+ place(entry);
+ }
+ async function navigate(tabId, input) {
+ const url = browserAddress(input);
+ if (url === null || blockedAddress(url)) {
+ publish2(tabId, { error: url === null ? "Only http and https addresses open here." : BLOCKED });
+ return false;
+ }
+ const entry = await open(tabId);
+ if (entry === void 0) return false;
+ publish2(tabId, { url, loading: true, error: null });
+ persist(owner.get(tabId));
+ if (!entry.ready) entry.pending = url;
+ else void entry.element.loadURL(url).catch(() => {
+ });
+ return true;
+ }
+ async function forget(botId) {
+ ensureTabs(botId);
+ for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) {
+ await opening.get(tabId)?.catch(() => {
+ });
+ if (shown === tabId) await show(null);
+ await release(tabId);
+ owner.delete(tabId);
+ pages.set((snapshot) => {
+ if (snapshot[tabId] === void 0) return snapshot;
+ const { [tabId]: _, ...rest } = snapshot;
+ return rest;
+ });
+ }
+ tabs.set((snapshot) => {
+ if (snapshot[botId] === void 0) return snapshot;
+ const { [botId]: _, ...rest } = snapshot;
+ return rest;
+ });
+ if (saved[botId] !== void 0) {
+ const { [botId]: _, ...rest } = saved;
+ saved = rest;
+ writeSaved();
+ }
+ }
return {
- chief: botIdOf(project?.chief),
- agent: botIdOf(project?.assistant),
- roles: { owner, maintainers, members },
- empty: owner.length === 0 && maintainers.length === 0 && members.length === 0,
- // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the
- // `project-read` record answers no rank, and the panel never re-derives the host's policy.
- role: text(project?.role),
- choices: idList(project?.choices)
- };
-}
-var teamRow = (role, label, botId, onOpenBot) => (0, import_react47.createElement)(
- "div",
- {
- key: role,
- className: "bt-project-team-row",
- "data-team-role": role,
- // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
- ...botId === "" ? { "data-team-state": "not-set" } : {
- "data-open-bot": botId,
- role: "button",
- tabIndex: 0,
- "aria-label": t("Open {name}'s chat", { name: botId }),
- onClick: () => onOpenBot?.(botId),
- onKeyDown: (event) => {
- if (event.key === "Enter" || event.key === " ") {
- event.preventDefault();
- onOpenBot?.(botId);
- }
+ tabs,
+ pages,
+ /** Restore a Bot's saved strip (or an empty one) so the panel can render it. */
+ ensureTabs,
+ openTab,
+ closeTab,
+ activate,
+ preview,
+ show,
+ navigate,
+ command(tabId, name) {
+ const entry = views.get(tabId);
+ if (!entry?.ready) return;
+ if (name === "back" && entry.element.canGoBack()) entry.element.goBack();
+ else if (name === "forward" && entry.element.canGoForward()) entry.element.goForward();
+ else if (name === "reload") entry.element.reload();
+ else if (name === "stop") entry.element.stop();
+ },
+ focus(tabId) {
+ views.get(tabId)?.element.focus();
+ },
+ /** Pages a read can reach: each Bot's active tab, live and showing a document. */
+ openPages() {
+ const open2 = {};
+ const snapshot = pages.getSnapshot();
+ for (const [botId, state] of Object.entries(tabs.getSnapshot())) {
+ const tabId = state.active;
+ const entry = tabId ? views.get(tabId) : void 0;
+ const page = tabId ? snapshot[tabId] : void 0;
+ if (entry?.ready && page?.url) open2[botId] = { url: page.url, title: page.title };
}
+ return open2;
+ },
+ /** A Bot's read reaches its active tab. */
+ async read(botId) {
+ const tabId = activeTab(botId);
+ const entry = tabId ? views.get(tabId) : void 0;
+ if (!entry?.ready) throw new Error("This Bot has no browser page open.");
+ let timer;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error("The page took too long to read.")), READ_TIMEOUT_MS);
+ });
+ try {
+ return await Promise.race([entry.element.executeJavaScript(READ_PAGE_SCRIPT), timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+ },
+ noteRead(botId) {
+ const tabId = activeTab(botId);
+ if (tabId) publish2(tabId, { readAt: Date.now() });
+ },
+ release,
+ forget,
+ /** Forget every Bot outside `botIds`, as when Bots were deleted from another window. */
+ retain(botIds) {
+ const keep = new Set(botIds);
+ const known = /* @__PURE__ */ new Set([...Object.keys(tabs.getSnapshot()), ...Object.keys(saved), ...owner.values()]);
+ for (const entry of views.values()) known.add(entry.botId);
+ return Promise.all([...known].filter((botId) => !keep.has(botId)).map(forget));
+ },
+ dispose() {
+ disposed = true;
+ clearTimeout(resizeLater);
+ for (const entry of views.values()) void release(entry.tabId);
+ layer?.remove();
}
- },
- (0, import_react47.createElement)("span", { key: "label", className: "bt-project-team-label" }, label),
- botId === "" ? (0, import_react47.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react47.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`)
-);
-var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]];
-function ProjectTeam({ project, onOpenBot }) {
- const view = projectTeamView(project);
- const ranks = view.empty ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react47.createElement)(
- "span",
- { className: "bt-project-team-roles" },
- POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react47.createElement)(
- "span",
- { key: role, className: "bt-project-team-role", "data-pool-role": role },
- `${roleText(role)}: ${view.roles[key].map((id) => `@${id}`).join(", ")}`
- ))
- );
- return (0, import_react47.createElement)(
- "section",
- { className: "bt-project-team", "data-team": "chief,agent,pool" },
- (0, import_react47.createElement)("h4", null, t("Project team")),
- teamRow("chief", t("Project Chief"), view.chief, onOpenBot),
- teamRow("agent", t("My agent"), view.agent, onOpenBot),
- (0, import_react47.createElement)(
- "div",
- { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" },
- (0, import_react47.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")),
- ranks,
- // The caller's own rank and what the host said they may switch to: the same answer the
- // sidebar menu reads, printed where the pool is.
- view.role === "" ? null : (0, import_react47.createElement)(
- "span",
- { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role },
- [
- t("My role"),
- ": ",
- roleText(view.role),
- view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}`
- ]
- )
- )
- );
-}
-function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot }) {
- const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics });
- return (0, import_react47.createElement)(
- "div",
- { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" },
- (0, import_react47.createElement)("h3", null, view.title),
- (0, import_react47.createElement)(ProjectTeam, { project, onOpenBot }),
- // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
- view.counts === null ? null : (0, import_react47.createElement)(
- "div",
- { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" },
- (0, import_react47.createElement)("span", null, [t("Waiting"), ": ", (0, import_react47.createElement)("b", { key: "w" }, countText(view.counts.waiting))]),
- (0, import_react47.createElement)("span", null, [t("In flight"), ": ", (0, import_react47.createElement)("b", { key: "f" }, countText(view.counts.inflight))]),
- (0, import_react47.createElement)("span", null, [t("Blocked"), ": ", (0, import_react47.createElement)("b", { key: "b" }, countText(view.counts.blocked))]),
- (0, import_react47.createElement)("span", null, [t("Done"), ": ", (0, import_react47.createElement)("b", { key: "d" }, countText(view.counts.done))])
- ),
- // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses
- // every write a non-owner attempts, so the refusal is an answer the reader sees, not a
- // control that silently disappeared.
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-actions" },
- (0, import_react47.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")),
- (0, import_react47.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members"))
- ),
- (0, import_react47.createElement)(ProjectMetrics, { view: view.metrics }),
- (0, import_react47.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }),
- (0, import_react47.createElement)(ProjectBoardRows, { view, onOpen }),
- (0, import_react47.createElement)(ProjectQueueGroups, { view, onOpen }),
- (0, import_react47.createElement)(ProjectDoneGroup, { view, onOpen }),
- (0, import_react47.createElement)(ProjectBoardRecord, { record: view.record })
- );
-}
-function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) {
- const save = request ?? call;
- const [kind, setKind] = (0, import_react47.useState)("bot");
- const [memberId, setMemberId] = (0, import_react47.useState)("");
- const [note, setNote] = (0, import_react47.useState)("");
- const [assistantOff, setAssistantOff] = (0, import_react47.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true);
- const [status, setStatus] = (0, import_react47.useState)("");
- const id = text(project?.id);
- const run = async (endpoint, payload, done) => {
- try {
- const result = await save(endpoint, payload);
- setStatus(done);
- onSaved?.(result);
- return result;
- } catch (error) {
- setStatus(hostText(error?.message ?? String(error)));
- return null;
- }
- };
- return (0, import_react47.createElement)(
- "div",
- { className: "bt-project-panel", "data-panel": "project-settings" },
- (0, import_react47.createElement)("h3", null, t("Project settings")),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-field" },
- (0, import_react47.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-row-actions" },
- (0, import_react47.createElement)(
- "select",
- { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) },
- (0, import_react47.createElement)("option", { value: "bot" }, t("Bot")),
- (0, import_react47.createElement)("option", { value: "group" }, t("Group"))
- ),
- (0, import_react47.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }),
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")),
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind"))
- )
- ),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-field" },
- (0, import_react47.createElement)("label", null, t("Project Assistant")),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-row-actions" },
- (0, import_react47.createElement)("label", null, (0, import_react47.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")),
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save"))
- )
- ),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-field" },
- // The host knows two memory kinds; the free text the panel takes is recorded as a decision,
- // never as a third kind the host would refuse (#1820).
- (0, import_react47.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")),
- (0, import_react47.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-row-actions" },
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")) }, t("Save memory"))
- )
- ),
- (0, import_react47.createElement)("div", { className: "bt-project-status-line", role: "status" }, status),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-row-actions" },
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
- )
- );
-}
-function ProjectMembersPanel({ project, rows, onClose } = {}) {
- const owners = [];
- const add = (id) => {
- const clean = text(id);
- if (clean !== "" && !owners.includes(clean)) owners.push(clean);
};
- add(project?.ownerUserId);
- for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id);
- const boardMembers = [];
- for (const row of Array.isArray(rows) ? rows : []) {
- const name = text(row?.assignee);
- if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name);
- }
- const chief = text(project?.chief?.botId);
- return (0, import_react47.createElement)(
- "div",
- { className: "bt-project-panel", "data-panel": "project-members" },
- (0, import_react47.createElement)("h3", null, t("Members")),
- (0, import_react47.createElement)(
- "ul",
- { className: "bt-project-members" },
- owners.map((owner) => (0, import_react47.createElement)(
- "li",
- { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner },
- (0, import_react47.createElement)("span", null, `@${owner}`),
- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Owner"))
- )),
- chief === "" ? null : (0, import_react47.createElement)(
- "li",
- { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief },
- (0, import_react47.createElement)("span", null, t("Project Chief")),
- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project"))
- ),
- boardMembers.map((name) => (0, import_react47.createElement)(
- "li",
- { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
- (0, import_react47.createElement)("span", null, `@${name}`),
- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
- ))
- ),
- owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
- (0, import_react47.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-row-actions" },
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
- )
- );
}
-var PROJECT_BOARD_POLL_MS = 5e3;
-var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project");
-function ProjectBoardOverlay({ actions, call: request } = {}) {
- const read = request ?? call;
- const [state, setState] = (0, import_react47.useState)(null);
- const [panel, setPanel] = (0, import_react47.useState)(null);
- const [hash, setHash] = (0, import_react47.useState)(() => typeof window === "undefined" ? "" : window.location.hash);
- const close = (0, import_react47.useCallback)(() => {
- if (typeof window !== "undefined") window.location.hash = "";
- setPanel(null);
- setState(null);
- }, []);
- (0, import_react47.useEffect)(() => {
- if (typeof window === "undefined") return void 0;
- const onKey = (event) => {
- if (event.key !== "Escape") return;
- if (panel !== null) setPanel(null);
- else close();
- };
- window.addEventListener("keydown", onKey);
- return () => window.removeEventListener("keydown", onKey);
- }, [panel, close]);
- (0, import_react47.useEffect)(() => {
- if (typeof window === "undefined") return void 0;
- const sync = () => setHash(window.location.hash);
- window.addEventListener("hashchange", sync);
- sync();
- return () => window.removeEventListener("hashchange", sync);
- }, []);
- (0, import_react47.useEffect)(() => {
- if (typeof window === "undefined") return void 0;
- if (!hash.startsWith("#project")) {
- setState(null);
- return void 0;
+var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.randomUUID(), retryMs = 1e3 }) {
+ let stopped = false;
+ let poll;
+ let reported = "";
+ const answered = /* @__PURE__ */ new Map();
+ const answer = async ({ id, botId }) => {
+ let job = answered.get(id);
+ if (job === void 0) {
+ job = (async () => {
+ try {
+ const result = { id, ok: true, page: await browsers.read(botId) };
+ browsers.noteRead(botId);
+ return result;
+ } catch (error) {
+ return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
+ }
+ })();
+ answered.set(id, job);
+ if (answered.size > 200) answered.delete(answered.keys().next().value);
}
- const wanted = hash.slice("#project".length).replace(/^=/, "");
- let gone = false;
- const loadBoard = async () => {
- const listing = await read("projects");
- const projects = listing?.projects ?? [];
- const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted);
- if (project === void 0) return { project: null, rows: [], canManage: false, tasks: [] };
- const [record, board, taskList] = await Promise.all([
- read("project-read", { id: project.id }),
- read("project-board", { id: project.id, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
- read("tasks", {})
- ]);
- const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? [];
- const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null };
- const base = record?.project ?? project;
- const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] };
- return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null };
- };
- const refresh = async (first) => {
+ await post("browser-result", await job).catch((error) => console.warn("[ds-bot] browser result", error));
+ };
+ const loop = async () => {
+ let failures = 0;
+ while (!stopped) {
+ poll = new AbortController();
+ const open = browsers.openPages();
+ reported = JSON.stringify(open);
try {
- const next = await loadBoard();
- if (!gone) setState(next);
+ const requests = await post("browser-wait", { clientId, open }, poll.signal);
+ failures = 0;
+ for (const request of Array.isArray(requests) ? requests : []) void answer(request);
} catch {
- if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] });
+ if (stopped) return;
+ if (poll.signal.aborted) continue;
+ failures += 1;
+ await sleep(Math.min(3e4, retryMs * 2 ** Math.min(failures, 5)));
}
- };
- void refresh(true);
- const timer = setInterval(() => {
- if (!boardPollPaused()) void refresh(false);
- }, PROJECT_BOARD_POLL_MS);
- return () => {
- gone = true;
- clearInterval(timer);
- };
- }, [hash]);
- if (state === null) return null;
- return (0, import_react47.createElement)(
- "div",
- { className: "bt-project-overlay" },
- (0, import_react47.createElement)("div", { className: "bt-scrim", onClick: close }),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
- (0, import_react47.createElement)(
- "div",
- { className: "bt-mem-head" },
- (0, import_react47.createElement)("h2", {}, t("Project board")),
- (0, import_react47.createElement)("span", { className: "bt-mem-head-gap" }),
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
- ),
- (0, import_react47.createElement)(
- "div",
- { className: "bt-project-body" },
- state.project === null ? (0, import_react47.createElement)(
- "div",
- { className: "bt-project-empty" },
- (0, import_react47.createElement)("p", null, t("No Project yet — create one.")),
- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
- close();
- actions?.openSettings?.("projects");
- } }, t("Open Projects in Settings"))
- ) : panel === "settings" ? (0, import_react47.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react47.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react47.createElement)(ProjectBoardPane, {
- project: state.project,
- rows: state.rows,
- done: state.done,
- canManage: state.canManage,
- tasks: state.tasks,
- decisions: state.decisions,
- meetings: state.meetings,
- queues: state.queues,
- metrics: state.metrics,
- onSettings: () => setPanel("settings"),
- onMembers: () => setPanel("members"),
- // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
- // so it closes first — otherwise the chat opens behind it.
- onOpenBot: (id) => {
- close();
- actions?.openSession?.(id);
- },
- // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
- // lands on that Bot or group. Nothing here re-decides which board that is.
- onOpen: (board) => {
- if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
- actions?.openBoard?.();
- setState(null);
- }
- })
- )
- )
- );
+ }
+ };
+ const offPages = browsers.pages.subscribe(() => {
+ if (JSON.stringify(browsers.openPages()) !== reported) poll?.abort();
+ });
+ void loop();
+ return () => {
+ stopped = true;
+ offPages();
+ poll?.abort();
+ };
+}
+
+// src/client/mobile.js
+var MOBILE_MAX_WIDTH = 640;
+var MOBILE_CSS = `
+@media (max-width:${MOBILE_MAX_WIDTH}px){
+.bt-held-actions{max-width:100%}
+.bt-held-card{padding:10px 12px}
+:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px}
+:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer}
+:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
+:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default}
+.bt-held-retry{min-height:32px}
+.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))}
+.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)}
+.bt-flybot-pointer{max-width:100%;min-width:0}
+.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none}
+.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
+.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px}
+.bt-inbox-card{flex-wrap:wrap;row-gap:8px}
+.bt-inbox-copy{flex-basis:calc(100% - 38px)}
+/* In progress and Done only. A Needs you ask is the whole text: inbox.js unsets this clamp on those five statuses (#1177). */
+.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
+.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px}
+.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px}
+}
+`;
+
+// src/client/panes.js
+var PANE_MIN_WIDTH_CSS = `
+.bt-panel{min-width:0}
+.bt-panel-view{min-width:0}
+.bt-drawer-body{min-width:0;overflow-wrap:anywhere}
+.bt-drawer-body :is(.bt-muted,.bt-note,.bt-section-title,.bt-drawer-role,.bt-drawer-permission){min-width:0;overflow-wrap:anywhere}
+.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
+.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
+[class*="_centerCol"]{min-width:0}
+html,body,#root{overflow-x:clip}
+[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
+[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
+`;
+var COMPOSER = "[data-composer-input], [data-composer-card]";
+function clipScroll(el) {
+ if (el && el.scrollLeft > 0) el.scrollLeft = 0;
+}
+function appFrameOf(doc) {
+ if (typeof doc.querySelector !== "function") return null;
+ const col = doc.querySelector('[class*="_centerCol"]');
+ const frame = col?.parentElement;
+ if (!frame) return null;
+ const name = frame.className;
+ const text2 = typeof name === "string" ? name : name?.baseVal ?? "";
+ return text2.includes("_frame") ? frame : null;
+}
+function levelShell(doc, from) {
+ const page = doc.scrollingElement || doc.documentElement;
+ clipScroll(page);
+ clipScroll(doc.documentElement);
+ clipScroll(doc.body);
+ clipScroll(appFrameOf(doc));
+ for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at);
+}
+function composerOf(node) {
+ let el = node;
+ if (el && el.nodeType !== 1) el = el.parentElement;
+ if (!el || typeof el.closest !== "function") return null;
+ return el.closest(COMPOSER);
+}
+function watchShellLevel(doc) {
+ const later = (from) => {
+ const reset = () => levelShell(doc, from);
+ reset();
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
+ };
+ const onFocus = (event) => later(composerOf(event.target) || void 0);
+ const onInput = (event) => {
+ const composer = composerOf(event.target);
+ if (composer) later(composer);
+ };
+ const onScroll = (event) => {
+ const target = event.target;
+ if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc);
+ };
+ doc.addEventListener("focusin", onFocus);
+ doc.addEventListener("input", onInput);
+ doc.addEventListener("scroll", onScroll, true);
+ return () => {
+ doc.removeEventListener("focusin", onFocus);
+ doc.removeEventListener("input", onInput);
+ doc.removeEventListener("scroll", onScroll, true);
+ };
}
// src/client/friend-lines.js
diff --git a/core/index.js b/core/index.js
index 1c94a2e18..37d3c9731 100644
--- a/core/index.js
+++ b/core/index.js
@@ -42,6 +42,7 @@ import * as capabilities from './src/host/capabilities.js'
import * as schedules from './src/host/schedules.js'
import * as projectAssistant from './src/host/project-assistant.js'
import * as projectChief from './src/host/project-chief.js'
+import * as projectTeam from './src/host/project-team.js'
import * as automationDefault from './src/host/automation-default.js'
import * as usage from './src/host/usage.js'
import * as update from './src/host/update.js'
@@ -60,7 +61,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
-const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]
+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]
export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
diff --git a/core/src/client/header.js b/core/src/client/header.js
index c98ed6202..08f8db393 100644
--- a/core/src/client/header.js
+++ b/core/src/client/header.js
@@ -7,6 +7,8 @@ import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js'
import { ActivityIndicator } from './activity-row.js'
import { BusyAckBubble } from './busy-ack.js'
import { t } from './i18n.js'
+import { call } from './api.js'
+import { ProjectAgentEntry, projectAgentFor } from './project-board.js'
import { friendBotTitle, roomTitle } from '../shared/remote-label.js'
// -------------------------------------------------------------------------
@@ -69,6 +71,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus
const owner = roster.byId[sessionId]?.id ?? sessionId
const doing = useActivity(map => map[owner])
const bot = roster.byId[sessionId]
+ // #1864: an owner's Bot that belongs to a Project gets the entry to that Project's assistant in
+ // its own chat header. The roster already carries the binding, so nothing else is read here.
+ const projectAgent = projectAgentFor(bot, roster.self?.memberId)
const room = roster.roomsById[sessionId]
const detailsOpen = useUi(state => state.details === sessionId)
const full = bot?.remote
@@ -96,6 +101,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus
h(MemoryNotice, { shown: memoryNotice }),
h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))),
h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''),
+ projectAgent === null ? null : h(ProjectAgentEntry, {
+ projectId: projectAgent.projectId, read: call, onOpen: id => actions?.openSession?.(id),
+ }),
h(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
h(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }))
diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js
index 20f2ad882..edb3ddf76 100644
--- a/core/src/client/i18n.js
+++ b/core/src/client/i18n.js
@@ -1403,6 +1403,15 @@ const ZH = {
'Role pool': '角色池',
'Can switch to {roles}': '可切换为 {roles}',
'No one in this pool yet.': '角色池里还没有人。',
+ // #1864: the Project team's dossier — persona, memory and skills are read-only in this slice.
+ 'Dossier': '档案',
+ 'Persona': '人设',
+ 'Skills': '技能',
+ 'No memory yet.': '还没有记忆。',
+ 'No skills attached.': '还没有附加技能。',
+ 'Open chat': '打开对话',
+ 'Open my agent': '打开我的项目助手',
+ "Open {name}'s dossier": '打开 {name} 的档案',
'No cards yet.': '还没有卡片。',
'Open Projects in Settings': '在设置中打开项目',
}
diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js
index 4e8b9c090..73fdd5649 100644
--- a/core/src/client/project-board.js
+++ b/core/src/client/project-board.js
@@ -101,6 +101,20 @@ export const PROJECT_BOARD_CSS = `
.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
+
+/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the
+ owner's own chat header carries the same entry to the Project assistant's chat. */
+.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
+.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px}
+.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}
+.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7}
+.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap}
+.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))}
+.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
`
// Sources in the order the board reads them; a source with no rows is left out so three empty
@@ -558,7 +572,7 @@ export function projectTeamView(project) {
}
}
-const teamRow = (role, label, botId, onOpenBot) => h('div', {
+const teamRow = (role, label, botId, onOpenBot, onOpenDossier) => h('div', {
key: role, className: 'bt-project-team-row', 'data-team-role': role,
// A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
...(botId === ''
@@ -573,23 +587,38 @@ const teamRow = (role, label, botId, onOpenBot) => h('div', {
h('span', { key: 'label', className: 'bt-project-team-label' }, label),
botId === ''
? h('span', { key: 'value', className: 'bt-project-missing' }, t('Not set'))
- : h('span', { key: 'value', className: 'bt-project-team-bot' }, `@${botId}`))
+ : h('span', { key: 'value', className: 'bt-project-team-bot' }, `@${botId}`),
+// #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a
+// reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set".
+h('button', {
+ key: 'dossier', type: 'button', className: 'bt-project-team-dossier',
+ 'data-dossier-role': role, ...(botId === '' ? {} : { 'data-dossier-bot': botId }),
+ 'aria-label': t('Open {name}\'s dossier', { name: botId === '' ? label : botId }),
+ onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role, label, botId }) },
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event?.stopPropagation(); onOpenDossier?.({ role, label, botId }) } },
+}, t('Dossier')))
const POOL_RANKS = [['owner', 'owner'], ['maintainer', 'maintainers'], ['member', 'members']]
-export function ProjectTeam({ project, onOpenBot }) {
+export function ProjectTeam({ project, onOpenBot, onOpenDossier }) {
const view = projectTeamView(project)
+ const poolId = (id) => h('button', {
+ key: id, type: 'button', className: 'bt-project-team-dossier',
+ 'data-dossier-role': 'pool', 'data-dossier-bot': id,
+ 'aria-label': t('Open {name}\'s dossier', { name: id }),
+ onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role: 'pool', label: t('Role pool'), botId: id }) },
+ }, `@${id}`)
const ranks = view.empty
? h('span', { className: 'bt-project-missing' }, t('No one in this pool yet.'))
: h('span', { className: 'bt-project-team-roles' },
POOL_RANKS.map(([role, key]) => (view.roles[key].length === 0
? null
: h('span', { key: role, className: 'bt-project-team-role', 'data-pool-role': role },
- `${roleText(role)}: ${view.roles[key].map(id => `@${id}`).join(', ')}`))))
+ `${roleText(role)}: `, view.roles[key].map(id => poolId(id))))))
return h('section', { className: 'bt-project-team', 'data-team': 'chief,agent,pool' },
h('h4', null, t('Project team')),
- teamRow('chief', t('Project Chief'), view.chief, onOpenBot),
- teamRow('agent', t('My agent'), view.agent, onOpenBot),
+ teamRow('chief', t('Project Chief'), view.chief, onOpenBot, onOpenDossier),
+ teamRow('agent', t('My agent'), view.agent, onOpenBot, onOpenDossier),
h('div', { key: 'pool', className: 'bt-project-team-row', 'data-team-role': 'pool' },
h('span', { key: 'label', className: 'bt-project-team-label' }, t('Role pool')),
ranks,
@@ -602,11 +631,99 @@ export function ProjectTeam({ project, onOpenBot }) {
view.choices.length === 0 ? null : ` · ${t('Can switch to {roles}', { roles: view.choices.map(roleText).join(', ') })}`])))
}
-export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot }) {
+// #1864: the dossier panel. It draws only the answer it was handed — persona, the memory head and
+// topic names, and the skill names — so it cannot re-fetch per section. A `null` row is the host's
+// "this Bot is gone" answer: the panel says "not set" and draws no invented persona.
+export function dossierRowOf(team, botId) {
+ const id = text(botId)
+ if (id === '') return null
+ const rows = Array.isArray(team?.rows) ? team.rows : []
+ return rows.find(row => row !== null && typeof row === 'object' && text(row.botId) === id) ?? null
+}
+
+const dossierSection = (title, children) => h('div', { key: title, className: 'bt-project-dossier-section' },
+ h('h4', null, title), children)
+
+export function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) {
+ const known = row !== null && typeof row === 'object'
+ const botId = known ? text(row.botId) : ''
+ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map(name => text(name)).filter(name => name !== '') : []
+ const skills = known && Array.isArray(row.skills) ? row.skills.map(name => text(name)).filter(name => name !== '') : []
+ const meta = known
+ ? [text(row.role), text(row.model)].filter(value => value !== '')
+ : []
+ return h('div', { className: 'bt-project-panel bt-project-dossier', 'data-panel': 'project-dossier', 'data-dossier-bot': botId === '' ? undefined : botId },
+ h('h3', null, label === '' || label === undefined ? t('Dossier') : `${label} · ${t('Dossier')}`),
+ known
+ ? [
+ dossierSection(t('Persona'), [
+ meta.length === 0 ? null : h('div', { key: 'meta', className: 'bt-project-dossier-meta' }, meta.map(value => h('span', { key: value }, value))),
+ h('p', { key: 'persona', className: 'bt-project-dossier-text' }, text(row.persona) || t('Not set')),
+ ]),
+ dossierSection(t('Memory'), [
+ h('p', { key: 'head', className: 'bt-project-dossier-text' }, text(row.memory?.head) || t('No memory yet.')),
+ topics.length === 0
+ ? null
+ : h('div', { key: 'topics', className: 'bt-project-dossier-topics' }, topics.map(topic => h('span', { key: topic, className: 'bt-project-dossier-topic' }, topic))),
+ ]),
+ dossierSection(t('Skills'), h('p', { key: 'skills', className: 'bt-project-dossier-text' }, skills.length === 0 ? t('No skills attached.') : skills.join(', '))),
+ ]
+ : h('p', { className: 'bt-project-missing' }, t('Not set')),
+ botId === '' || !known
+ ? null
+ : h('div', { key: 'actions', className: 'bt-project-row-actions' },
+ h('button', {
+ type: 'button', className: 'bt-soft bt-soft-sm', 'data-open-bot': botId,
+ onClick: () => onOpenBot?.(botId),
+ }, t('Open chat'))),
+ h('div', { className: 'bt-project-row-actions' },
+ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
+}
+
+// #1864: the entry in the owner's own Bot chat. The roster already carries a Bot's `projectId`
+// (the binding writes it on the record), so the header needs no second list read; the assistant id
+// comes from the Project record when the reader presses the entry.
+export function projectAgentFor(bot, selfId) {
+ if (bot === null || typeof bot !== 'object' || bot.remote !== undefined) return null
+ const projectId = text(bot.projectId)
+ if (projectId === '') return null
+ const owner = text(bot.ownerId)
+ const self = text(selfId)
+ if (self !== '' && owner !== '' && owner !== self) return null
+ return { projectId }
+}
+
+export function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
+ const [agentId, setAgentId] = useState('')
+ const id = text(projectId)
+ const open = async () => {
+ let target = agentId
+ if (target === '') {
+ try {
+ const record = await (read ?? call)('project-read', { id })
+ target = botIdOf(record?.project?.assistant)
+ } catch {
+ // A Project that cannot be read offers no agent; the chat is unchanged.
+ target = ''
+ }
+ if (target !== '') setAgentId(target)
+ }
+ if (target !== '') onOpen?.(target)
+ }
+ if (id === '') return null
+ return h('button', {
+ type: 'button', className: 'bt-project-agent-entry',
+ 'data-project-agent': id, 'data-open-bot': agentId === '' ? undefined : agentId,
+ title: t('Open my agent'), 'aria-label': t('Open my agent'),
+ onClick: open,
+ }, t('My agent'))
+}
+
+export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics })
return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' },
h('h3', null, view.title),
- h(ProjectTeam, { project, onOpenBot }),
+ h(ProjectTeam, { project, onOpenBot, onOpenDossier }),
// Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
view.counts === null ? null : h('div', { className: 'bt-project-counts', 'data-counts': 'waiting,inflight,blocked,done' },
h('span', null, [t('Waiting'), ': ', h('b', { key: 'w' }, countText(view.counts.waiting))]),
@@ -718,6 +835,37 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) {
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
}
+// #1864: one read for the whole board, exported so a test can prove the dossier comes from a single
+// `project-team` call. `read` is the channel seam (`call` in the app).
+export async function readProjectBoard(read, wanted) {
+ const listing = await read('projects')
+ const projects = Array.isArray(listing?.projects) ? listing.projects : []
+ const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted)
+ if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }
+ const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function'
+ ? undefined
+ : Intl.DateTimeFormat().resolvedOptions().timeZone
+ const [record, board, taskList, team] = await Promise.all([
+ read('project-read', { id: project.id }),
+ read('project-board', { id: project.id, timeZone: zone }),
+ read('tasks', {}),
+ read('project-team', { id: project.id }),
+ ])
+ const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? [])
+ // The groups are the same read as the rows, so the header cannot disagree with the cards.
+ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }
+ // #1860: `project-read` answers the stored record, which carries no caller rank — `role` and
+ // `choices` are the `projects` listing's answer (#1828). They stay on the record the pane
+ // reads, so the team block prints the rank without a second listing read.
+ const base = record?.project ?? project
+ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }
+ return {
+ project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true,
+ tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null,
+ team: team !== null && typeof team === 'object' ? team : { rows: [] },
+ }
+}
+
// #1814: what a card has waited (`waitedMs`) and whether the lease it holds is live are computed by
// the host at read time — a lease lasts 30 minutes (`tasks.js` `LEASE_TTL_SEC`) — so a board that
// reads once freezes: an expiry that happens while the page is open never turns the card `stalled`
@@ -740,6 +888,7 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
const read = request ?? call
const [state, setState] = useState(null)
const [panel, setPanel] = useState(null)
+ const [dossier, setDossier] = useState(null)
// The hash is the whole entry point, so it has to be read again every time it changes: an
// entry that writes `#project=` while this mounts opened nothing when the read was one-shot.
const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash))
@@ -771,28 +920,9 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
if (!hash.startsWith('#project')) { setState(null); return undefined }
const wanted = hash.slice('#project'.length).replace(/^=/, '')
let gone = false
- // Four reads of one board: the Project, its record, its rows, and the task texts the rows are
- // joined against. Nothing here asks a Bot by Bot.
- const loadBoard = async () => {
- const listing = await read('projects')
- const projects = listing?.projects ?? []
- const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted)
- if (project === undefined) return { project: null, rows: [], canManage: false, tasks: [] }
- const [record, board, taskList] = await Promise.all([
- read('project-read', { id: project.id }),
- read('project-board', { id: project.id, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
- read('tasks', {}),
- ])
- const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? [])
- // The groups are the same read as the rows, so the header cannot disagree with the cards.
- const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }
- // #1860: `project-read` answers the stored record, which carries no caller rank — `role` and
- // `choices` are the `projects` listing's answer (#1828). They stay on the record the pane
- // reads, so the team block prints the rank without a second listing read.
- const base = record?.project ?? project
- const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }
- return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null }
- }
+ // #1864: the board, its record, its rows, the task texts and the team dossier are one read path,
+ // and the dossier comes from the single `project-team` call that read makes.
+ const loadBoard = () => readProjectBoard(read, wanted)
const refresh = async (first) => {
try {
const next = await loadBoard()
@@ -825,13 +955,21 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { close(); actions?.openSettings?.('projects') } }, t('Open Projects in Settings')))
// A row opens the board its task lives on, carried by `#task=`: the Tasks board reads
// that hash and lands on the Bot or group the task belongs to.
+ : panel === 'dossier' ? h(ProjectDossierPanel, {
+ row: dossierRowOf(state.team, dossier?.botId),
+ label: dossier?.label ?? '',
+ onClose: () => setPanel(null),
+ onOpenBot: (id) => { close(); actions?.openSession?.(id) },
+ })
: panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) })
: panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) })
: h(ProjectBoardPane, {
project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks,
- decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics,
+ decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics, team: state.team,
onSettings: () => setPanel('settings'),
onMembers: () => setPanel('members'),
+ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
+ onOpenDossier: (value) => { setDossier(value); setPanel('dossier') },
// #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
// so it closes first — otherwise the chat opens behind it.
onOpenBot: (id) => { close(); actions?.openSession?.(id) },
diff --git a/core/src/host/authz.js b/core/src/host/authz.js
index a5782c2b4..44e1f546a 100644
--- a/core/src/host/authz.js
+++ b/core/src/host/authz.js
@@ -69,6 +69,10 @@ const TASK_ENDPOINTS = new Set([
// other wiring point was present, and a signed-in host answered `bot/not-found: Unknown
// endpoint project-board` while `project-read` and `project-of` answered ALLOW.
'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board',
+ // The team dossier (#1864) is a read over the same Project and the same bindings: the owner
+ // decided on 2026-10-30 that persona and memory are shared by the Project's members, so it sits
+ // beside `project-read` rather than behind the owner gate.
+ 'project-team',
// The assistant (#1712): its memory is per Project, and deleting a Project takes it along. The
// owner controls (#1759) are a Project-wide write — the digest opt-out silences every member's
// 09:00 digest — so `project-assistant-owner` belongs here with them: dispatched but missing
diff --git a/core/src/host/channel.js b/core/src/host/channel.js
index c5a67ff27..0d24db49f 100644
--- a/core/src/host/channel.js
+++ b/core/src/host/channel.js
@@ -675,6 +675,7 @@ export function install(rt) {
case 'project-bind':
case 'project-of':
case 'project-board':
+ case 'project-team':
case 'project-role':
case 'project-delete':
case 'project-memory':
diff --git a/core/src/host/memory.js b/core/src/host/memory.js
index 6906cc8b0..57123eb4c 100644
--- a/core/src/host/memory.js
+++ b/core/src/host/memory.js
@@ -411,6 +411,7 @@ export function install(rt) {
memoryEndpoint: async () => { throw new Error('Memory is turned off on this host') },
memoryNews: () => ({}),
memoryReviews: () => 0,
+ memoryDigest: () => ({ head: '', topics: [] }),
})
return
}
@@ -1469,9 +1470,22 @@ export function install(rt) {
})
}
+ // The Project board's dossier (#1864) reads the head and the topic names only: never an entry
+ // body from a topic, and never another scope. It is a read seam, so the panel adds no second
+ // memory file reader beside this one and cannot drift from where `remember` writes.
+ function memoryDigest(botId, limits = {}) {
+ const scope = ownScope(String(botId ?? ''))
+ const head = Number.isFinite(limits.head) ? limits.head : 4000
+ const topics = Number.isFinite(limits.topics) ? limits.topics : 64
+ return {
+ head: readText(join(dirOf(scope), MAIN)).slice(0, head),
+ topics: topicNames(scope).slice(0, topics),
+ }
+ }
+
Object.assign(rt, {
memoryEnabled: true, renewMemory, archiveBotMemory, memoryEndpoint, memoryReviews,
memoryNews: () => Object.fromEntries(news),
- botMemoryRead, botMemoryAppend,
+ botMemoryRead, botMemoryAppend, memoryDigest,
})
}
diff --git a/core/src/host/project-team.js b/core/src/host/project-team.js
new file mode 100644
index 000000000..af6c59a99
--- /dev/null
+++ b/core/src/host/project-team.js
@@ -0,0 +1,86 @@
+// The Project board's team dossier (#1864). One read-only answer for the people who serve a
+// Project: its own Chief (#1859), the assistant that speaks for the caller (#1712) and the role
+// pool the Project names (#1828). Nothing here writes persona, instructions or memory; editing stays
+// in the Project settings panel, and a later slice that wants an editor opens its own issue.
+//
+// Persona is the Bot's `instructions`, memory is the head of its own MEMORY.md plus the topic names
+// (never a topic body, never another Project's memory), and skills are the attached capabilities the
+// library records as `skill` — a pack contributes the skills it contains. The owner decided on
+// 2026-10-30 that persona and memory are shared by the Project's members and kept as files in the
+// repository; this slice reads what the Bot carries today, and adds no writer.
+//
+// Every string and list is capped, and a Bot that was deleted is a `null` row: one dead Chief must
+// not take the assistant and the pool down with it. The row shape is the contract the client's
+// dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working
+// folder or a stored secret.
+import { rolesOf } from './project-board.js'
+
+export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 }
+
+const text = value => (typeof value === 'string' ? value.trim() : '')
+const capped = (value, limit) => text(value).slice(0, limit)
+
+export function install(rt) {
+ // The attached names that are skills. A pack is expanded to its skill children; a capability the
+ // library no longer holds is kept by name so a moved library does not erase what the Bot has.
+ const skillNames = (bot) => {
+ const library = typeof rt.capabilityLibraryView === 'function' ? rt.capabilityLibraryView() : []
+ const byName = new Map((Array.isArray(library) ? library : []).map(item => [text(item?.name), item]))
+ const names = []
+ const add = (name) => {
+ const clean = text(name)
+ if (clean !== '' && !names.includes(clean)) names.push(clean)
+ }
+ for (const attached of Array.isArray(bot.capabilities) ? bot.capabilities : []) {
+ const item = byName.get(text(attached))
+ if (item === undefined) add(attached)
+ else if (item.kind === 'skill') add(attached)
+ else if (item.kind === 'pack') for (const child of item.items ?? []) if (byName.get(text(child))?.kind === 'skill') add(child)
+ }
+ return names.slice(0, DOSSIER_LIMITS.skills)
+ }
+
+ // Only this Project's rooms: a dossier is the Project's own read, so a Bot that also works in
+ // another Project's room does not carry that room's name into this answer.
+ const roomsOf = (bot, project) => Object.values(rt.state?.rooms ?? {})
+ .filter(room => room !== null && typeof room === 'object' && Array.isArray(room.members) && room.members.includes(bot.id))
+ .filter(room => rt.projectOfMember?.('group', room.id)?.id === project.id)
+ .map(room => ({ id: text(room.id), name: text(room.name).slice(0, 200) }))
+ .slice(0, DOSSIER_LIMITS.rooms)
+
+ const rowOf = (botId, project) => {
+ const id = text(botId)
+ if (id === '') return null
+ const bot = rt.botOf?.(id)
+ if (bot === undefined || bot === null) return null
+ const digest = rt.memoryDigest?.(id) ?? { head: '', topics: [] }
+ return {
+ botId: id,
+ name: capped(bot.name, DOSSIER_LIMITS.name),
+ role: capped(bot.role, DOSSIER_LIMITS.role),
+ persona: capped(bot.instructions, DOSSIER_LIMITS.persona),
+ model: capped(bot.model, DOSSIER_LIMITS.model),
+ color: capped(bot.color, DOSSIER_LIMITS.color),
+ createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null,
+ memory: {
+ head: (typeof digest.head === 'string' ? digest.head : '').slice(0, DOSSIER_LIMITS.memoryHead),
+ topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics),
+ },
+ skills: skillNames(bot),
+ rooms: roomsOf(bot, project),
+ }
+ }
+
+ // The order is the board's own: Chief, assistant, then the pool. A Project with no Chief keeps
+ // the first slot as `null`, so the client always reads the same three groups.
+ const projectTeam = (projectId) => {
+ const project = rt.readProject?.(text(projectId)) ?? null
+ if (project === null) return { rows: [] }
+ const roles = rolesOf(project)
+ const pool = [...roles.maintainers, ...roles.members].filter((id, at, all) => all.indexOf(id) === at)
+ const ids = [text(project.chief?.botId), text(project.assistant?.botId), ...pool]
+ return { rows: ids.map(id => rowOf(id, project)) }
+ }
+
+ rt.projectTeam = projectTeam
+}
diff --git a/core/src/host/store.js b/core/src/host/store.js
index bda6d26a0..0a586a37a 100644
--- a/core/src/host/store.js
+++ b/core/src/host/store.js
@@ -719,6 +719,10 @@ export function install(rt) {
return { project: readProject(project.id), ...(result ?? {}) }
}
case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) }
+ // The read-only team dossier (#1864): the Project's Chief, its assistant and its pool, each
+ // with persona, memory head/topics and skills. The read lives in project-team.js; a missing
+ // Bot is a `null` row there, never an error for the whole answer.
+ case 'project-team': return rt.projectTeam?.(projectText(body.id, 200)) ?? { rows: [] }
// The read-only board (#1696): Project id in, its tasks out. The aggregate lives in
// project-board.js; it reads and returns, and writes nothing. The actor goes with the id
// so the board applies the same visibility rule `tasks` does (#1723).
diff --git a/core/test/project-board-panels.test.js b/core/test/project-board-panels.test.js
index aa9273391..0ed3f0d5c 100644
--- a/core/test/project-board-panels.test.js
+++ b/core/test/project-board-panels.test.js
@@ -55,7 +55,7 @@ runInNewContext(bundle.outputFiles[0].text, {
throw new Error(`no platform module ${name}`)
},
})
-const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel, hostText, installLocale } = module.exports
+const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel, ProjectDossierPanel, ProjectAgentEntry, projectAgentFor, readProjectBoard, hostText, installLocale } = module.exports
const render = (component, props) => { index = 0; return component(props) }
const all = (node, out = []) => {
@@ -450,3 +450,89 @@ test('a Project with no Chief keeps the row and reads "not set"', () => {
assert.match(text(drawn), /Not set/)
assert.deepEqual(opened, [])
})
+
+// #1864: the dossier. The board's team block now carries a Dossier control on every row, the panel
+// draws Persona / Memory / Skills from the one `project-team` answer, and the owner's own chat
+// header has an entry that opens the Project assistant's Session.
+const dossier = () => ({
+ botId: 'bot-chief', name: 'Project Chief (FlyBot)', role: 'Project Chief',
+ persona: 'You are the Project Chief of FlyBot.', model: 'deepseek-chat', color: '#112233', createdAt: 1,
+ memory: { head: 'The Chief remembers the Project.', topics: ['travel', 'work'] },
+ skills: ['ship-it'], rooms: [{ id: 'r1', name: 'FlyBot dev' }],
+})
+
+const projectRecord = () => ({
+ id: 'project-1', name: 'FlyBot', ownerUserId: 'owner-1',
+ chief: { botId: 'bot-chief', sessionId: 'bot-chief', createdAt: 1 },
+ assistant: { botId: 'bot-assistant', sessionId: 'bot-assistant', createdAt: 1 },
+ roles: { maintainers: ['bot-dev'], members: [] },
+})
+
+const boardAnswer = () => ({ tasks: [], done: [], decisions: {}, meetings: {}, waiting: [], inflight: [], blocked: [], counts: null, metrics: null })
+
+test('the board reads the dossier from exactly one project-team call', async () => {
+ resetSlots()
+ const calls = []
+ const read = async (endpoint) => {
+ calls.push(endpoint)
+ if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] }
+ if (endpoint === 'project-read') return { project: projectRecord(), canManage: true }
+ if (endpoint === 'project-board') return boardAnswer()
+ if (endpoint === 'tasks') return []
+ if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] }
+ throw new Error(`unexpected endpoint ${endpoint}`)
+ }
+ const state = await readProjectBoard(read, 'project-1')
+ assert.equal(calls.filter(endpoint => endpoint === 'project-team').length, 1, 'one call feeds the whole dossier')
+ assert.equal(state.team.rows.length, 3, 'the null row survives the read so the panel can stay empty')
+ assert.equal(state.team.rows[0].botId, 'bot-chief')
+
+ const tree = render(ProjectBoardPane, { project: state.project, rows: state.rows, team: state.team, canManage: true })
+ const chief = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief')
+ assert.ok(chief, 'the Chief row carries the dossier control')
+ const opened = []
+ const drawn = render(ProjectBoardPane, { project: state.project, rows: state.rows, team: state.team, canManage: true, onOpenDossier: value => opened.push(value) })
+ const control = all(drawn).find(node => node.props?.['data-dossier-bot'] === 'bot-chief')
+ control.props.onClick()
+ assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief'])
+})
+
+test('the dossier panel draws Persona, Memory and Skills, and stays empty for a null row', () => {
+ resetSlots()
+ const drawn = text(render(ProjectDossierPanel, { row: dossier(), label: 'Project Chief' }))
+ assert.match(drawn, /Persona/, 'the Persona section is drawn')
+ assert.match(drawn, /Project Chief of FlyBot/, 'the persona text is the host answer')
+ assert.match(drawn, /deepseek-chat/, 'the model is shown with the persona')
+ assert.match(drawn, /Memory/, 'the Memory section is drawn')
+ assert.match(drawn, /The Chief remembers the Project\./, 'the memory head is shown')
+ assert.match(drawn, /travel/, 'the topic names are listed')
+ assert.match(drawn, /Skills/, 'the Skills section is drawn')
+ assert.match(drawn, /ship-it/, 'the attached skill is listed')
+
+ const empty = text(render(ProjectDossierPanel, { row: null, label: 'Project Chief' }))
+ assert.match(empty, /Not set/, 'an unset Bot reads "not set"')
+ assert.doesNotMatch(empty, /deepseek-chat/, 'and the panel draws no invented persona')
+})
+
+test('the owner\'s chat entry opens the Project assistant, and only the owner\'s own Bot offers it', async () => {
+ resetSlots()
+ const calls = []
+ const opened = []
+ const read = async (endpoint, payload) => {
+ calls.push({ endpoint, payload })
+ return { project: { assistant: { botId: 'bot-assistant' } } }
+ }
+ let tree = render(ProjectAgentEntry, { projectId: 'project-1', read, onOpen: id => opened.push(id) })
+ const entry = all(tree).find(node => node.props?.['data-project-agent'] === 'project-1')
+ entry.props.onClick()
+ await waitFor(() => (opened.length > 0 ? true : undefined))
+ assert.deepEqual(opened, ['bot-assistant'], 'the entry opens the assistant Session')
+ assert.deepEqual(JSON.parse(JSON.stringify(calls)), [{ endpoint: 'project-read', payload: { id: 'project-1' } }], 'one project-read answers the assistant id')
+ tree = render(ProjectAgentEntry, { projectId: 'project-1', read, onOpen: id => opened.push(id) })
+ assert.equal(all(tree).find(node => node.props?.['data-open-bot']).props['data-open-bot'], 'bot-assistant', 'the resolved entry names the Bot it opens')
+
+ assert.deepEqual(JSON.parse(JSON.stringify(projectAgentFor({ projectId: 'project-1', ownerId: 'owner-1' }, 'owner-1'))), { projectId: 'project-1' })
+ assert.equal(projectAgentFor({ projectId: 'project-1', ownerId: 'other' }, 'owner-1'), null, "another owner's Bot offers no entry")
+ assert.equal(projectAgentFor({ ownerId: 'owner-1' }, 'owner-1'), null, 'a Bot outside a Project offers no entry')
+ assert.equal(projectAgentFor({ projectId: 'project-1', ownerId: 'owner-1', remote: {} }, 'owner-1'), null, "a friend's Bot offers no entry")
+})
diff --git a/core/test/project-board-view.test.js b/core/test/project-board-view.test.js
index cfcb15bb9..fb73f3127 100644
--- a/core/test/project-board-view.test.js
+++ b/core/test/project-board-view.test.js
@@ -316,11 +316,11 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
win.location.hash = '#project'
const count = { calls: 0 }
const { tree, cleanups } = await mount({ actions: {}, call: boardAnswers(count) })
- assert.equal(count.calls, 4, 'the first read is the four calls the board makes: Project, record, board, task texts')
+ assert.equal(count.calls, 5, 'the first read is the five calls the board makes: Project, record, board, task texts, team dossier')
assert.match(textIn(tree), /Dev4 · stalled/, 'the board draws what the first read answered')
const afterFirst = count.calls
await tick()
- assert.equal(count.calls, afterFirst + 4, 'an open board reads again on the next tick')
+ assert.equal(count.calls, afterFirst + 5, 'an open board reads again on the next tick')
// A row opens its own board: the hash names the task and this board stops drawing, so a tick that
// lands before the cleanup must not read. This segment pins the unmount, not the predicate.
const afterTick = count.calls
@@ -330,7 +330,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
// Closing that board puts this one back in front: the next tick reads again.
win.location.hash = '#project'
await tick()
- assert.equal(count.calls, afterTick + 4, 'closing the row’s board resumes the poll')
+ assert.equal(count.calls, afterTick + 5, 'closing the row’s board resumes the poll')
// A hidden tab reads nothing either — this is the segment the named predicate is pinned by.
const afterResume = count.calls
doc.visibilityState = 'hidden'
@@ -338,7 +338,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
assert.equal(count.calls, afterResume, 'no read in a hidden tab')
doc.visibilityState = 'visible'
await tick()
- assert.equal(count.calls, afterResume + 4, 'a visible tab reads again on the next tick')
+ assert.equal(count.calls, afterResume + 5, 'a visible tab reads again on the next tick')
// Close the board: the timer goes with it, so a tick afterwards reads nothing at all.
const afterVisible = count.calls
for (const off of cleanups) off()
diff --git a/core/test/project-team.test.js b/core/test/project-team.test.js
new file mode 100644
index 000000000..b4e10612d
--- /dev/null
+++ b/core/test/project-team.test.js
@@ -0,0 +1,175 @@
+// #1864: the Project board's team dossier. One read-only `project-team` endpoint answers, for the
+// Project's Chief, its assistant and the pool members the Project names, the persona (`instructions`),
+// the memory head and topic names, and the attached skills — capped, and with a missing Bot as a
+// `null` row instead of an error for the whole answer.
+//
+// Before the slice the host answered `Unknown endpoint project-team` before any handler ran, so the
+// first two tests are the red half: they call the endpoint through the same route the browser uses.
+import assert from 'node:assert/strict'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { createRuntime, provideFlyBot } from '../index.js'
+import { fakeContext } from './fake-ctx.js'
+
+const LONG = 'x'.repeat(5000)
+
+const seed = () => ({
+ version: 2,
+ revision: 1,
+ mainBotIds: ['bot-chief'],
+ members: {
+ 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'bot-chief' },
+ 'peer-1': { id: 'peer-1', createdAt: 2 },
+ },
+ bots: {
+ 'bot-chief': {
+ id: 'bot-chief', name: 'Project Chief (FlyBot)', role: 'Project Chief', sessionId: 'bot-chief',
+ createdAt: 11, ownerId: 'owner-1', model: 'deepseek-chat', color: '#112233',
+ instructions: 'You are the Project Chief of FlyBot.', capabilities: ['ship-it'],
+ },
+ 'bot-assistant': {
+ id: 'bot-assistant', name: 'Project Assistant (FlyBot)', role: 'Project Assistant', sessionId: 'bot-assistant',
+ createdAt: 12, ownerId: 'owner-1', model: 'deepseek-chat', color: '#445566',
+ instructions: 'You are the Project Assistant of FlyBot.', capabilities: ['ship-it', 'not-a-skill'],
+ },
+ 'bot-dev': {
+ id: 'bot-dev', name: 'Dev', role: 'Dev', sessionId: 'bot-dev',
+ createdAt: 13, ownerId: 'owner-1', model: 'deepseek-reasoner', color: '#778899',
+ instructions: 'You implement issues.', capabilities: [],
+ },
+ },
+ rooms: {
+ r1: { id: 'r1', name: 'FlyBot dev', members: ['bot-chief', 'bot-dev'], mode: 'admin', admin: 'bot-chief', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-1' },
+ r2: { id: 'r2', name: 'Far Project room', members: ['bot-dev'], mode: 'admin', admin: 'bot-dev', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-2' },
+ },
+ agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {},
+ capabilities: {
+ items: {
+ 'ship-it': { name: 'ship-it', kind: 'skill', description: 'Ship things' },
+ 'not-a-skill': { name: 'not-a-skill', kind: 'mcp', description: 'Not a skill' },
+ },
+ },
+ projects: {
+ 'project-1': {
+ id: 'project-1', name: 'FlyBot', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main',
+ ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active',
+ chief: { botId: 'bot-chief', sessionId: 'bot-chief', createdAt: 1 },
+ assistant: { botId: 'bot-assistant', sessionId: 'bot-assistant', createdAt: 1 },
+ roles: { maintainers: ['bot-dev'], members: ['bot-gone'] },
+ },
+ 'project-2': {
+ id: 'project-2', name: 'Far', repository: null, defaultBranch: 'main',
+ ownerUserId: 'owner-1', createdAt: 2, updatedAt: 2, status: 'active',
+ },
+ },
+})
+
+const memory = (home, botId, head, topics = []) => {
+ const dir = join(home, 'memory', 'bots', botId)
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'MEMORY.md'), head)
+ for (const topic of topics) writeFileSync(join(dir, `${topic}.md`), `- a fact about ${topic}\n`)
+}
+
+async function start({ signedIn = false } = {}) {
+ const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-team-'))
+ writeFileSync(join(home, 'state.json'), JSON.stringify(seed()))
+ memory(home, 'bot-chief', 'The Chief remembers the Project.\n', ['travel', 'work'])
+ memory(home, 'bot-assistant', 'The assistant remembers the board.\n', ['board'])
+ memory(home, 'bot-dev', 'Dev memory.\n')
+ const ctx = fakeContext()
+ let route
+ const register = ctx.connection.fetch.register
+ ctx.connection.fetch.register = (options) => { route = options; return register(options) }
+ const agents = new Map()
+ const agentOf = (sessionId) => {
+ if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } })
+ return agents.get(sessionId)
+ }
+ Object.assign(ctx, {
+ llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
+ workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
+ sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} },
+ sessionTitle: { rename: async () => {} },
+ sessions: { flush: async () => {} },
+ })
+ const rt = createRuntime(ctx, { home })
+ provideFlyBot(ctx, rt.flybot)
+ await rt.load()
+ const callAs = async (endpoint, payload, token) => (await route.fetch(new Request('http://localhost/api/bot', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(token === undefined ? {} : { 'x-flybot-member-session': token }) },
+ body: JSON.stringify({ endpoint, payload }),
+ }))).json()
+ if (signedIn) {
+ rt.authenticate = async token => (token === 'tok-owner' ? { memberId: 'owner-1' } : null)
+ rt.state.authSessions = { 's-owner': { memberId: 'owner-1', tokenHash: 'o' } }
+ }
+ const stop = async () => {
+ await ctx.dispose()
+ rmSync(home, { recursive: true, force: true, maxRetries: 5 })
+ }
+ return { callAs, home, rt, stop }
+}
+
+const ROW_KEYS = ['botId', 'color', 'createdAt', 'memory', 'model', 'name', 'persona', 'role', 'rooms', 'skills']
+
+test('project-team answers persona, memory and skills for the Chief, the assistant and the pool', async (t) => {
+ const { callAs, stop } = await start()
+ t.after(stop)
+ const answer = await callAs('project-team', { id: 'project-1' })
+ assert.equal(answer.ok, true, 'the endpoint is dispatched through the channel, not Unknown endpoint')
+ const rows = answer.value.rows
+ assert.equal(rows.length, 4, 'Chief, assistant and the two pool members, in that order')
+ assert.deepEqual(rows.slice(0, 3).map(row => row.botId), ['bot-chief', 'bot-assistant', 'bot-dev'])
+ assert.equal(rows[3], null, 'a pool member whose Bot is gone is a null row, not an error')
+
+ const chief = rows[0]
+ assert.deepEqual(Object.keys(chief).sort(), ROW_KEYS, 'exactly the documented keys, no secret-bearing field')
+ assert.equal(chief.name, 'Project Chief (FlyBot)')
+ assert.equal(chief.role, 'Project Chief')
+ assert.equal(chief.model, 'deepseek-chat')
+ assert.equal(chief.color, '#112233')
+ assert.equal(chief.createdAt, 11)
+ assert.equal(chief.persona, 'You are the Project Chief of FlyBot.', 'persona is the Bot instructions')
+ assert.deepEqual(chief.memory, { head: 'The Chief remembers the Project.\n', topics: ['travel', 'work'] })
+ assert.deepEqual(chief.skills, ['ship-it'], 'only attached skills, not every attached capability')
+ assert.deepEqual(chief.rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'only this Project\'s rooms')
+
+ assert.deepEqual(rows[1].memory, { head: 'The assistant remembers the board.\n', topics: ['board'] })
+ assert.deepEqual(rows[1].skills, ['ship-it'])
+ assert.deepEqual(rows[2].rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'the far Project\'s room is not listed')
+})
+
+test('project-team caps persona, memory head and topics', async (t) => {
+ const { callAs, home, rt, stop } = await start()
+ t.after(stop)
+ rt.state.bots['bot-chief'].instructions = LONG
+ memory(home, 'bot-chief', LONG, Array.from({ length: 70 }, (_, at) => `topic-${String(at).padStart(2, '0')}`))
+ const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
+ assert.equal(rows[0].persona.length, 4000, 'persona is capped at 4000 characters')
+ assert.equal(rows[0].memory.head.length, 4000, 'the memory head is capped at 4000 characters')
+ assert.equal(rows[0].memory.topics.length, 64, 'at most 64 topic names')
+})
+
+test('a deleted Chief is a null row while the rest of the team still answers', async (t) => {
+ const { callAs, rt, stop } = await start()
+ t.after(stop)
+ delete rt.state.bots['bot-chief']
+ const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
+ assert.equal(rows[0], null, 'the missing Chief is null')
+ assert.equal(rows[1].botId, 'bot-assistant', 'the assistant still answers')
+ assert.equal(rows[2].botId, 'bot-dev', 'the pool still answers')
+})
+
+test('project-team reads with the project-read class of caller, including a signed-in member', async (t) => {
+ const { callAs, stop } = await start({ signedIn: true })
+ t.after(stop)
+ const answer = await callAs('project-team', { id: 'project-1' }, 'tok-owner')
+ assert.equal(answer.ok, true, 'the endpoint belongs beside project-read in authz, not behind the owner gate')
+ assert.equal(answer.value.rows[0].botId, 'bot-chief')
+ const unknown = await callAs('project-team', { id: 'project-none' }, 'tok-owner')
+ assert.deepEqual(unknown.value.rows, [], 'an unknown Project answers no rows instead of an error')
+})
diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md
index 4cc53a7c9..2edca2a5f 100644
--- a/docs/subsystems/core-client.md
+++ b/docs/subsystems/core-client.md
@@ -312,6 +312,19 @@ spawn failed — stays drawn and reads *Not set*, so this slice never waits on #
live in `src/client/role-labels.js`, which the sidebar and this block both print. Tests:
`core/test/project-board-panels.test.js`.
+The **dossier** (#1864) is the read-only half beside those rows. Each team row carries a `Dossier`
+control (`data-dossier-bot`, also for a pool member), and `ProjectDossierPanel` draws three sections
+from the one `project-team` answer the overlay reads with the board: **Persona** (the Bot's
+instructions, role and model), **Memory** (the `MEMORY.md` head and the topic names) and **Skills**
+(the attached `skill` capability names). The panel never re-fetches per section, and a `null` row —
+the host's answer for a deleted Bot — shows *Not set* with nothing invented. `readProjectBoard`
+keeps the dossier on the same read as the rows, so the board and the panel cannot disagree, and the
+"my agent" row uses the same panel. The entry in the owner's own Bot chat is
+`ProjectAgentEntry` in the header: the roster already carries `bot.projectId`, the entry reads
+`project-read` once for `assistant.botId` and opens that Session through `actions.openSession`; the
+assistant is not a special case of the board, and the Project page keeps its own row. Tests:
+`core/test/project-board-panels.test.js` and `core/test/project-team.test.js`.
+
The pane is registered on the existing `shell.overlay` surface and opens at `#project=`; the overlay
re-reads the hash on `hashchange`, so an entry that writes it while the overlay is mounted still opens
the board. The sidebar is the only entry (#1854): the **Project board** button #1701 put in
diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md
index d2a7e2684..7818c01cd 100644
--- a/docs/subsystems/core-host.md
+++ b/docs/subsystems/core-host.md
@@ -52,6 +52,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core
| `core/src/host/project-state.js` | The `project_state` tool (#1702, #1550 Stage 2.4): the Project State files of the Session working folder (`PROJECT.md`, `ROADMAP.md`, `docs/governance.md`, `decisions/*.md`, `LESSONS.md`), listed with byte size, sha256 content hash and last-modified time, or one file handed over whole up to 200,000 bytes. A caller compares the hash with the copy it already had, so a stale copy is visible instead of assumed. Answers `absent`, `too large` (with the size) or `not a repository` rather than an empty document, refuses any path outside that list, and reads nothing at install time: no prompt, system message or Session context is written by a call. Tests: `core/test/project-state.test.js`. |
| `core/src/host/project-assistant.js` | The Project Assistant (#1712, #1550 Stage 2.5): one hidden Bot and Session per Project, created by `project-create` through the roster and refusing an unknown or archived id, with exactly one 09:00 Asia/Shanghai task on that Session through the host's own `schedule-create` path and removed with the Project by `project-delete` — never a cron inside the plugin, and no backfill for a Project made before this slice. Memory lives in `state.projectMemory[id]` (`{decisions, questions, digest}`, behind `rt.projectMemoryOf` / `appendProjectMemory` / `setProjectDigest` and the `project-memory` / `project-memory-write` endpoints) keyed by Project id, so one Project cannot read another's entries. Since #1847 that bound is enforced twice, from one value: `MAX_ENTRY` (600, in `constants.js`) is checked by the assistant writer and again by `appendProjectMemory`, the one write door, so the endpoint refuses an empty entry (`A Project memory entry needs text`) and one over the cap (`A Project memory entry is at most 600 characters`) instead of storing a blank row or cutting the text, and a refusal leaves the table and `project.updatedAt` untouched; the panel quotes either sentence, in Chinese under a zh locale (`core/src/client/i18n.js`). `buildDigest` answers, in order, what merged since the previous digest, what closed, what is still in progress and on whom, and the next slice, using only board rows (`source`/`column`/`assignee`/`updatedAt`) and the Project State file list; a day with no change is one line, one day has one note and one `deliver()`, and the second build answers `skipped`. Identity is the install's GitHub connector account (the owner's own on a single-person install): with no connector signed in, or on a multi-member host whose `projects.ownerUserId` is not this host's own user, `digest` refuses and escalates instead of posting as the shared app, and every write carries `Written-by: Project Assistant ()` and `Posted-for: `. Since #1733 the memory and the digest are per (Project, owner) — `state.projectMemory[projectId].owners[ownerId]` with the primary owner mirrored on the row, behind `rt.projectMemoryFor(id, ownerId)`, `appendProjectMemory(id, kind, entry)`, `setProjectDigest(id, note, ownerId)` — and one `projectBoard` read plus one `listProjectState` read serve one render per owner, so the facts are shared and the personalisation is not; a read outside `ownersOf(project)` is refused naming that boundary. Since #1846 the digest reads the Project's own board, not one member's page: `rt.projectBoard(id, null, { internal: true })` is the one opt-in that skips the per-reader `taskVisible` rule, and the channel builds its options from the zone alone, so no request body can ask for it. Tests: `core/test/project-assistant.test.js`, `core/test/project-digest-authz.test.js`. |
| `core/src/host/project-chief.js` | The Project Chief (#1859): every Project gets its own Chief, created with the Project by `project-create` — never the user's main Chief acting part-time. `rt.projectChief` has the same shape as `rt.projectAssistant` (`create(projectId)`, `chiefOf(projectId)`, `remove(projectId)`), the record carries `chief: { botId, sessionId, createdAt }` beside `assistant`, and the spawned Bot is named `Project Chief ()`, bound with `bindProject` so it carries `projectId`, and given instructions that name that one Project and forbid answering for any other (another Project's name is never quoted). A host without the seam logs once and answers `undefined`, so `project-create` still creates the Project with `chief: { error }`; `project-delete` retires the Chief through `deleteBot` and clears its binding. Tests: `core/test/projects.test.js`. |
+| `core/src/host/project-team.js` | The Project's team dossier (#1864), read-only. `rt.projectTeam(projectId)` answers `{ rows }` in the board's order — the Project's Chief, its assistant, then `roles.maintainers` / `roles.members` — with each row `{ botId, name, role, persona, model, color, createdAt, memory: { head, topics }, skills, rooms }`. `persona` is the Bot's `instructions`; `memory.head` is its own `MEMORY.md` (read through `rt.memoryDigest` in `memory.js`, which also supplies the topic names and never a topic body); `skills` is the attached capabilities the library records as `skill` (a pack contributes its skill children); `rooms` is only the rooms this Project owns. Every string and list is capped (`DOSSIER_LIMITS`: persona and memory head 4000, 64 topics, 64 skills, 32 rooms), a deleted Bot is a `null` row instead of an error for the whole answer, and an unknown Project answers `{ rows: [] }`. The endpoint sits beside `project-read` in `authz.js`'s `TASK_ENDPOINTS`, per the owner decision that persona and memory are shared by the Project's members. Tests: `core/test/project-team.test.js`. |
| `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. |
| `core/src/shared/preview.js` | What a stored line may show a person (#130, #1347): `visibleText(text)` unwraps a complete `` wrap, then removes leftover marker fragments until neither changes the text, then folds to one line. `stripFence` is the marker half alone. The sidebar rows read it (`previewOf` in `src/client/text.js`), and so does the `preview` this channel puts on a room in the `state` payload, which used to be a plain `unwrapUntrusted` and so leaked `from="User"> … >` on a line whose opener had already been cut. Tests: `core/test/ui-clickthrough.test.js`. |
| `core/src/shared/failure-copy.js` | A failed turn's sentence a person can act on (#1347): `failureSentence(raw, translate)` keys on the provider's own `code` field in the stored text — not on the message, which is not stable — and returns the mapped sentence (naming the feature in an `UnsupportedModel` refusal, and where to change the model), or the text unchanged when the code is not mapped, because a failure nobody has mapped reads better as it came than as a guess. `src/shared/turn-failures.js` and `src/client/inbox.js` both read it, so the chat's dead-turn lines and the Inbox card say the same thing. Tests: `core/test/ui-clickthrough.test.js`. |
@@ -608,6 +609,13 @@ card, so the header and the #1829 metrics read exactly as they did. Nothing else
blocked and a waiting card stay rows. The `project-board` endpoint carries the archive beside `tasks` as
`done`, which is what a page needs to fold months of history into one closed group instead of painting it.
An unreadable store still answers `counts` of `null`. Tests: `core/test/project-board.test.js`.
+The team dossier (#1864) is the same class of read: `project-team` takes `{ id }`, is dispatched by
+`channel.js` beside `project-board`, and sits in `authz.js`'s `TASK_ENDPOINTS` next to
+`project-read` — the owner decided that a Project's persona and memory are shared by its members.
+`core/src/host/project-team.js` builds the rows from the Project record and the Bot store, reads the
+memory head and topic names through `rt.memoryDigest`, and writes nothing; a Bot that was deleted is
+a `null` row, so one dead Chief does not take the rest of the answer down. Tests:
+`core/test/project-team.test.js`.
Beside the columns the board reads three groups (#1768), from the same pass and never a
second store: `waiting` is an open card with a `queueRole` and no claim, oldest wait first, carrying
diff --git a/patches/ds-bot/0018-project-dossier.patch b/patches/ds-bot/0018-project-dossier.patch
new file mode 100644
index 000000000..1956b8069
--- /dev/null
+++ b/patches/ds-bot/0018-project-dossier.patch
@@ -0,0 +1,21009 @@
+FlyBot patch 0018-project-dossier on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/).
+
+diff --git a/client.js b/client.js
+--- a/client.js
++++ b/client.js
+@@ -1450,6 +1450,15 @@ var ZH = {
+ "Role pool": "角色池",
+ "Can switch to {roles}": "可切换为 {roles}",
+ "No one in this pool yet.": "角色池里还没有人。",
++ // #1864: the Project team's dossier — persona, memory and skills are read-only in this slice.
++ "Dossier": "档案",
++ "Persona": "人设",
++ "Skills": "技能",
++ "No memory yet.": "还没有记忆。",
++ "No skills attached.": "还没有附加技能。",
++ "Open chat": "打开对话",
++ "Open my agent": "打开我的项目助手",
++ "Open {name}'s dossier": "打开 {name} 的档案",
+ "No cards yet.": "还没有卡片。",
+ "Open Projects in Settings": "在设置中打开项目"
+ };
+@@ -10378,10620 +10378,10789 @@ var BUSY_ACK_CSS = `
+ @media (prefers-reduced-motion:reduce){.bt-busy-dots>span{animation:none;opacity:.6}}
+ `;
+
+-// src/client/header.js
++// src/client/project-board.js
+ var import_react19 = require("react");
+-var import_react_dom6 = require("react-dom");
+-var GoArrow = () => (0, import_react19.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" }));
+-var MEMORY_NOTICE_MS = 3200;
+-var noNews = (select) => select({});
+-function useMemoryNotice(ids, useMemoryNews) {
+- const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0)));
+- const [shown, setShown] = (0, import_react19.useState)(false);
+- (0, import_react19.useEffect)(() => {
+- const left = seen + MEMORY_NOTICE_MS - Date.now();
+- setShown(seen > 0 && left > 0);
+- if (seen === 0 || left <= 0) return void 0;
+- const timer = setTimeout(() => setShown(false), left);
+- return () => clearTimeout(timer);
+- }, [seen]);
+- return shown;
+-}
+-function MemoryNotice({ shown }) {
+- const inner = (0, import_react19.useRef)(null);
+- const [width, setWidth] = (0, import_react19.useState)(0);
+- const label = t("Memory updated");
+- (0, import_react19.useLayoutEffect)(() => {
+- if (shown) setWidth(inner.current?.scrollWidth ?? 0);
+- }, [label, shown]);
+- return (0, import_react19.createElement)(
+- "span",
+- { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } },
+- (0, import_react19.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react19.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react19.createElement)(MemoryIcon)), label)
+- );
++var PROJECT_BOARD_CSS = `
++/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the
++ bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the
++ Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */
++.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
++.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))}
++.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto}
++.bt-project-board{padding:16px 18px}
++.bt-project-group{margin:0 0 14px}
++.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
++.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
++.bt-project-row[data-column="done"]{opacity:.7}
++.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
++.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
++.bt-project-empty{padding:22px 4px;opacity:.7}
++/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new
++ component language: a heading, a list, and a monospace hash the reader can compare. */
++.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
++.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
++.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55}
++.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
++.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
++.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
++.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-missing{font-size:11px;opacity:.6}
++.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
++/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
++ carries. A severe card is the only one painted louder, because it is the only one that waits. */
++.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
++.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
++.bt-project-recipients{font-size:11px;opacity:.65}
++.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
++.bt-project-holder{font-size:11px;opacity:.7}
++.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
++.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
++.bt-project-metrics{margin:0 0 14px}
++.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
++.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px}
++.bt-project-metric-value{font-size:18px;font-weight:600}
++.bt-project-metric-label{font-size:12px;font-weight:600}
++.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3}
++.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7}
++.bt-project-running{margin:0 0 14px}
++.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0}
++.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em}
++.bt-project-running-row[role="button"]{cursor:pointer}
++.bt-project-running-row[data-overdue="true"]{color:#c2410c}
++.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8}
++.bt-project-counts b{font-weight:600}
++.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default}
++.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto}
++.bt-project-state{font-size:11px;margin-left:auto}
++.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b}
++.bt-project-reason{font-size:11px;opacity:.7}
++
++/* #1855: the finished cards are one group of their own, closed until the reader opens it — a
++ Project with months of history reads as the work in flight, not as its archive. */
++.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
++.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none}
++.bt-project-done>summary::-webkit-details-marker{display:none}
++.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6}
++.bt-project-done[open]>summary::before{content:'\\25BE'}
++.bt-project-done[open]>summary{margin:0 0 8px}
++
++/* #1749: management panels. Same surface as the board, one form row per control, and a status
++ line that quotes the host's own answer — a refused save reads where the reader pressed it. */
++.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
++.bt-project-panel h3{margin:0}
++.bt-project-field{display:flex;flex-direction:column;gap:4px}
++.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
++.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
++.bt-project-field textarea{min-height:88px;resize:vertical}
++.bt-project-row-actions{display:flex;gap:8px;align-items:center}
++.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
++.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
++.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6}
++.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px}
++.bt-project-note{font-size:12px;opacity:.65;margin:0}
++
++/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool.
++ One quiet block above the cards, and a Bot row is a button because a reader is looking a
++ person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */
++.bt-project-team{margin:0 0 14px}
++.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
++.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
++.bt-project-team-row[data-open-bot]{cursor:pointer}
++.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
++.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px}
++.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
++.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
++.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
++
++/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the
++ owner's own chat header carries the same entry to the Project assistant's chat. */
++.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
++.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
++.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
++.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px}
++.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
++.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}
++.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7}
++.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap}
++.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))}
++.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
++.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
++`;
++var PROJECT_SOURCES = ["bot", "group", "workflow"];
++var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" };
++var text = (value) => typeof value === "string" ? value.trim() : "";
++var DONE_COLUMN = "done";
++var boardOfRow = (row, task) => {
++ if (task === void 0) return null;
++ if (row.source === "group") {
++ const id = text(task.origin?.roomId);
++ return id === "" ? null : { kind: "group", id };
++ }
++ if (row.source === "bot") {
++ const id = text(task.ownerBotId);
++ return id === "" ? null : { kind: "bot", id };
++ }
++ return null;
++};
++var decisionOf = (file) => {
++ if (file === null || typeof file !== "object") return null;
++ const path = text(file.path);
++ if (path === "") return null;
++ return {
++ key: `decision:${path}`,
++ path,
++ name: text(file.name) || path.slice(path.lastIndexOf("/") + 1),
++ date: text(file.date),
++ title: text(file.title),
++ sha256: text(file.sha256),
++ shortHash: text(file.sha256).slice(0, 12)
++ };
++};
++var meetingOf = (meeting, status) => {
++ if (meeting === null || typeof meeting !== "object") return null;
++ const id = text(meeting.id);
++ if (id === "") return null;
++ return {
++ key: `meeting:${id}`,
++ id,
++ date: text(meeting.date),
++ question: text(meeting.question) || text(meeting.title),
++ owner: text(meeting.owner) || null,
++ status,
++ path: text(meeting.path),
++ blockingQuestion: text(meeting.blockingQuestion) || null,
++ // #1831: the level and recipients the host answered, and the refusal to advance a severe card.
++ // Nothing is inferred here: a record written before the slice carries an ordinary card and no
++ // recipients, exactly as the host reads it.
++ level: text(meeting.level) || "ordinary",
++ recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [],
++ advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null },
++ // #1861: the host's participants with who each position speaks for, so the row can say a
++ // Project Chief or an agent held it for someone rather than for themselves.
++ participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : []
++ };
++};
++var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : [];
++function projectBoardRecord({ decisions, meetings } = {}) {
++ const dir = text(decisions?.dir) || null;
++ const files = listOf(decisions?.files);
++ const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : [];
++ const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : [];
++ return {
++ decisions: {
++ dir,
++ files,
++ count: files.length,
++ empty: files.length === 0,
++ emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`)
++ },
++ meetings: {
++ decided,
++ pending,
++ count: decided.length + pending.length,
++ empty: decided.length === 0 && pending.length === 0,
++ emptyText: text(meetings?.reason) || "No meeting of this Project is recorded."
++ }
++ };
+ }
+-function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
+- const roster = useRoster((value) => value);
+- const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? "");
+- const firstTurns = useSessions((list) => {
+- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+- return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : "";
+- });
+- const hiddenTurns = roster.exchangeTurns?.[sessionId];
+- const visit = useUi((state) => state.visits?.[sessionId]);
+- const opening = useTurnClock((state) => {
+- const first = openingTurn(state[sessionId], firstTurns, hiddenTurns);
+- return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : "";
++function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) {
++ const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== "";
++ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
++ const list = [];
++ const archived = [];
++ const seen = /* @__PURE__ */ new Set();
++ for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) {
++ if (!isRow(row)) continue;
++ const id = text(row.id);
++ if (seen.has(id)) continue;
++ seen.add(id);
++ const column = text(row.column) || "not-started";
++ (column === DONE_COLUMN ? archived : list).push(row);
++ }
++ const projectRow = (row) => {
++ const task = byId.get(text(row.id));
++ return {
++ id: text(row.id),
++ key: `task:${text(row.id)}`,
++ column: text(row.column) || "not-started",
++ assignee: text(row.assignee) || null,
++ runId: text(row.runId) || null,
++ nodeId: text(row.nodeId) || null,
++ title: text(task?.title) || text(task?.note) || null,
++ priority: text(task?.priority) || null,
++ audit: text(row.audit) || null,
++ // #1831: who is doing the card, and who is speaking for whom. Both are host truth;
++ // a card carrying neither renders exactly as before.
++ holder: text(row.holder) || null,
++ speaksFor: text(row.speaksFor) || null,
++ board: boardOfRow(row, task)
++ };
++ };
++ const groups = PROJECT_SOURCES.map((source) => ({
++ source,
++ label: SOURCE_LABELS[source],
++ rows: list.filter((row) => row.source === source).map(projectRow)
++ })).filter((group) => group.rows.length > 0);
++ const countOf = (value) => value === void 0 || value === null ? null : value;
++ const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => {
++ const id = text(item.id);
++ const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source);
++ const task = byId.get(id);
++ return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) };
+ });
+- (0, import_react19.useEffect)(() => {
+- if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening));
+- else document.body.style.removeProperty("--bt-first-sep");
+- return () => document.body.style.removeProperty("--bt-first-sep");
+- }, [opening]);
+- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
+- const owner = roster.byId[sessionId]?.id ?? sessionId;
+- const doing = useActivity((map) => map[owner]);
+- const bot = roster.byId[sessionId];
+- const room2 = roster.roomsById[sessionId];
+- const detailsOpen = useUi((state) => state.details === sessionId);
+- const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
+- const face = room2?.origin === "face-to-face";
+- const name = face ? faceRowTitle(full2) : full2;
+- const useNews = useMemoryNews ?? noNews;
+- const memoryIds = bot ? [bot.id] : room2?.members ?? [];
+- const notice = useMemoryNotice(memoryIds, useNews);
+- const changed2 = useNews((map) => latestMemoryChange(map, memoryIds));
+- const memoryNotice = notice && changed2 !== null && roster.memory !== false;
++ const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
++ const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" }));
++ const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null }));
++ const queueGroups = [
++ { group: "waiting", label: "Waiting", items: waiting },
++ { group: "inflight", label: "In flight", items: inflight },
++ { group: "blocked", label: "Blocked", items: blocked }
++ ].filter((entry) => entry.items.length > 0);
++ const rawCounts = queues?.counts ?? null;
++ return {
++ projectId: text(project?.id) || null,
++ title: text(project?.name) || "Project",
++ canManage: canManage === true,
++ groups,
++ // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a
++ // finished card is still a card — only its place on the board changed.
++ done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) },
++ queues: queueGroups,
++ counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) },
++ count: list.length,
++ empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0,
++ emptyText: "No cards yet.",
++ record: projectBoardRecord({ decisions, meetings }),
++ metrics: metricsView(metrics, { tasks })
++ };
++}
++function projectRowNode(row, onOpen) {
++ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+ return (0, import_react19.createElement)(
+ "div",
+- { className: "bt-head" },
++ {
++ key: row.key,
++ className: "bt-project-row",
++ "data-task-id": row.id,
++ "data-column": row.column,
++ "data-priority": row.priority ?? void 0,
++ role: open === void 0 ? void 0 : "button",
++ tabIndex: open === void 0 ? void 0 : 0,
++ onClick: open,
++ onKeyDown: open === void 0 ? void 0 : (event) => {
++ if (event.key === "Enter" || event.key === " ") open();
++ }
++ },
++ (0, import_react19.createElement)("span", { className: "bt-project-column" }, t(row.column)),
+ (0, import_react19.createElement)(
+- "button",
+- {
+- type: "button",
+- className: "bt-pill",
+- "aria-expanded": detailsOpen,
+- "data-memory": memoryNotice ? "updated" : void 0,
+- "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"),
+- onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId)
+- },
+- bot ? (0, import_react19.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react19.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null,
+- (0, import_react19.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")),
+- (0, import_react19.createElement)(MemoryNotice, { shown: memoryNotice }),
+- (0, import_react19.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react19.createElement)(GoArrow))
++ "span",
++ { className: "bt-project-title-wrap" },
++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
++ // The audit text the row already carries (#1730): shown inside the card detail, never a
++ // second read. A row without one renders exactly as before.
++ row.audit === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-audit" }, row.audit)
+ ),
+- (0, import_react19.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""),
+- (0, import_react19.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
+- (0, import_react19.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
+- (0, import_react19.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive })
++ // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
++ // says so, so the three identities (Bot, Assistant, user) never read as one.
++ row.holder === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`),
++ row.speaksFor === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })),
++ row.priority === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-priority" }, row.priority),
++ row.runId === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
+ );
+ }
+-function useTranscriptView() {
+- const [view, setView] = (0, import_react19.useState)(null);
+- (0, import_react19.useEffect)(() => {
+- let frame = 0;
+- let watched = null;
+- const observer = new MutationObserver(() => schedule());
+- const measure = () => {
+- frame = 0;
+- const scroller = document.querySelector("[data-conversation-scroll]");
+- if (scroller !== watched) {
+- observer.disconnect();
+- watched = scroller;
+- if (scroller) observer.observe(scroller, { childList: true, subtree: true });
+- }
+- if (!scroller) {
+- setView(null);
+- return;
+- }
+- const box = scroller.getBoundingClientRect();
+- const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50;
+- const sep = scroller.querySelector(".bt-new-sep");
+- let divider = null;
+- if (sep) {
+- const at = sep.getBoundingClientRect();
+- divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view";
+- }
+- const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152;
+- const next = {
+- unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
+- divider,
+- center: Math.round(box.left + box.width / 2),
+- top: Math.round(headerBottom + 8),
+- bottom: Math.round(window.innerHeight - box.bottom + composer + 8)
+- };
+- setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next);
+- };
+- const schedule = () => {
+- if (!frame) frame = requestAnimationFrame(measure);
+- };
+- document.addEventListener("scroll", schedule, true);
+- window.addEventListener("resize", schedule);
+- schedule();
+- return () => {
+- cancelAnimationFrame(frame);
+- observer.disconnect();
+- document.removeEventListener("scroll", schedule, true);
+- window.removeEventListener("resize", schedule);
+- };
+- }, []);
+- return view;
++function ProjectBoardRows({ view, onOpen }) {
++ if (view.empty) return (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText));
++ return view.groups.map((group) => (0, import_react19.createElement)(
++ "section",
++ { key: group.source, className: "bt-project-group", "data-source": group.source },
++ (0, import_react19.createElement)("h4", null, t(group.label)),
++ group.rows.map((row) => projectRowNode(row, onOpen))
++ ));
+ }
+-function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) {
+- const replies = useSessions((list) => {
+- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+- return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0;
+- });
+- const visit = useUi((state) => state.visits?.[sessionId]);
+- const unread = useTurnClock((state) => {
+- if (typeof visit?.seen !== "number") return 0;
+- let count = 0;
+- for (const entry of Object.values(state[sessionId] ?? {})) {
+- if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1;
+- }
+- return count;
+- });
+- const view = useTranscriptView();
+- const unpinned = view?.unpinned === true;
+- const [release, setRelease] = (0, import_react19.useState)({ base: null, dismissed: false });
+- const [above, setAbove] = (0, import_react19.useState)({ seen: false, dismissed: false });
+- (0, import_react19.useEffect)(() => {
+- setRelease({ base: unpinned ? replies : null, dismissed: false });
+- }, [unpinned]);
+- (0, import_react19.useEffect)(() => {
+- if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true });
+- }, [view?.divider]);
+- const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns));
+- (0, import_react19.useEffect)(() => {
+- if (above.seen && movedOn) actions?.settleVisit?.(sessionId);
+- }, [above.seen, movedOn, sessionId]);
+- const fresh = release.base === null ? 0 : replies - release.base;
+- const showDown = unpinned && fresh > 0 && !release.dismissed;
+- const showUp = view?.divider === "above" && !above.seen && !above.dismissed;
+- (0, import_react19.useEffect)(() => {
+- const scroller = document.querySelector("[data-conversation-scroll]");
+- if (!scroller) return void 0;
+- scroller.toggleAttribute("data-bt-news", showDown);
+- return () => scroller.removeAttribute("data-bt-news");
+- }, [showDown]);
+- if (!view || !showDown && !showUp) return null;
+- const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages");
+- const pill = (direction, count, style, onJump, onDismiss) => (0, import_react19.createElement)(
+- "div",
+- { className: "bt-news", "data-direction": direction, style },
+- (0, import_react19.createElement)(
+- "button",
+- { type: "button", className: "bt-news-jump", onClick: onJump },
+- (0, import_react19.createElement)("span", { className: "bt-news-ico" }, (0, import_react19.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)),
+- label(count)
+- ),
+- (0, import_react19.createElement)("button", {
+- type: "button",
+- className: "bt-news-x",
+- "aria-label": t("Dismiss new messages"),
+- onClick: (event) => {
+- event.stopPropagation();
+- onDismiss();
+- }
+- }, (0, import_react19.createElement)(CloseIcon))
+- );
+- const toLatest = () => {
+- const scroller = document.querySelector("[data-conversation-scroll]");
+- const back = scroller?.querySelector('[class*="_toBottomSlot"]>button');
+- if (back) back.click();
+- else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
+- };
+- const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" });
+- return (0, import_react_dom6.createPortal)(
+- (0, import_react19.createElement)(
+- import_react19.Fragment,
+- null,
+- showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null,
+- showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null
+- ),
+- document.body
++function ProjectDoneGroup({ view, onOpen }) {
++ if (view.done.rows.length === 0) return null;
++ return (0, import_react19.createElement)(
++ "details",
++ { className: "bt-project-group bt-project-done", "data-group": "done" },
++ (0, import_react19.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`),
++ view.done.rows.map((row) => projectRowNode(row, onOpen))
+ );
+ }
+-
+-// src/client/composer-label.js
+-var ATTRIBUTES = ["aria-label", "data-placeholder"];
+-function holdComposerLabel(doc, label) {
+- doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label));
+- const shell = /* @__PURE__ */ new Map();
+- const apply2 = () => {
+- for (const el of doc.querySelectorAll("[data-composer-input]")) {
+- for (const name of ATTRIBUTES) {
+- const current = el.getAttribute(name);
+- if (current === label) continue;
+- if (!shell.has(el)) shell.set(el, {});
+- shell.get(el)[name] = current;
+- el.setAttribute(name, label);
+- }
+- }
+- };
+- apply2();
+- const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
+- const watcher = typeof Observer === "function" ? new Observer(apply2) : null;
+- watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES });
+- return () => {
+- watcher?.disconnect();
+- for (const [el, values] of shell) {
+- if (!el.isConnected) continue;
+- for (const [name, value] of Object.entries(values)) {
+- if (value == null) el.removeAttribute(name);
+- else el.setAttribute(name, value);
+- }
+- }
+- };
++function waitedLabel(ms) {
++ if (!Number.isFinite(ms) || ms < 0) return null;
++ const minutes = Math.floor(ms / 6e4);
++ if (minutes < 60) return `${minutes}m`;
++ const hours = Math.floor(minutes / 60);
++ if (hours < 24) return `${hours}h`;
++ return `${Math.floor(hours / 24)}d`;
+ }
+-
+-// src/client/header-effects.js
+-function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) {
+- if (!doc) return () => {
+- };
+- const slots = /* @__PURE__ */ new Map();
+- const run = (id, dep, setup) => {
+- const held = slots.get(id);
+- if (held !== void 0 && held.dep === dep) return;
+- held?.off();
+- slots.set(id, { dep, off: setup() ?? (() => {
+- }) });
+- };
+- let running = false;
+- let again = false;
+- const pass = () => {
+- const list = sessions.getSnapshot();
+- const state = roster.getSnapshot();
+- const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0);
+- const sessionId = row?.id;
+- const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId];
+- const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId];
+- const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
+- run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => {
+- if (!bot?.remote) return void 0;
+- doc.body.dataset.btFriendChat = "";
+- return () => {
+- delete doc.body.dataset.btFriendChat;
+- };
+- });
+- run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => {
+- if (!bot?.remote || row?.blank !== true) return void 0;
+- doc.body.dataset.btRemoteChat = "empty";
+- doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on.")));
+- return () => {
+- delete doc.body.dataset.btRemoteChat;
+- doc.body.style.removeProperty("--bt-remote-empty");
+- };
+- });
+- run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message")));
+- run("visit", sessionId, () => {
+- if (sessionId === void 0) return void 0;
+- actions.beginVisit(sessionId);
+- return () => actions.endVisit(sessionId);
+- });
+- run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => {
+- if (room2?.origin !== "face-to-face") return void 0;
+- const stop = actions.filterFaceAtMenu?.(sessionId);
+- return typeof stop === "function" ? stop : void 0;
+- });
+- run("room-flag", `${sessionId}|${room2 !== void 0}`, () => {
+- if (room2 === void 0) return void 0;
+- doc.body.dataset.btRoom = "";
+- return () => {
+- delete doc.body.dataset.btRoom;
+- };
+- });
+- };
+- const apply2 = () => {
+- if (running) {
+- again = true;
+- return;
+- }
+- running = true;
+- try {
+- pass();
+- } finally {
+- running = false;
+- }
+- if (again) {
+- again = false;
+- apply2();
+- }
+- };
+- apply2();
+- const offSessions = sessions.subscribe(apply2);
+- const offRoster = roster.subscribe(apply2);
+- return () => {
+- offSessions?.();
+- offRoster?.();
+- for (const held of slots.values()) held.off();
+- slots.clear();
+- };
+-}
+-
+-// src/client/composer.js
+-var import_react20 = require("react");
+-var import_react_dom7 = require("react-dom");
+-var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition;
+-var keepComposerFocus = (event) => event.preventDefault();
+-function useDictation(onText) {
+- const [listening, setListening] = (0, import_react20.useState)(false);
+- const [note, setNote] = (0, import_react20.useState)("");
+- const recognition = (0, import_react20.useRef)(null);
+- const latest = (0, import_react20.useRef)(onText);
+- latest.current = onText;
+- (0, import_react20.useEffect)(() => () => recognition.current?.abort(), []);
+- (0, import_react20.useEffect)(() => {
+- if (!note) return void 0;
+- const timer = setTimeout(() => setNote(""), 2600);
+- return () => clearTimeout(timer);
+- }, [note]);
+- const start = ({ continuous = true, onEnd } = {}) => {
+- const Api = speechApi();
+- if (!Api) {
+- setNote(t("Dictation is not available in this browser"));
+- return false;
+- }
+- const engine = new Api();
+- engine.lang = navigator.language || "zh-CN";
+- engine.interimResults = true;
+- engine.continuous = continuous;
+- engine.onresult = (event) => {
+- let interim = "";
+- for (let index = event.resultIndex; index < event.results.length; index += 1) {
+- const result = event.results[index];
+- const text2 = result[0]?.transcript ?? "";
+- if (result.isFinal) {
+- if (text2.trim()) latest.current(text2, true);
+- } else interim += text2;
+- }
+- if (interim) latest.current(interim, false);
+- };
+- engine.onerror = (event) => {
+- if (event.error === "aborted" || event.error === "no-speech") return;
+- setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that"));
+- };
+- engine.onend = () => {
+- setListening(false);
+- recognition.current = null;
+- onEnd?.();
++var METRIC_CARDS = [
++ { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." },
++ { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." },
++ { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." },
++ { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." },
++ { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." },
++ { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." },
++ { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." }
++];
++var METRIC_DASH = "—";
++var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]);
++var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3;
++var metricText = (key, value) => {
++ if (!Number.isFinite(value)) return METRIC_DASH;
++ if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH;
++ return String(value);
++};
++function metricsView(metrics, { tasks } = {}) {
++ const source = metrics !== null && typeof metrics === "object" ? metrics : null;
++ const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {};
++ const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
++ const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => {
++ const id = text(row.id);
++ const task = byId.get(id);
++ return {
++ id,
++ key: "task:" + id,
++ title: text(row.title) || text(task?.title) || text(task?.note) || id,
++ status: text(row.status),
++ who: text(row.who) || null,
++ sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null,
++ source: text(row.source) || null,
++ lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null,
++ overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS,
++ board: boardOfRow({ source: text(row.source) }, task)
+ };
+- recognition.current = engine;
+- try {
+- engine.start();
+- setListening(true);
+- return true;
+- } catch {
+- setNote(t("Couldn't start dictation"));
+- return false;
+- }
++ });
++ return {
++ available: source?.ok === true,
++ reason: text(source?.reason) || null,
++ cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })),
++ running,
++ empty: source === null,
++ emptyText: "No metrics read yet."
+ };
+- const stop = () => recognition.current?.stop();
+- return { listening, note, start, stop };
+ }
+-function ComposerIdentity({ sessionId, useRoster }) {
+- const roster = useRoster((value) => value);
+- const room2 = roster.roomsById?.[sessionId];
+- const self = roster.self;
+- const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0);
+- if (room2?.viewerRole === "invited-owner") {
+- return (0, import_react20.createElement)(
+- "span",
+- { className: "bt-composer-as", title: t("You can remove your Bot from this room") },
+- t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") })
+- );
+- }
+- if (!self || !room2 || humans.length < 2) return null;
+- return (0, import_react20.createElement)(
+- "span",
+- { className: "bt-composer-as", title: t("Messages in this room are attributed to you") },
+- t("Sending as {name}", { name: self.displayName || self.memberId })
++function ProjectMetrics({ view }) {
++ return (0, import_react19.createElement)(
++ "section",
++ { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" },
++ view.available || view.reason === null ? null : (0, import_react19.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-metric-cards" },
++ view.cards.map((card) => (0, import_react19.createElement)(
++ "div",
++ { key: card.key, className: "bt-project-metric", "data-metric": card.key },
++ (0, import_react19.createElement)("span", { className: "bt-project-metric-value" }, card.value),
++ (0, import_react19.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)),
++ (0, import_react19.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption))
++ ))
++ )
+ );
+ }
+-function ComposerVoice({ sessionId, inputActions, actions, useUi }) {
+- const dictation = useDictation((text2, final) => {
+- if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion());
+- });
+- const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null);
+- (0, import_react20.useEffect)(() => {
+- if (!quote || !inputActions) return;
+- actions.clearQuote(quote.token);
+- const excerpt = quote.text.replace(/\s+/g, " ").trim();
+- const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt}
+-
+-`;
+- inputActions.insertText(line2, inputActions.captureInsertion());
+- const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]');
+- if (box instanceof HTMLElement) {
+- box.focus();
+- const selection = window.getSelection();
+- selection?.selectAllChildren(box);
+- selection?.collapseToEnd();
+- }
+- }, [quote?.token]);
+- if (sessionId === void 0) return null;
+- const pluginMic = actions.speech() !== void 0;
+- return (0, import_react20.createElement)(
+- import_react20.Fragment,
+- null,
+- pluginMic ? null : (0, import_react20.createElement)("button", {
+- type: "button",
+- className: "bt-mic",
+- "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"),
+- "aria-pressed": dictation.listening,
+- title: dictation.listening ? t("Stop dictation") : t("Dictate"),
+- onMouseDown: keepComposerFocus,
+- onClick: () => dictation.listening ? dictation.stop() : dictation.start()
+- }, (0, import_react20.createElement)(MicIcon)),
+- (0, import_react20.createElement)("button", {
+- type: "button",
+- className: "bt-voice",
+- "aria-label": t("Start voice chat"),
+- title: t("Voice chat"),
+- onMouseDown: keepComposerFocus,
+- onClick: () => actions.openVoice(sessionId)
+- }, (0, import_react20.createElement)(WaveIcon, { size: 18 })),
+- dictation.note ? (0, import_react20.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null
++function ProjectRunningTable({ view, onOpen }) {
++ if (view.running.length === 0) return (0, import_react19.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react19.createElement)("h4", null, t("Running tasks")), (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running.")));
++ return (0, import_react19.createElement)(
++ "section",
++ { className: "bt-project-running", "data-running": String(view.running.length) },
++ (0, import_react19.createElement)("h4", null, t("Running tasks")),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-running-head" },
++ (0, import_react19.createElement)("span", null, t("Task")),
++ (0, import_react19.createElement)("span", null, t("Status")),
++ (0, import_react19.createElement)("span", null, t("Who")),
++ (0, import_react19.createElement)("span", null, t("Claimed for")),
++ (0, import_react19.createElement)("span", null, t("Source")),
++ (0, import_react19.createElement)("span", null, t("Last activity"))
++ ),
++ view.running.map((row) => {
++ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
++ return (0, import_react19.createElement)(
++ "div",
++ {
++ key: row.key,
++ className: "bt-project-running-row",
++ "data-task-id": row.id,
++ "data-overdue": row.overdue ? "true" : "false",
++ role: open === void 0 ? void 0 : "button",
++ tabIndex: open === void 0 ? void 0 : 0,
++ onClick: open
++ },
++ (0, import_react19.createElement)("span", { className: "bt-project-running-title" }, row.title),
++ (0, import_react19.createElement)("span", { className: "bt-project-running-status" }, t(row.status)),
++ (0, import_react19.createElement)("span", null, row.who ?? METRIC_DASH),
++ (0, import_react19.createElement)("span", null, metricText("since", row.sinceMs)),
++ (0, import_react19.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)),
++ (0, import_react19.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale()))
++ );
++ })
+ );
+ }
+-function menuIcons(roster) {
+- const marks = /* @__PURE__ */ new Map();
+- return (row) => {
+- if (!row.bot) return PeopleIcon;
+- const id = row.bot.id;
+- if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react20.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size }));
+- return marks.get(id);
+- };
++function ProjectQueueGroups({ view, onOpen }) {
++ return view.queues.map((entry) => (0, import_react19.createElement)(
++ "section",
++ { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group },
++ (0, import_react19.createElement)("h4", null, t(entry.label)),
++ entry.items.map((row) => {
++ const open = row.board === null ? void 0 : () => onOpen?.(row.board);
++ const note = entry.group === "waiting" ? (0, import_react19.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react19.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react19.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react19.createElement)("span", { className: "bt-project-reason" }, row.reason);
++ return (0, import_react19.createElement)(
++ "div",
++ {
++ key: row.key,
++ className: "bt-project-row",
++ "data-task-id": row.id,
++ "data-state": row.state ?? void 0,
++ role: open === void 0 ? void 0 : "button",
++ tabIndex: open === void 0 ? void 0 : 0,
++ onClick: open,
++ onKeyDown: open === void 0 ? void 0 : (event) => {
++ if (event.key === "Enter" || event.key === " ") open();
++ }
++ },
++ (0, import_react19.createElement)(
++ "span",
++ { className: "bt-project-title-wrap" },
++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`)
++ ),
++ note
++ );
++ })
++ ));
+ }
+-function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
+- const roster = useRoster((value) => value);
+- const [open, setOpen] = (0, import_react20.useState)(null);
+- const [shown, leaving] = useLinger(open, 120);
+- const button = (0, import_react20.useRef)(null);
+- const menu = (0, import_react20.useRef)(null);
+- (0, import_react20.useEffect)(() => {
+- if (!open) return void 0;
+- const onDown = (event) => {
+- if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null);
+- };
+- const onKey = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- setOpen(null);
+- }
+- };
+- window.addEventListener("mousedown", onDown, true);
+- window.addEventListener("keydown", onKey, true);
+- return () => {
+- window.removeEventListener("mousedown", onDown, true);
+- window.removeEventListener("keydown", onKey, true);
+- };
+- }, [open]);
+- const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id;
+- if (sessionId === void 0 || target === void 0) return null;
+- const toggle = () => {
+- if (open) {
+- setOpen(null);
+- return;
+- }
+- const rect = button.current.getBoundingClientRect();
+- setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 });
+- };
+- const attach = () => {
+- setOpen(null);
+- button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click();
+- };
+- const schedule = () => {
+- setOpen(null);
+- actions.openSettings("schedules", target);
++var countText = (value) => value === null ? t("unknown") : String(value);
++function ProjectBoardRecord({ record }) {
++ const { decisions, meetings } = record;
++ const decisionRows = decisions.files.map((file) => (0, import_react19.createElement)(
++ "div",
++ { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 },
++ (0, import_react19.createElement)("span", { className: "bt-project-date" }, file.date),
++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
++ (0, import_react19.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
++ ));
++ const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", ");
++ const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", ");
++ const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react19.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting));
++ const levelOf = (meeting) => (0, import_react19.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level));
++ const decidedRows = meetings.decided.map((meeting) => (0, import_react19.createElement)(
++ "div",
++ { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" },
++ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date),
++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.question),
++ meeting.owner === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`),
++ proxyRow(meeting),
++ // A decided meeting whose file is gone still reads as decided; the missing path says so
++ // instead of the row disappearing with the file.
++ meeting.path === "" ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react19.createElement)("span", { className: "bt-project-path" }, meeting.path)
++ ));
++ const pendingRows = meetings.pending.map((meeting) => (0, import_react19.createElement)(
++ "div",
++ { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level },
++ (0, import_react19.createElement)("span", { className: "bt-project-date" }, meeting.date),
++ (0, import_react19.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion),
++ levelOf(meeting),
++ recipientText(meeting.recipients) === null ? null : (0, import_react19.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)),
++ meeting.advance.allowed ? null : (0, import_react19.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason),
++ proxyRow(meeting),
++ (0, import_react19.createElement)("span", { className: "bt-project-status" }, meeting.status)
++ ));
++ return (0, import_react19.createElement)(
++ "section",
++ { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) },
++ (0, import_react19.createElement)("h4", null, t("Decisions")),
++ decisions.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows,
++ (0, import_react19.createElement)("h4", null, t("Meetings")),
++ meetings.empty ? (0, import_react19.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows,
++ meetings.pending.length === 0 ? null : (0, import_react19.createElement)("h5", null, t("Not decided")),
++ meetings.pending.length === 0 ? null : pendingRows
++ );
++}
++var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : "";
++var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : [];
++function projectTeamView(project) {
++ const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {};
++ const owner = [];
++ const addOwner = (id) => {
++ const clean = text(id);
++ if (clean !== "" && !owner.includes(clean)) owner.push(clean);
+ };
+- const reference = () => {
+- setOpen(null);
+- actions.referenceChat(sessionId, inputActions?.captureInsertion?.());
++ addOwner(project?.ownerUserId);
++ for (const id of idList(project?.ownerUserIds)) addOwner(id);
++ const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id));
++ const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id));
++ return {
++ chief: botIdOf(project?.chief),
++ agent: botIdOf(project?.assistant),
++ roles: { owner, maintainers, members },
++ empty: owner.length === 0 && maintainers.length === 0 && members.length === 0,
++ // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the
++ // `project-read` record answers no rank, and the panel never re-derives the host's policy.
++ role: text(project?.role),
++ choices: idList(project?.choices)
+ };
+- const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat();
+- return (0, import_react20.createElement)(
+- import_react20.Fragment,
+- null,
+- (0, import_react20.createElement)("button", {
+- ref: button,
+- type: "button",
+- className: "bt-plus",
+- "aria-label": t("Add"),
+- title: t("Add"),
+- "aria-haspopup": "menu",
+- "aria-expanded": Boolean(open),
+- onMouseDown: keepComposerFocus,
+- onClick: toggle
+- }, (0, import_react20.createElement)(PlusIcon)),
+- shown ? (0, import_react_dom7.createPortal)((0, import_react20.createElement)(
++}
++var teamRow = (role, label, botId, onOpenBot, onOpenDossier) => (0, import_react19.createElement)(
++ "div",
++ {
++ key: role,
++ className: "bt-project-team-row",
++ "data-team-role": role,
++ // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
++ ...botId === "" ? { "data-team-state": "not-set" } : {
++ "data-open-bot": botId,
++ role: "button",
++ tabIndex: 0,
++ "aria-label": t("Open {name}'s chat", { name: botId }),
++ onClick: () => onOpenBot?.(botId),
++ onKeyDown: (event) => {
++ if (event.key === "Enter" || event.key === " ") {
++ event.preventDefault();
++ onOpenBot?.(botId);
++ }
++ }
++ }
++ },
++ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, label),
++ botId === "" ? (0, import_react19.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react19.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`),
++ // #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a
++ // reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set".
++ (0, import_react19.createElement)("button", {
++ key: "dossier",
++ type: "button",
++ className: "bt-project-team-dossier",
++ "data-dossier-role": role,
++ ...botId === "" ? {} : { "data-dossier-bot": botId },
++ "aria-label": t("Open {name}'s dossier", { name: botId === "" ? label : botId }),
++ onClick: (event) => {
++ event?.stopPropagation();
++ onOpenDossier?.({ role, label, botId });
++ },
++ onKeyDown: (event) => {
++ if (event.key === "Enter" || event.key === " ") {
++ event?.stopPropagation();
++ onOpenDossier?.({ role, label, botId });
++ }
++ }
++ }, t("Dossier"))
++);
++var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]];
++function ProjectTeam({ project, onOpenBot, onOpenDossier }) {
++ const view = projectTeamView(project);
++ const poolId = (id) => (0, import_react19.createElement)("button", {
++ key: id,
++ type: "button",
++ className: "bt-project-team-dossier",
++ "data-dossier-role": "pool",
++ "data-dossier-bot": id,
++ "aria-label": t("Open {name}'s dossier", { name: id }),
++ onClick: (event) => {
++ event?.stopPropagation();
++ onOpenDossier?.({ role: "pool", label: t("Role pool"), botId: id });
++ }
++ }, `@${id}`);
++ const ranks = view.empty ? (0, import_react19.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react19.createElement)(
++ "span",
++ { className: "bt-project-team-roles" },
++ POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react19.createElement)(
++ "span",
++ { key: role, className: "bt-project-team-role", "data-pool-role": role },
++ `${roleText(role)}: `,
++ view.roles[key].map((id) => poolId(id))
++ ))
++ );
++ return (0, import_react19.createElement)(
++ "section",
++ { className: "bt-project-team", "data-team": "chief,agent,pool" },
++ (0, import_react19.createElement)("h4", null, t("Project team")),
++ teamRow("chief", t("Project Chief"), view.chief, onOpenBot, onOpenDossier),
++ teamRow("agent", t("My agent"), view.agent, onOpenBot, onOpenDossier),
++ (0, import_react19.createElement)(
+ "div",
+- {
+- ref: menu,
+- className: "bt-menu bt-menu-up bt-plus-menu",
+- role: "menu",
+- "aria-label": t("Add"),
+- style: { left: shown.left, bottom: shown.bottom },
+- "data-leaving": leaving || void 0
+- },
+- (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react20.createElement)(PaperclipIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Add files"))),
+- canReference ? (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react20.createElement)(ChatsIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null,
+- (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react20.createElement)(ClockIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task")))
+- ), document.body) : null
++ { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" },
++ (0, import_react19.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")),
++ ranks,
++ // The caller's own rank and what the host said they may switch to: the same answer the
++ // sidebar menu reads, printed where the pool is.
++ view.role === "" ? null : (0, import_react19.createElement)(
++ "span",
++ { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role },
++ [
++ t("My role"),
++ ": ",
++ roleText(view.role),
++ view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}`
++ ]
++ )
++ )
+ );
+ }
+-
+-// src/client/at-menu.js
+-var BOTS_SOURCE = "ds-bot";
+-var CHATS_SOURCE = "ds-bot-chats";
+-var ROOM_ALL = ["all", "everyone", "所有人", "全体"];
+-var OTHER_CHATS_MAX = 8;
+-var conversationOf = (roster, sessionId) => {
+- if (!roster.ready) return null;
+- const room2 = roster.roomsById[sessionId];
+- if (room2) return { room: room2 };
+- const self = roster.byId[sessionId];
+- return self ? { self } : null;
+-};
+-function mentionRows(roster, sessionId, query = "") {
+- const here = conversationOf(roster, sessionId);
+- if (here === null) return null;
+- const face = isFaceRoom(here.room);
+- const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0);
+- const bots = here.room ? [
+- ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot),
+- ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
+- // A remote seat already listed under its handle (a duplicate participant record) is offered once.
+- ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
+- id: p.handle || p.id,
+- // A face room inserts the disambiguated title. An ordinary group inserts the name the
+- // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
+- name: face ? remoteBotTitle(p) : remoteCallName(p),
+- kind: "bot",
+- remote: p,
+- handle: p.handle,
+- label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p)
+- }))
+- ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
+- const needle = query.trim().toLowerCase();
+- const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` }));
+- const everyone = t("Everyone");
+- if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) {
+- rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` });
+- }
+- if (face) return rows;
+- const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean));
+- if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))];
+- const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle)));
+- return [
+- ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))),
+- ...remoteMentionRows(roster, query)
+- ];
++function dossierRowOf(team, botId) {
++ const id = text(botId);
++ if (id === "") return null;
++ const rows = Array.isArray(team?.rows) ? team.rows : [];
++ return rows.find((row) => row !== null && typeof row === "object" && text(row.botId) === id) ?? null;
+ }
+-var isFaceRoom = (room2) => room2?.origin === "face-to-face";
+-var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count });
+-var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count });
+-var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
+-var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null;
+-var myOwnerName = (roster, live, bot) => {
+- const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle));
+- return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, "");
+-};
+-var uniqueBy = (list, key) => {
+- const seen = /* @__PURE__ */ new Set();
+- return list.filter((item) => {
+- const k = key(item);
+- if (!k || seen.has(k)) return !k;
+- seen.add(k);
+- return true;
+- });
+-};
+-var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot;
+-function chatRows(roster, list, sessionId) {
+- const here = conversationOf(roster, sessionId);
+- if (here === null) return [];
+- const byId = list?.byId ?? {};
+- const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => {
+- const id = currentPart(bot);
+- return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt };
+- });
+- const team = /* @__PURE__ */ new Set([
+- ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]),
+- ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []])
+- ]);
+- const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt }));
+- return [...bots, ...others];
+-}
+-function sessionMention(sessionId, label) {
+- let binary = "";
+- for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte);
+- const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+- return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`;
++var dossierSection = (title, children) => (0, import_react19.createElement)(
++ "div",
++ { key: title, className: "bt-project-dossier-section" },
++ (0, import_react19.createElement)("h4", null, title),
++ children
++);
++function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) {
++ const known = row !== null && typeof row === "object";
++ const botId = known ? text(row.botId) : "";
++ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map((name) => text(name)).filter((name) => name !== "") : [];
++ const skills = known && Array.isArray(row.skills) ? row.skills.map((name) => text(name)).filter((name) => name !== "") : [];
++ const meta = known ? [text(row.role), text(row.model)].filter((value) => value !== "") : [];
++ return (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-panel bt-project-dossier", "data-panel": "project-dossier", "data-dossier-bot": botId === "" ? void 0 : botId },
++ (0, import_react19.createElement)("h3", null, label === "" || label === void 0 ? t("Dossier") : `${label} · ${t("Dossier")}`),
++ known ? [
++ dossierSection(t("Persona"), [
++ meta.length === 0 ? null : (0, import_react19.createElement)("div", { key: "meta", className: "bt-project-dossier-meta" }, meta.map((value) => (0, import_react19.createElement)("span", { key: value }, value))),
++ (0, import_react19.createElement)("p", { key: "persona", className: "bt-project-dossier-text" }, text(row.persona) || t("Not set"))
++ ]),
++ dossierSection(t("Memory"), [
++ (0, import_react19.createElement)("p", { key: "head", className: "bt-project-dossier-text" }, text(row.memory?.head) || t("No memory yet.")),
++ topics.length === 0 ? null : (0, import_react19.createElement)("div", { key: "topics", className: "bt-project-dossier-topics" }, topics.map((topic) => (0, import_react19.createElement)("span", { key: topic, className: "bt-project-dossier-topic" }, topic)))
++ ]),
++ dossierSection(t("Skills"), (0, import_react19.createElement)("p", { key: "skills", className: "bt-project-dossier-text" }, skills.length === 0 ? t("No skills attached.") : skills.join(", ")))
++ ] : (0, import_react19.createElement)("p", { className: "bt-project-missing" }, t("Not set")),
++ botId === "" || !known ? null : (0, import_react19.createElement)(
++ "div",
++ { key: "actions", className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("button", {
++ type: "button",
++ className: "bt-soft bt-soft-sm",
++ "data-open-bot": botId,
++ onClick: () => onOpenBot?.(botId)
++ }, t("Open chat"))
++ ),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
++ )
++ );
+ }
+-function atSources({ roster, sessions, launched, iconFor }) {
+- const bots = {
+- trigger: "@",
+- name: BOTS_SOURCE,
+- order: -20,
+- showGroupTitle: false,
+- async candidates(session, { query }) {
+- const snapshot = roster.getSnapshot();
+- const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
+- const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
+- // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
+- // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
+- // row and header (#498).
+- name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"),
+- ...row.bot?.role && !face ? { description: row.bot.role } : {},
+- ...row.remote ? { description: row.remote.handle } : {},
+- ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {},
+- // A face room counts the Bots in it, mine and everyone else's (#305, #457).
+- ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {},
+- icon: iconFor(row),
+- section: t("Bots"),
+- value: row.text
+- }));
+- if (mentionRows(snapshot, session.sessionId, "") === null || face) return team;
+- const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({
+- name: row.name,
+- ...row.description ? { description: row.description } : {},
+- icon: row.icon ?? "session",
+- section: row.section ?? "FlyBot",
+- value: row.value
+- }));
+- return [...team, ...extra];
+- },
+- onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0
+- };
+- const chats = {
+- trigger: "@",
+- name: CHATS_SOURCE,
+- order: -10,
+- showGroupTitle: false,
+- async candidates(session) {
+- if (!launched(session.sessionId)) return [];
+- return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({
+- name: row.bot ? row.bot.name : row.label,
+- ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {},
+- icon: row.bot ? iconFor(row) : "session",
+- section: row.bot ? t("Bots") : t("Conversations"),
+- value: JSON.stringify({ sessionId: row.sessionId, label: row.label })
+- }));
+- },
+- onPick({ candidate }) {
+- const { sessionId, label } = JSON.parse(candidate.value);
+- const mention = sessionMention(sessionId, label);
+- return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } };
+- },
+- codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) }
++function projectAgentFor(bot, selfId) {
++ if (bot === null || typeof bot !== "object" || bot.remote !== void 0) return null;
++ const projectId = text(bot.projectId);
++ if (projectId === "") return null;
++ const owner = text(bot.ownerId);
++ const self = text(selfId);
++ if (self !== "" && owner !== "" && owner !== self) return null;
++ return { projectId };
++}
++function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
++ const [agentId, setAgentId] = (0, import_react19.useState)("");
++ const id = text(projectId);
++ const open = async () => {
++ let target = agentId;
++ if (target === "") {
++ try {
++ const record = await (read ?? call)("project-read", { id });
++ target = botIdOf(record?.project?.assistant);
++ } catch {
++ target = "";
++ }
++ if (target !== "") setAgentId(target);
++ }
++ if (target !== "") onOpen?.(target);
+ };
+- return [bots, chats];
++ if (id === "") return null;
++ return (0, import_react19.createElement)("button", {
++ type: "button",
++ className: "bt-project-agent-entry",
++ "data-project-agent": id,
++ "data-open-bot": agentId === "" ? void 0 : agentId,
++ title: t("Open my agent"),
++ "aria-label": t("Open my agent"),
++ onClick: open
++ }, t("My agent"));
++}
++function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
++ const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics });
++ return (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" },
++ (0, import_react19.createElement)("h3", null, view.title),
++ (0, import_react19.createElement)(ProjectTeam, { project, onOpenBot, onOpenDossier }),
++ // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
++ view.counts === null ? null : (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" },
++ (0, import_react19.createElement)("span", null, [t("Waiting"), ": ", (0, import_react19.createElement)("b", { key: "w" }, countText(view.counts.waiting))]),
++ (0, import_react19.createElement)("span", null, [t("In flight"), ": ", (0, import_react19.createElement)("b", { key: "f" }, countText(view.counts.inflight))]),
++ (0, import_react19.createElement)("span", null, [t("Blocked"), ": ", (0, import_react19.createElement)("b", { key: "b" }, countText(view.counts.blocked))]),
++ (0, import_react19.createElement)("span", null, [t("Done"), ": ", (0, import_react19.createElement)("b", { key: "d" }, countText(view.counts.done))])
++ ),
++ // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses
++ // every write a non-owner attempts, so the refusal is an answer the reader sees, not a
++ // control that silently disappeared.
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-actions" },
++ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")),
++ (0, import_react19.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members"))
++ ),
++ (0, import_react19.createElement)(ProjectMetrics, { view: view.metrics }),
++ (0, import_react19.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }),
++ (0, import_react19.createElement)(ProjectBoardRows, { view, onOpen }),
++ (0, import_react19.createElement)(ProjectQueueGroups, { view, onOpen }),
++ (0, import_react19.createElement)(ProjectDoneGroup, { view, onOpen }),
++ (0, import_react19.createElement)(ProjectBoardRecord, { record: view.record })
++ );
+ }
+-
+-// src/client/face-at-filter.js
+-var REFERENCE_SOURCE = "reference";
+-var optionId = (source, index) => `dsh-slash-option-${source}-${index}`;
+-var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session";
+-var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index;
+-function planFaceMenu(state, previous = null) {
+- const hidden = [];
+- const ready = [];
+- let shown = 0;
+- let loading = false;
+- for (const group of state?.groups ?? []) {
+- const items = group.items ?? [];
+- if (group.status !== "ready" && items.length === 0) loading = true;
+- items.forEach((item, index) => {
+- const hide = isSessionRow(group, item);
+- if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section });
+- else shown += 1;
+- if (group.status === "ready") ready.push({ source: group.source, index, hide });
+- });
+- }
+- const open = state?.open === true;
+- const blank3 = open && hidden.length > 0 && shown === 0 && !loading;
+- const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready");
+- return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) };
++function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) {
++ const save = request ?? call;
++ const [kind, setKind] = (0, import_react19.useState)("bot");
++ const [memberId, setMemberId] = (0, import_react19.useState)("");
++ const [note, setNote] = (0, import_react19.useState)("");
++ const [assistantOff, setAssistantOff] = (0, import_react19.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true);
++ const [status, setStatus] = (0, import_react19.useState)("");
++ const id = text(project?.id);
++ const run = async (endpoint, payload, done) => {
++ try {
++ const result = await save(endpoint, payload);
++ setStatus(done);
++ onSaved?.(result);
++ return result;
++ } catch (error) {
++ setStatus(hostText(error?.message ?? String(error)));
++ return null;
++ }
++ };
++ return (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-panel", "data-panel": "project-settings" },
++ (0, import_react19.createElement)("h3", null, t("Project settings")),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-field" },
++ (0, import_react19.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)(
++ "select",
++ { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) },
++ (0, import_react19.createElement)("option", { value: "bot" }, t("Bot")),
++ (0, import_react19.createElement)("option", { value: "group" }, t("Group"))
++ ),
++ (0, import_react19.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }),
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")),
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind"))
++ )
++ ),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-field" },
++ (0, import_react19.createElement)("label", null, t("Project Assistant")),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("label", null, (0, import_react19.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")),
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save"))
++ )
++ ),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-field" },
++ // The host knows two memory kinds; the free text the panel takes is recorded as a decision,
++ // never as a third kind the host would refuse (#1820).
++ (0, import_react19.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")),
++ (0, import_react19.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")) }, t("Save memory"))
++ )
++ ),
++ (0, import_react19.createElement)("div", { className: "bt-project-status-line", role: "status" }, status),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
++ )
++ );
+ }
+-function steer(ready, highlight, previous) {
+- const at = ready.findIndex((row) => same(row, highlight));
+- if (at < 0 || !ready[at].hide) return null;
+- const from = ready.findIndex((row) => same(row, previous));
+- const n = ready.length;
+- const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1;
+- for (let step = 1; step < n; step += 1) {
+- const row = ready[((at + dir * step) % n + n) % n];
+- if (!row.hide) return { source: row.source, index: row.index };
++function ProjectMembersPanel({ project, rows, onClose } = {}) {
++ const owners = [];
++ const add = (id) => {
++ const clean = text(id);
++ if (clean !== "" && !owners.includes(clean)) owners.push(clean);
++ };
++ add(project?.ownerUserId);
++ for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id);
++ const boardMembers = [];
++ for (const row of Array.isArray(rows) ? rows : []) {
++ const name = text(row?.assignee);
++ if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name);
+ }
+- return null;
+-}
+-function faceMenuCss({ hidden, blank: blank3 }) {
+- if (hidden.length === 0) return "";
+- const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`);
+- const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`);
+- const css = [`${[...rows, ...titles].join(",")}{display:none!important}`];
+- if (blank3) css.push("[data-trigger-menu]{display:none!important}");
+- return css.join("\n");
++ const chief = text(project?.chief?.botId);
++ return (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-panel", "data-panel": "project-members" },
++ (0, import_react19.createElement)("h3", null, t("Members")),
++ (0, import_react19.createElement)(
++ "ul",
++ { className: "bt-project-members" },
++ owners.map((owner) => (0, import_react19.createElement)(
++ "li",
++ { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner },
++ (0, import_react19.createElement)("span", null, `@${owner}`),
++ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Owner"))
++ )),
++ chief === "" ? null : (0, import_react19.createElement)(
++ "li",
++ { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief },
++ (0, import_react19.createElement)("span", null, t("Project Chief")),
++ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project"))
++ ),
++ boardMembers.map((name) => (0, import_react19.createElement)(
++ "li",
++ { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
++ (0, import_react19.createElement)("span", null, `@${name}`),
++ (0, import_react19.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
++ ))
++ ),
++ owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react19.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
++ (0, import_react19.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
++ (0, import_react19.createElement)(
++ "div",
++ { className: "bt-project-row-actions" },
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
++ )
++ );
+ }
+-function watchFaceMenu(controller, doc = globalThis.document) {
+- if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => {
++async function readProjectBoard(read, wanted) {
++ const listing = await read("projects");
++ const projects = Array.isArray(listing?.projects) ? listing.projects : [];
++ const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted);
++ if (project === void 0) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } };
++ const zone = typeof Intl === "undefined" || typeof Intl.DateTimeFormat !== "function" ? void 0 : Intl.DateTimeFormat().resolvedOptions().timeZone;
++ const [record, board, taskList, team] = await Promise.all([
++ read("project-read", { id: project.id }),
++ read("project-board", { id: project.id, timeZone: zone }),
++ read("tasks", {}),
++ read("project-team", { id: project.id })
++ ]);
++ const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? [];
++ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null };
++ const base = record?.project ?? project;
++ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] };
++ return {
++ project: withRole,
++ assistantMine: record?.assistantMine ?? null,
++ rows: board?.tasks ?? [],
++ done: board?.done ?? [],
++ canManage: record?.canManage === true,
++ tasks,
++ decisions: board?.decisions,
++ meetings: board?.meetings,
++ queues,
++ metrics: board?.metrics ?? null,
++ team: team !== null && typeof team === "object" ? team : { rows: [] }
+ };
+- const style = doc.createElement("style");
+- style.setAttribute("data-bt-face-at", "");
+- doc.head.appendChild(style);
+- let previous = null;
+- let stopped = false;
+- const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
+- const apply2 = () => {
+- if (stopped) return;
+- const state = controller.menu.getSnapshot();
+- const plan = planFaceMenu(state, previous);
+- const css = faceMenuCss(plan);
+- if (style.textContent !== css) style.textContent = css;
+- if (plan.dismiss) later(() => {
+- if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss();
+- });
+- else if (plan.target) later(() => {
+- if (!stopped) controller.hover(plan.target.source, plan.target.index);
+- });
+- else previous = state.open ? state.highlight : null;
+- };
+- const unsubscribe = controller.menu.subscribe(apply2);
+- apply2();
+- return () => {
+- stopped = true;
+- unsubscribe?.();
+- style.remove();
+- };
+-}
+-
+-// src/client/speech.js
+-var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle";
+-var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]);
+-async function speechCatalog(speech) {
+- const result = await speech.catalog();
+- if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed");
+- const catalog = result.value;
+- const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId);
+- return { catalog, usable: USABLE.has(provider?.preparation?.phase) };
+-}
+-function waveBytes(samples, rate = 16e3) {
+- const bytes = new Uint8Array(44 + samples.length * 2);
+- const view = new DataView(bytes.buffer);
+- const ascii = (at, text2) => {
+- for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index);
+- };
+- ascii(0, "RIFF");
+- view.setUint32(4, 36 + samples.length * 2, true);
+- ascii(8, "WAVEfmt ");
+- view.setUint32(16, 16, true);
+- view.setUint16(20, 1, true);
+- view.setUint16(22, 1, true);
+- view.setUint32(24, rate, true);
+- view.setUint32(28, rate * 2, true);
+- view.setUint16(32, 2, true);
+- view.setUint16(34, 16, true);
+- ascii(36, "data");
+- view.setUint32(40, samples.length * 2, true);
+- samples.forEach((sample, index) => {
+- const value = Math.max(-1, Math.min(1, sample));
+- view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true);
+- });
+- return bytes;
+-}
+-function base64Of(bytes) {
+- let text2 = "";
+- for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768));
+- return btoa(text2);
+ }
+-var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause });
+-async function openCapture() {
+- const devices = navigator.mediaDevices;
+- if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable");
+- let stream;
+- try {
+- stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
+- } catch (error) {
+- throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error);
+- }
+- const audio = new AudioContext();
+- const analyser = audio.createAnalyser();
+- analyser.fftSize = 512;
+- audio.createMediaStreamSource(stream).connect(analyser);
+- const frame = new Float32Array(analyser.fftSize);
+- const recorder = new MediaRecorder(stream);
+- const chunks = [];
+- recorder.ondataavailable = (event) => {
+- if (event.data.size > 0) chunks.push(event.data);
+- };
+- recorder.start();
+- let closed = false;
+- const close = () => {
+- if (closed) return;
+- closed = true;
+- if (recorder.state !== "inactive") recorder.stop();
+- stream.getTracks().forEach((track) => track.stop());
+- void audio.close().catch(() => {
+- });
+- };
+- return {
+- level() {
+- if (closed) return 0;
+- analyser.getFloatTimeDomainData(frame);
+- let sum = 0;
+- for (const sample of frame) sum += sample * sample;
+- return Math.min(1, Math.sqrt(sum / frame.length) * 4);
+- },
+- cancel: close,
+- async finish(maxSeconds) {
+- if (closed || recorder.state === "inactive") {
+- close();
+- return null;
+- }
++var PROJECT_BOARD_POLL_MS = 5e3;
++var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project");
++function ProjectBoardOverlay({ actions, call: request } = {}) {
++ const read = request ?? call;
++ const [state, setState] = (0, import_react19.useState)(null);
++ const [panel, setPanel] = (0, import_react19.useState)(null);
++ const [dossier, setDossier] = (0, import_react19.useState)(null);
++ const [hash, setHash] = (0, import_react19.useState)(() => typeof window === "undefined" ? "" : window.location.hash);
++ const close = (0, import_react19.useCallback)(() => {
++ if (typeof window !== "undefined") window.location.hash = "";
++ setPanel(null);
++ setState(null);
++ }, []);
++ (0, import_react19.useEffect)(() => {
++ if (typeof window === "undefined") return void 0;
++ const onKey = (event) => {
++ if (event.key !== "Escape") return;
++ if (panel !== null) setPanel(null);
++ else close();
++ };
++ window.addEventListener("keydown", onKey);
++ return () => window.removeEventListener("keydown", onKey);
++ }, [panel, close]);
++ (0, import_react19.useEffect)(() => {
++ if (typeof window === "undefined") return void 0;
++ const sync = () => setHash(window.location.hash);
++ window.addEventListener("hashchange", sync);
++ sync();
++ return () => window.removeEventListener("hashchange", sync);
++ }, []);
++ (0, import_react19.useEffect)(() => {
++ if (typeof window === "undefined") return void 0;
++ if (!hash.startsWith("#project")) {
++ setState(null);
++ return void 0;
++ }
++ const wanted = hash.slice("#project".length).replace(/^=/, "");
++ let gone = false;
++ const loadBoard = () => readProjectBoard(read, wanted);
++ const refresh = async (first) => {
+ try {
+- await new Promise((resolve) => {
+- recorder.onstop = resolve;
+- recorder.stop();
+- });
+- stream.getTracks().forEach((track) => track.stop());
+- const blob = new Blob(chunks, { type: recorder.mimeType });
+- if (blob.size === 0) return null;
+- const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
+- const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
+- const offline = new OfflineAudioContext(1, length, 16e3);
+- const source = offline.createBufferSource();
+- source.buffer = decoded;
+- source.connect(offline.destination);
+- source.start();
+- return waveBytes((await offline.startRendering()).getChannelData(0));
+- } finally {
+- close();
++ const next = await loadBoard();
++ if (!gone) setState(next);
++ } catch {
++ if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] });
+ }
+- }
+- };
+-}
+-async function transcribeWave(speech, catalog, wave, signal) {
+- if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
+- const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
+- if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
+- return String(result.value?.text ?? "").trim();
+-}
+-function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
+- let spokeAt = 0;
+- let quietSince = 0;
+- return (level, now) => {
+- if (level >= loud) {
+- spokeAt ||= now;
+- quietSince = 0;
+- return "speech";
+- }
+- if (!spokeAt) return "waiting";
+- if (level < quiet) quietSince ||= now;
+- return quietSince && now - quietSince >= quietMs ? "done" : "speech";
+- };
+-}
+-
+-// src/client/model-menu.js
+-var import_react21 = require("react");
+-var import_react_dom8 = require("react-dom");
+-var MENU_EXIT_MS = 120;
+-function groupModels(models) {
+- const groups = /* @__PURE__ */ new Map();
+- for (const model of models ?? []) {
+- const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0];
+- if (!groups.has(provider)) groups.set(provider, []);
+- groups.get(provider).push(model);
+- }
+- const names = /* @__PURE__ */ new Map();
+- for (const [provider, list] of groups) {
+- const name = list[0].providerName ?? provider;
+- names.set(name, (names.get(name) ?? 0) + 1);
+- }
+- return [...groups].map(([provider, list]) => {
+- const name = list[0].providerName ?? provider;
+- return [names.get(name) > 1 ? `${name} (${provider})` : name, list];
+- });
+-}
+-var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
+-var effortCache = /* @__PURE__ */ new Map();
+-function useEfforts(served, ref, actions) {
+- const [fetched, setFetched] = (0, import_react21.useState)(void 0);
+- (0, import_react21.useEffect)(() => {
+- setFetched(void 0);
+- if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0;
+- let live = true;
+- if (effortCache.has(ref)) setFetched(effortCache.get(ref));
+- else void Promise.resolve(actions.efforts(ref)).then((value) => {
+- const reasoning = value?.efforts ? value : null;
+- effortCache.set(ref, reasoning);
+- if (live) setFetched(reasoning);
+- }).catch(() => {
+- });
++ };
++ void refresh(true);
++ const timer = setInterval(() => {
++ if (!boardPollPaused()) void refresh(false);
++ }, PROJECT_BOARD_POLL_MS);
+ return () => {
+- live = false;
++ gone = true;
++ clearInterval(timer);
+ };
+- }, [ref, served?.reasoning !== void 0, actions]);
+- return served?.reasoning ?? fetched;
+-}
+-function effortRows(reasoning) {
+- if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return [];
+- return [
+- { effort: void 0, label: t("Default") },
+- ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name }))
+- ];
+-}
+-function EffortRows({ reasoning, current, onPick }) {
+- return (0, import_react21.createElement)(
+- import_react21.Fragment,
+- null,
+- (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+- (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
+- effortRows(reasoning).map((row) => (0, import_react21.createElement)(
+- "button",
+- {
+- key: row.effort ?? "default",
+- type: "button",
+- role: "menuitemradio",
+- "aria-checked": row.effort === current,
+- onClick: () => onPick(row.effort)
+- },
+- (0, import_react21.createElement)("span", { className: "bt-menu-label" }, row.label),
+- (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react21.createElement)(CheckIcon) : null)
+- ))
+- );
+-}
+-function ModelRows({ roster, current, onPick }) {
+- return groupModels(roster.models).map(([provider, list]) => (0, import_react21.createElement)(
+- import_react21.Fragment,
+- { key: list[0].provider ?? provider },
+- (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
+- list.map((model) => (0, import_react21.createElement)(
+- "button",
+- {
+- key: model.ref,
+- type: "button",
+- role: "menuitemradio",
+- "aria-checked": model.ref === current,
+- title: model.ref,
+- onClick: () => onPick(model.ref)
+- },
+- (0, import_react21.createElement)("span", { className: "bt-menu-label" }, model.name),
+- model.ref === roster.defaultModel ? (0, import_react21.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
+- (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react21.createElement)(CheckIcon) : null)
+- ))
+- ));
+-}
+-var Caret = () => (0, import_react21.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
+-function useAnchoredMenu() {
+- const [place, setPlace] = (0, import_react21.useState)(null);
+- const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS);
+- const button = (0, import_react21.useRef)(null);
+- const menu = (0, import_react21.useRef)(null);
+- const close = () => setPlace(null);
+- (0, import_react21.useEffect)(() => {
+- if (place === null) return void 0;
+- const onDown = (event) => {
+- if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return;
+- close();
+- };
+- const onKey = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- close();
+- button.current?.focus();
+- }
+- };
+- const onScroll = (event) => {
+- if (!menu.current?.contains(event.target)) close();
+- };
+- window.addEventListener("mousedown", onDown);
+- window.addEventListener("keydown", onKey, true);
+- window.addEventListener("scroll", onScroll, true);
+- return () => {
+- window.removeEventListener("mousedown", onDown);
+- window.removeEventListener("keydown", onKey, true);
+- window.removeEventListener("scroll", onScroll, true);
+- };
+- }, [place]);
+- const open = () => {
+- const rect = button.current.getBoundingClientRect();
+- const width = Math.max(240, rect.width);
+- const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
+- const below = window.innerHeight - rect.bottom;
+- setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" });
+- };
+- return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close };
+-}
+-function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) {
+- if (bot?.remote !== void 0) return null;
+- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
+- const shown = modelOf(bot, roster);
+- const current = bot.model ?? bot.appliedModel;
+- const served = servedOf(roster, current);
+- const reasoning = useEfforts(served, current, actions);
+- const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0;
+- const currentEffort = picked ?? reasoning?.defaultEffort;
+- const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort;
+- const toggle = () => {
+- if (isOpen) {
+- close();
+- return;
+- }
+- void actions?.refreshModels?.();
+- open();
+- };
+- const pick2 = (ref) => {
+- close();
+- if (onPick) onPick(ref);
+- else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref }));
+- };
+- const pickEffort = (effort) => {
+- close();
+- if (onPickEffort) onPickEffort(effort);
+- else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null }));
+- };
+- const models = roster.models ?? [];
+- return (0, import_react21.createElement)(
+- import_react21.Fragment,
+- null,
+- (0, import_react21.createElement)(
+- "button",
+- {
+- ref: button,
+- type: "button",
+- className: "bt-model-trigger",
+- "data-wide": wide || void 0,
+- "aria-haspopup": "menu",
+- "aria-expanded": isOpen,
+- "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }),
+- "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0,
+- onClick: toggle
+- },
+- (0, import_react21.createElement)(
+- "span",
+- { className: "bt-model-copy" },
+- (0, import_react21.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
+- shown ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
+- ),
+- (0, import_react21.createElement)(Caret)
+- ),
+- shownPlace ? (0, import_react_dom8.createPortal)((0, import_react21.createElement)(
+- "div",
+- { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 },
+- models.length > 0 ? (0, import_react21.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
+- effortRows(reasoning).length > 0 ? (0, import_react21.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
+- (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+- (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+- close();
+- openHarnessModels();
+- } }, (0, import_react21.createElement)(SlidersIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
+- ), document.body) : null
+- );
+-}
+-function ModelChoiceDock({ sessionId, useRoster, actions }) {
+- const roster = useRoster((value) => value);
+- const bot = roster.byId[sessionId];
+- const [busy, setBusy] = (0, import_react21.useState)("");
+- const [error, setError] = (0, import_react21.useState)("");
+- const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready;
+- (0, import_react21.useEffect)(() => {
+- if (shown) void actions.refreshModels?.();
+- }, [shown, sessionId]);
+- if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null;
+- if (bot.remote) return bot.remote.gone ? (0, import_react21.createElement)(RemoteGoneDock, { bot }) : null;
+- const models = roster.models ?? [];
+- const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false;
+- if (bot.model && (!lost || models.length === 0)) return null;
+- const pick2 = (ref) => {
+- setBusy(ref);
+- setError("");
+- void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => {
+- setError(failure?.message ?? String(failure));
+- void actions.refreshModels?.();
+- }).finally(() => setBusy(""));
+- };
+- const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name });
+- const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" })] : [
+- ...groupModels(models).map(([provider, list]) => (0, import_react21.createElement)(
+- "div",
+- { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider },
+- (0, import_react21.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
+- (0, import_react21.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react21.createElement)("button", {
+- key: model.ref,
+- type: "button",
+- className: "bt-model-chip",
+- disabled: busy !== "",
+- "aria-busy": busy === model.ref || void 0,
+- "aria-label": `${model.name} · ${provider}`,
+- title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref,
+- onClick: () => pick2(model.ref)
+- }, model.name, busy === model.ref ? (0, import_react21.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
+- )),
+- (0, import_react21.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react21.createElement)(SlidersIcon), t("Manage models"))
+- ];
+- return (0, import_react21.createElement)(
++ }, [hash]);
++ if (state === null) return null;
++ return (0, import_react19.createElement)(
+ "div",
+- { className: "bt-model-dock" },
+- (0, import_react21.createElement)(
++ { className: "bt-project-overlay" },
++ (0, import_react19.createElement)("div", { className: "bt-scrim", onClick: close }),
++ (0, import_react19.createElement)(
+ "div",
+- { className: "bt-astack", "data-lead": "" },
+- (0, import_react21.createElement)(
++ { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
++ (0, import_react19.createElement)(
+ "div",
+- { className: "bt-msg" },
+- (0, import_react21.createElement)(
+- "div",
+- { className: "bt-msg-line" },
+- (0, import_react21.createElement)(BotMark, { bot, size: 22 }),
+- (0, import_react21.createElement)("div", { className: "bt-bubble" }, text2)
+- )
+- )
+- ),
+- (0, import_react21.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
+- error ? (0, import_react21.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
+- );
+-}
+-function RemoteGoneDock({ bot }) {
+- return (0, import_react21.createElement)(
+- "div",
+- { className: "bt-model-dock", "data-flybot": "remote-gone" },
+- (0, import_react21.createElement)(
+- "div",
+- { className: "bt-astack", "data-lead": "" },
+- (0, import_react21.createElement)(
++ { className: "bt-mem-head" },
++ (0, import_react19.createElement)("h2", {}, t("Project board")),
++ (0, import_react19.createElement)("span", { className: "bt-mem-head-gap" }),
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
++ ),
++ (0, import_react19.createElement)(
+ "div",
+- { className: "bt-msg" },
+- (0, import_react21.createElement)(
++ { className: "bt-project-body" },
++ state.project === null ? (0, import_react19.createElement)(
+ "div",
+- { className: "bt-msg-line" },
+- (0, import_react21.createElement)(BotMark, { bot, size: 22 }),
+- (0, import_react21.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
+- )
++ { className: "bt-project-empty" },
++ (0, import_react19.createElement)("p", null, t("No Project yet — create one.")),
++ (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
++ close();
++ actions?.openSettings?.("projects");
++ } }, t("Open Projects in Settings"))
++ ) : panel === "dossier" ? (0, import_react19.createElement)(ProjectDossierPanel, {
++ row: dossierRowOf(state.team, dossier?.botId),
++ label: dossier?.label ?? "",
++ onClose: () => setPanel(null),
++ onOpenBot: (id) => {
++ close();
++ actions?.openSession?.(id);
++ }
++ }) : panel === "settings" ? (0, import_react19.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react19.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react19.createElement)(ProjectBoardPane, {
++ project: state.project,
++ rows: state.rows,
++ done: state.done,
++ canManage: state.canManage,
++ tasks: state.tasks,
++ decisions: state.decisions,
++ meetings: state.meetings,
++ queues: state.queues,
++ metrics: state.metrics,
++ team: state.team,
++ onSettings: () => setPanel("settings"),
++ onMembers: () => setPanel("members"),
++ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
++ onOpenDossier: (value) => {
++ setDossier(value);
++ setPanel("dossier");
++ },
++ // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
++ // so it closes first — otherwise the chat opens behind it.
++ onOpenBot: (id) => {
++ close();
++ actions?.openSession?.(id);
++ },
++ // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
++ // lands on that Bot or group. Nothing here re-decides which board that is.
++ onOpen: (board) => {
++ if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
++ actions?.openBoard?.();
++ setState(null);
++ }
++ })
+ )
+ )
+ );
+ }
+-function setupChoices(actions) {
+- return [
+- actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null,
+- [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")],
+- [t("Open settings to set up a custom model"), openHarnessModels]
+- ].filter(Boolean);
+-}
+-var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md";
+-function KeyHelpLink() {
+- return (0, import_react21.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
+-}
+-function missingModelKey(message) {
+- const text2 = String(message ?? "");
+- if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0;
+- return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" };
+-}
+-function modelKeyChoices(actions, route) {
+- if (/^deepseek/i.test(route)) return setupChoices(actions);
+- return [[t("Manage models"), openHarnessModels]];
++
++// src/client/header.js
++var import_react20 = require("react");
++var import_react_dom6 = require("react-dom");
++var GoArrow = () => (0, import_react20.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react20.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" }));
++var MEMORY_NOTICE_MS = 3200;
++var noNews = (select) => select({});
++function useMemoryNotice(ids, useMemoryNews) {
++ const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0)));
++ const [shown, setShown] = (0, import_react20.useState)(false);
++ (0, import_react20.useEffect)(() => {
++ const left = seen + MEMORY_NOTICE_MS - Date.now();
++ setShown(seen > 0 && left > 0);
++ if (seen === 0 || left <= 0) return void 0;
++ const timer = setTimeout(() => setShown(false), left);
++ return () => clearTimeout(timer);
++ }, [seen]);
++ return shown;
+ }
+-function TurnErrorCell({ node, t: chatT, actions }) {
+- const data = node?.data ?? {};
+- const code = typeof data.code === "string" && data.code ? data.code : void 0;
+- const raw = String(data.message ?? "");
+- const shell = (key, fallback) => {
+- const text2 = typeof chatT === "function" ? chatT(key) : void 0;
+- return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback;
+- };
+- const missing = missingModelKey(raw);
+- const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed"));
+- const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw;
+- const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [];
+- const deepseek = missing ? /^deepseek/i.test(missing.route) : false;
+- return (0, import_react21.createElement)(
+- "div",
+- { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 },
+- (0, import_react21.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
+- (0, import_react21.createElement)(
+- "div",
+- { className: "bt-turn-error-copy" },
+- (0, import_react21.createElement)("span", { className: "bt-turn-error-title" }, title),
+- (0, import_react21.createElement)("span", { className: "bt-turn-error-message" }, message),
+- code ? (0, import_react21.createElement)("code", { className: "bt-turn-error-code" }, code) : null
+- ),
+- choices.length > 0 ? (0, import_react21.createElement)(
+- "div",
+- { className: "bt-turn-error-actions" },
+- choices.map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
+- deepseek ? (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" }) : null
+- ) : null
++function MemoryNotice({ shown }) {
++ const inner = (0, import_react20.useRef)(null);
++ const [width, setWidth] = (0, import_react20.useState)(0);
++ const label = t("Memory updated");
++ (0, import_react20.useLayoutEffect)(() => {
++ if (shown) setWidth(inner.current?.scrollWidth ?? 0);
++ }, [label, shown]);
++ return (0, import_react20.createElement)(
++ "span",
++ { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } },
++ (0, import_react20.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react20.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react20.createElement)(MemoryIcon)), label)
+ );
+ }
+-function DeepSeekSignIn({ useUi, actions }) {
+- const open = useUi((state) => state.signIn);
+- const entry = open ? actions.signInEntry?.() : null;
+- if (!entry) return null;
+- return (0, import_react21.createElement)(SignInHost, { key: open, entry, actions });
+-}
+-function SignInHost({ entry, actions }) {
+- const [ops] = (0, import_react21.useState)(() => entry.inject?.() ?? {});
+- const close = () => actions.closeSignIn();
+- const useHook = (source) => (selector) => (0, import_react21.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
+- const [hooks] = (0, import_react21.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
+- return (0, import_react21.createElement)(entry.component, {
+- ...ops,
+- ...hooks,
+- t: actions.accountT(),
+- complete: close,
+- useApiKey: () => {
+- close();
+- openHarnessOnboarding("deepseek-official");
+- }
++function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useBusyAcks, useMemoryNews, useTurnClock, useUi, useLive, actions }) {
++ const roster = useRoster((value) => value);
++ const title = useSessions((list) => list.byId[sessionId]?.displayTitle ?? list.byId[sessionId]?.title ?? "");
++ const firstTurns = useSessions((list) => {
++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
++ return Array.isArray(outline) ? outline.slice(0, 8).map((entry) => entry.turn).join(",") : "";
+ });
+-}
+-var MODEL_CSS = `
+-.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px}
+-.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135}
+-.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere}
+-.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135}
+-.bt-turn-error-message{color:var(--bt-ink-2)}
+-.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
+-.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
+-.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px}
+-.bt-model-help:hover{color:var(--bt-ink)}
+-.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px}
+-.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease}
+-.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)}
+-.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
+-.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e}
+-.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)}
+-.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)}
+-.bt-model-copy{display:flex;flex-direction:column;min-width:0}
+-.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}}
+-.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)}
+-.bt-model-menu{overflow-y:auto;scrollbar-width:thin}
+-.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none}
+-.bt-model-group:first-child{padding-top:2px}
+-.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)}
+-.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards}
+-.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+-.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
+-.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin}
+-.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0}
+-.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere}
+-.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px}
+-.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}
+-.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)}
+-.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
+-.bt-model-chip-quiet{color:var(--bt-ink-2)}
+-.bt-model-chip svg{width:14px;height:14px}
+-.bt-model-chip:disabled{cursor:default}
+-.bt-model-chip:disabled:not([aria-busy]){opacity:.5}
+-.bt-model-error{margin-left:30px}
+-@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}}
+-.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite}
+-@keyframes bt-model-spin{to{transform:rotate(360deg)}}
+-@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}}
+-`;
+-
+-// src/client/model-test.js
+-var import_react22 = require("react");
+-var FAILURE_COPY = {
+- NO_MODEL: "Pick a model first.",
+- NO_KEY: "This model has no API key yet. Set one up in the model settings.",
+- AUTH: "The API key was rejected. Check it in the model settings.",
+- QUOTA: "The account is out of quota.",
+- RATE_LIMIT: "Too many requests right now. Try again in a moment.",
+- TIMEOUT: "The model did not answer within 30 seconds.",
+- NOT_SERVED: "This model is not available now. Check its provider in the model settings.",
+- MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.",
+- TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.",
+- SERVER: "The model service failed. Try again later.",
+- INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.",
+- UNKNOWN: "The test failed."
+-};
+-function testOutcome(result, modelName) {
+- if (result?.ok === true) {
+- const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0;
+- return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) };
+- }
+- const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN";
+- return { ok: false, code, text: t(FAILURE_COPY[code]) };
+-}
+-var testModel = (id) => call("test-model", { id });
+-function ModelTestRow({ bot, modelName, test = testModel }) {
+- const [state, setState] = (0, import_react22.useState)({ busy: false, outcome: void 0 });
+- const live = (0, import_react22.useRef)(true);
+- (0, import_react22.useEffect)(() => () => {
+- live.current = false;
+- }, []);
+- (0, import_react22.useEffect)(() => {
+- setState({ busy: false, outcome: void 0 });
+- }, [bot.id, bot.model]);
+- const start = () => {
+- if (state.busy) return;
+- setState({ busy: true, outcome: void 0 });
+- Promise.resolve().then(() => test(bot.id)).then(
+- (result) => testOutcome(result, modelName ?? result?.ref ?? ""),
+- (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) })
+- ).then((outcome2) => {
+- if (live.current) setState({ busy: false, outcome: outcome2 });
+- });
+- };
+- const { busy, outcome } = state;
+- return (0, import_react22.createElement)(
++ const hiddenTurns = roster.exchangeTurns?.[sessionId];
++ const visit = useUi((state) => state.visits?.[sessionId]);
++ const opening = useTurnClock((state) => {
++ const first = openingTurn(state[sessionId], firstTurns, hiddenTurns);
++ return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : "";
++ });
++ (0, import_react20.useEffect)(() => {
++ if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening));
++ else document.body.style.removeProperty("--bt-first-sep");
++ return () => document.body.style.removeProperty("--bt-first-sep");
++ }, [opening]);
++ const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
++ const owner = roster.byId[sessionId]?.id ?? sessionId;
++ const doing = useActivity((map) => map[owner]);
++ const bot = roster.byId[sessionId];
++ const projectAgent = projectAgentFor(bot, roster.self?.memberId);
++ const room2 = roster.roomsById[sessionId];
++ const detailsOpen = useUi((state) => state.details === sessionId);
++ const full2 = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
++ const face = room2?.origin === "face-to-face";
++ const name = face ? faceRowTitle(full2) : full2;
++ const useNews = useMemoryNews ?? noNews;
++ const memoryIds = bot ? [bot.id] : room2?.members ?? [];
++ const notice = useMemoryNotice(memoryIds, useNews);
++ const changed2 = useNews((map) => latestMemoryChange(map, memoryIds));
++ const memoryNotice = notice && changed2 !== null && roster.memory !== false;
++ return (0, import_react20.createElement)(
+ "div",
+- { className: "bt-set-row bt-bs-row bt-model-test" },
+- (0, import_react22.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
+- (0, import_react22.createElement)(
+- "span",
+- { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) },
+- busy ? t("Testing…") : outcome === void 0 ? null : outcome.text,
+- outcome?.code ? (0, import_react22.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
+- )
++ { className: "bt-head" },
++ (0, import_react20.createElement)(
++ "button",
++ {
++ type: "button",
++ className: "bt-pill",
++ "aria-expanded": detailsOpen,
++ "data-memory": memoryNotice ? "updated" : void 0,
++ "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"),
++ onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId)
++ },
++ bot ? (0, import_react20.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react20.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null,
++ (0, import_react20.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")),
++ (0, import_react20.createElement)(MemoryNotice, { shown: memoryNotice }),
++ (0, import_react20.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react20.createElement)(GoArrow))
+ ),
+- (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
++ (0, import_react20.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""),
++ projectAgent === null ? null : (0, import_react20.createElement)(ProjectAgentEntry, {
++ projectId: projectAgent.projectId,
++ read: call,
++ onOpen: (id) => actions?.openSession?.(id)
++ }),
++ (0, import_react20.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
++ (0, import_react20.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
++ (0, import_react20.createElement)(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive })
+ );
+ }
+-var MODEL_TEST_CSS = `
+-.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+-.bt-model-test-result:empty{display:none}
+-.bt-model-test-result[data-ok=true]{color:#1a7f37}
+-.bt-model-test-result[data-ok=false]{color:#c21d2e}
+-.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
+-/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own
+- words ("Test connection" on one line) and takes the room it needs. */
+-.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch}
+-.bt-model-test>.bt-soft{flex:none;white-space:nowrap}
+-@container bt-panel (max-width:300px){
+- .bt-model-test{flex-wrap:wrap;row-gap:6px}
+- .bt-model-test>.bt-set-copy{flex:1 1 100%}
++function useTranscriptView() {
++ const [view, setView] = (0, import_react20.useState)(null);
++ (0, import_react20.useEffect)(() => {
++ let frame = 0;
++ let watched = null;
++ const observer = new MutationObserver(() => schedule());
++ const measure = () => {
++ frame = 0;
++ const scroller = document.querySelector("[data-conversation-scroll]");
++ if (scroller !== watched) {
++ observer.disconnect();
++ watched = scroller;
++ if (scroller) observer.observe(scroller, { childList: true, subtree: true });
++ }
++ if (!scroller) {
++ setView(null);
++ return;
++ }
++ const box = scroller.getBoundingClientRect();
++ const headerBottom = document.querySelector('header[class*="_header"]')?.getBoundingClientRect().bottom ?? box.top + 50;
++ const sep = scroller.querySelector(".bt-new-sep");
++ let divider = null;
++ if (sep) {
++ const at = sep.getBoundingClientRect();
++ divider = at.bottom <= headerBottom ? "above" : at.top >= box.bottom ? "below" : "in-view";
++ }
++ const composer = parseFloat(getComputedStyle(scroller).getPropertyValue("--dsh-composer-height")) || 152;
++ const next = {
++ unpinned: scroller.querySelector('[class*="_toBottomSlot"]>button') !== null,
++ divider,
++ center: Math.round(box.left + box.width / 2),
++ top: Math.round(headerBottom + 8),
++ bottom: Math.round(window.innerHeight - box.bottom + composer + 8)
++ };
++ setView((prev) => prev && Object.keys(next).every((key) => prev[key] === next[key]) ? prev : next);
++ };
++ const schedule = () => {
++ if (!frame) frame = requestAnimationFrame(measure);
++ };
++ document.addEventListener("scroll", schedule, true);
++ window.addEventListener("resize", schedule);
++ schedule();
++ return () => {
++ cancelAnimationFrame(frame);
++ observer.disconnect();
++ document.removeEventListener("scroll", schedule, true);
++ window.removeEventListener("resize", schedule);
++ };
++ }, []);
++ return view;
+ }
+-`;
+-
+-// src/client/folder-menu.js
+-var import_react23 = require("react");
+-var import_react_dom9 = require("react-dom");
+-var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path);
+-var Tick = ({ on }) => (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react23.createElement)(CheckIcon) : null);
+-function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) {
+- const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
+- const [info, setInfo] = (0, import_react23.useState)(null);
+- const [view, setView] = (0, import_react23.useState)("list");
+- const [draft, setDraft] = (0, import_react23.useState)("");
+- const [listing, setListing] = (0, import_react23.useState)(null);
+- const [problem, setProblem] = (0, import_react23.useState)("");
+- const fallback = group ? t("Each Bot's own folder") : t("Team workspace");
+- const toggle = () => {
+- if (isOpen) {
+- close();
+- return;
++function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }) {
++ const replies = useSessions((list) => {
++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
++ return Array.isArray(outline) ? outline.reduce((count, entry) => count + (entry.response ? 1 : 0), 0) : 0;
++ });
++ const visit = useUi((state) => state.visits?.[sessionId]);
++ const unread = useTurnClock((state) => {
++ if (typeof visit?.seen !== "number") return 0;
++ let count = 0;
++ for (const entry of Object.values(state[sessionId] ?? {})) {
++ if (entry.first !== void 0 && entry.first > visit.seen && entry.first <= visit.entered) count += 1;
+ }
+- setView("list");
+- setProblem("");
+- void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] }));
+- open();
+- };
+- const choose = (path) => {
+- close();
+- if (path !== (value ?? "")) void onPick(path);
+- };
+- const browse = (path) => {
+- setProblem("");
+- void Promise.resolve(actions.folderList(path)).then((next) => {
+- setListing(next);
+- setView("browse");
+- }).catch((failure) => setProblem(failure?.message ?? String(failure)));
+- };
+- const pickNative = () => {
+- close();
+- void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0));
+- };
+- const typed = () => {
+- const path = draft.trim();
+- if (path !== "") choose(path);
+- };
+- const known = info?.folders ?? [];
+- const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known;
+- const listView = (0, import_react23.createElement)(
++ return count;
++ });
++ const view = useTranscriptView();
++ const unpinned = view?.unpinned === true;
++ const [release, setRelease] = (0, import_react20.useState)({ base: null, dismissed: false });
++ const [above, setAbove] = (0, import_react20.useState)({ seen: false, dismissed: false });
++ (0, import_react20.useEffect)(() => {
++ setRelease({ base: unpinned ? replies : null, dismissed: false });
++ }, [unpinned]);
++ (0, import_react20.useEffect)(() => {
++ if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true });
++ }, [view?.divider]);
++ const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns));
++ (0, import_react20.useEffect)(() => {
++ if (above.seen && movedOn) actions?.settleVisit?.(sessionId);
++ }, [above.seen, movedOn, sessionId]);
++ const fresh = release.base === null ? 0 : replies - release.base;
++ const showDown = unpinned && fresh > 0 && !release.dismissed;
++ const showUp = view?.divider === "above" && !above.seen && !above.dismissed;
++ (0, import_react20.useEffect)(() => {
++ const scroller = document.querySelector("[data-conversation-scroll]");
++ if (!scroller) return void 0;
++ scroller.toggleAttribute("data-bt-news", showDown);
++ return () => scroller.removeAttribute("data-bt-news");
++ }, [showDown]);
++ if (!view || !showDown && !showUp) return null;
++ const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages");
++ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react20.createElement)(
+ "div",
+- { key: "list", className: "bt-folder-view" },
+- (0, import_react23.createElement)(
++ { className: "bt-news", "data-direction": direction, style },
++ (0, import_react20.createElement)(
+ "button",
+- { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") },
+- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, fallback),
+- (0, import_react23.createElement)(Tick, { on: !value })
++ { type: "button", className: "bt-news-jump", onClick: onJump },
++ (0, import_react20.createElement)("span", { className: "bt-news-ico" }, (0, import_react20.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)),
++ label(count)
+ ),
+- folders.length > 0 ? (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
+- folders.map((folder) => (0, import_react23.createElement)(
+- "button",
+- { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) },
+- (0, import_react23.createElement)("span", { className: "bt-folder-copy" }, (0, import_react23.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react23.createElement)("span", { className: "bt-folder-path" }, folder.path)),
+- (0, import_react23.createElement)(Tick, { on: folder.path === value })
+- )),
+- (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+- info?.picker === "native" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
+- info?.picker === "browse" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
+- (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
+- setDraft(value ?? "");
+- setView("type");
+- } }, (0, import_react23.createElement)(PencilIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
+- );
+- const typeView = (0, import_react23.createElement)(
+- "div",
+- { key: "type", className: "bt-folder-view" },
+- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+- (0, import_react23.createElement)("input", {
+- className: "bt-folder-input",
+- autoFocus: true,
+- value: draft,
+- spellCheck: false,
+- "aria-label": t("Folder path"),
+- placeholder: t("Full path of a folder"),
+- onChange: (event) => setDraft(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
++ (0, import_react20.createElement)("button", {
++ type: "button",
++ className: "bt-news-x",
++ "aria-label": t("Dismiss new messages"),
++ onClick: (event) => {
++ event.stopPropagation();
++ onDismiss();
+ }
+- }),
+- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
++ }, (0, import_react20.createElement)(CloseIcon))
+ );
+- const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
+- const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
+- const browseView = listing ? (0, import_react23.createElement)(
+- "div",
+- { key: `browse:${listing.path}`, className: "bt-folder-view" },
+- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
+- (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
+- parent ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
+- inside.map((entry) => (0, import_react23.createElement)(
+- "button",
+- { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
+- (0, import_react23.createElement)(FolderIcon),
+- (0, import_react23.createElement)("span", { className: "bt-menu-label" }, entry.name),
+- (0, import_react23.createElement)(ChevronRightIcon)
+- )),
+- inside.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
+- listing.truncated ? (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
+- (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
+- (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
+- ) : null;
+- const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
+- return (0, import_react23.createElement)(
+- import_react23.Fragment,
+- null,
+- (0, import_react23.createElement)(
+- "button",
+- {
+- ref: button,
+- type: "button",
+- className: "bt-model-trigger",
+- "data-wide": wide || void 0,
+- "aria-haspopup": "menu",
+- "aria-expanded": isOpen,
+- "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
+- title: value,
+- onClick: toggle
+- },
+- (0, import_react23.createElement)(
+- "span",
+- { className: "bt-model-copy" },
+- (0, import_react23.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
+- value ? (0, import_react23.createElement)("span", { className: "bt-model-provider" }, value) : null
+- ),
+- (0, import_react23.createElement)(Caret)
++ const toLatest = () => {
++ const scroller = document.querySelector("[data-conversation-scroll]");
++ const back = scroller?.querySelector('[class*="_toBottomSlot"]>button');
++ if (back) back.click();
++ else scroller?.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
++ };
++ const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" });
++ return (0, import_react_dom6.createPortal)(
++ (0, import_react20.createElement)(
++ import_react20.Fragment,
++ null,
++ showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null,
++ showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null
+ ),
+- shownPlace ? (0, import_react_dom9.createPortal)((0, import_react23.createElement)(
+- "div",
+- { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
+- shownView,
+- problem ? (0, import_react23.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
+- ), document.body) : null
++ document.body
+ );
+ }
+-var FOLDER_CSS = `
+-.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
+-.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
+-.bt-folder-copy .bt-menu-label{flex:none}
+-.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
+-.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
+-.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
+-.bt-folder-input:focus{border-color:var(--bt-accent)}
+-.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
+-.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
+-.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
+-@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
+-`;
+
+-// src/client/schedules.js
+-var import_react24 = require("react");
+-var POLL_MS4 = 3e4;
+-var shared = null;
+-var listeners = /* @__PURE__ */ new Set();
+-function publish(view) {
+- shared = view;
+- for (const listener of listeners) listener(view);
+-}
+-function useSchedules(actions) {
+- const [view, setView] = (0, import_react24.useState)(shared);
+- (0, import_react24.useEffect)(() => {
+- listeners.add(setView);
+- let stopped = false;
+- let timer;
+- const load = () => actions.schedules?.().then((next) => {
+- if (!stopped) publish(next);
+- }).catch(() => {
+- }).finally(() => {
+- if (!stopped) timer = setTimeout(load, POLL_MS4);
+- });
+- load();
+- return () => {
+- stopped = true;
+- clearTimeout(timer);
+- listeners.delete(setView);
+- };
+- }, []);
+- return view;
+-}
+-var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
+-var clock2 = (time) => String(time ?? "").slice(0, 5);
+-var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
+-function dayAndTime(ms) {
+- const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
+- return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
+-}
+-function weekdayNames(days) {
+- const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
+- return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
+-}
+-function everyText(seconds) {
+- if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
+- if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
+- if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 });
+- return t("Every {count} seconds", { count: seconds });
+-}
+-function timingText(task) {
+- const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
+- switch (task.kind) {
+- case "after":
+- case "at":
+- return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
+- case "every":
+- return everyText(task.everySeconds);
+- case "daily":
+- return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
+- case "weekly": {
+- const days = (task.weekdays ?? []).join();
+- const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
+- return `${text2}${zone}`;
+- }
+- case "cron":
+- return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
+- default:
+- return "";
+- }
+-}
+-var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
+-function lastRunText(run) {
+- if (run === void 0 || !Number.isFinite(run.at)) return "";
+- const time = dayAndTime(run.at);
+- switch (run.result) {
+- case "running":
+- return t("Running since {time}", { time });
+- case "stopped":
+- return t("Last run {time} · Stopped", { time });
+- case "failed":
+- return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") });
+- default:
+- return t("Last run {time}", { time });
+- }
+-}
+-function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) {
+- const [confirming, setConfirming] = (0, import_react24.useState)(false);
+- const [busy, setBusy] = (0, import_react24.useState)(false);
+- (0, import_react24.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 3e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const remove = () => {
+- if (!confirming) {
+- setConfirming(true);
+- return;
++// src/client/composer-label.js
++var ATTRIBUTES = ["aria-label", "data-placeholder"];
++function holdComposerLabel(doc, label) {
++ doc.body.style.setProperty("--bt-placeholder", JSON.stringify(label));
++ const shell = /* @__PURE__ */ new Map();
++ const apply2 = () => {
++ for (const el of doc.querySelectorAll("[data-composer-input]")) {
++ for (const name of ATTRIBUTES) {
++ const current = el.getAttribute(name);
++ if (current === label) continue;
++ if (!shell.has(el)) shell.set(el, {});
++ shell.get(el)[name] = current;
++ el.setAttribute(name, label);
++ }
+ }
+- setBusy(true);
+- void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
+ };
+- const toggle = () => {
+- setBusy(true);
+- const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause;
+- void step(task.sessionId, task.id).then((next) => {
+- setBusy(false);
+- onView(next);
+- }, () => setBusy(false));
++ apply2();
++ const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver;
++ const watcher = typeof Observer === "function" ? new Observer(apply2) : null;
++ watcher?.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ATTRIBUTES });
++ return () => {
++ watcher?.disconnect();
++ for (const [el, values] of shell) {
++ if (!el.isConnected) continue;
++ for (const [name, value] of Object.entries(values)) {
++ if (value == null) el.removeAttribute(name);
++ else el.setAttribute(name, value);
++ }
++ }
+ };
+- const [retryError, setRetryError] = (0, import_react24.useState)("");
+- const retry = () => {
+- setBusy(true);
+- setRetryError("");
+- void actions.scheduleRetry(task.sessionId, task.id).then((next) => {
+- setBusy(false);
+- onView(next);
+- }, (error) => {
+- setBusy(false);
+- setRetryError(hostText(error?.message ?? String(error)));
++}
++
++// src/client/header-effects.js
++function installHeaderEffects({ sessions, roster, actions, doc = globalThis.document }) {
++ if (!doc) return () => {
++ };
++ const slots = /* @__PURE__ */ new Map();
++ const run = (id, dep, setup) => {
++ const held = slots.get(id);
++ if (held !== void 0 && held.dep === dep) return;
++ held?.off();
++ slots.set(id, { dep, off: setup() ?? (() => {
++ }) });
++ };
++ let running = false;
++ let again = false;
++ const pass = () => {
++ const list = sessions.getSnapshot();
++ const state = roster.getSnapshot();
++ const row = Object.values(list?.byId ?? {}).find((entry) => (entry?.retainedBy?.mainView ?? 0) > 0);
++ const sessionId = row?.id;
++ const bot = sessionId === void 0 ? void 0 : state?.byId?.[sessionId];
++ const room2 = sessionId === void 0 ? void 0 : state?.roomsById?.[sessionId];
++ const name = bot?.remote ? friendBotTitle(bot) : roomTitle(room2) || bot?.name || "";
++ run("friend-chat", `${sessionId}|${bot?.remote === true}`, () => {
++ if (!bot?.remote) return void 0;
++ doc.body.dataset.btFriendChat = "";
++ return () => {
++ delete doc.body.dataset.btFriendChat;
++ };
++ });
++ run("remote-empty", `${sessionId}|${bot?.remote === true}|${row?.blank === true}`, () => {
++ if (!bot?.remote || row?.blank !== true) return void 0;
++ doc.body.dataset.btRemoteChat = "empty";
++ doc.body.style.setProperty("--bt-remote-empty", JSON.stringify(t("Send a message. Your friend approves unless Auto-send is on.")));
++ return () => {
++ delete doc.body.dataset.btRemoteChat;
++ doc.body.style.removeProperty("--bt-remote-empty");
++ };
++ });
++ run("composer-label", `${sessionId}|${name}`, () => holdComposerLabel(doc, name ? t("Message {name}", { name }) : t("Message")));
++ run("visit", sessionId, () => {
++ if (sessionId === void 0) return void 0;
++ actions.beginVisit(sessionId);
++ return () => actions.endVisit(sessionId);
++ });
++ run("face-at-menu", `${sessionId}|${room2?.origin === "face-to-face"}`, () => {
++ if (room2?.origin !== "face-to-face") return void 0;
++ const stop = actions.filterFaceAtMenu?.(sessionId);
++ return typeof stop === "function" ? stop : void 0;
++ });
++ run("room-flag", `${sessionId}|${room2 !== void 0}`, () => {
++ if (room2 === void 0) return void 0;
++ doc.body.dataset.btRoom = "";
++ return () => {
++ delete doc.body.dataset.btRoom;
++ };
+ });
+ };
+- const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
+- const stoppable = task.status === "active" || task.status === "paused";
+- const failed = task.lastRun?.result === "failed";
+- const last = lastRunText(task.lastRun);
+- return (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 },
+- showBot ? (0, import_react24.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
+- (0, import_react24.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react24.createElement)("span", { className: "bt-task-title" }, task.title),
+- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
+- task.prompt && task.prompt !== task.title ? (0, import_react24.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
+- last ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
+- retryError ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
+- ),
+- (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)),
+- failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
+- onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null,
+- onCopy ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react24.createElement)(DuplicateIcon)) : null,
+- stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
+- (0, import_react24.createElement)("button", {
+- type: "button",
+- className: "bt-mini bt-task-remove",
+- "data-confirm": confirming || void 0,
+- disabled: busy,
+- "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
+- title: confirming ? void 0 : t("Delete task"),
+- onClick: remove
+- }, confirming ? t("Delete") : (0, import_react24.createElement)(TrashIcon))
+- );
+-}
+-var pad = (number) => String(number).padStart(2, "0");
+-function inAnHour() {
+- const date = new Date(Date.now() + 36e5);
+- date.setMinutes(0, 0, 0);
+- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
+-}
+-var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
+-var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]];
+-function localInput(ms) {
+- const date = new Date(ms);
+- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+-}
+-function formStart(task) {
+- const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600;
+- const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1];
+- const once = task?.kind === "at" || task?.kind === "after";
+- return {
+- title: task?.title ?? "",
+- prompt: task?.prompt ?? "",
+- kind: once ? "at" : task?.kind ?? "daily",
+- at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(),
+- time: task?.time ? clock2(task.time) : "09:00",
+- weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5],
+- count: Math.max(1, Math.round(seconds / size)),
+- unit,
+- timeZone: task?.timeZone || localZone()
++ const apply2 = () => {
++ if (running) {
++ again = true;
++ return;
++ }
++ running = true;
++ try {
++ pass();
++ } finally {
++ running = false;
++ }
++ if (again) {
++ again = false;
++ apply2();
++ }
+ };
+-}
+-function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
+- const first = formStart(task);
+- const [target, setTarget] = (0, import_react24.useState)(task ? task.botId ?? task.roomId : start ?? "");
+- const [title, setTitle] = (0, import_react24.useState)(first.title);
+- const [prompt, setPrompt] = (0, import_react24.useState)(first.prompt);
+- const [kind, setKind] = (0, import_react24.useState)(first.kind);
+- const [at, setAt] = (0, import_react24.useState)(first.at);
+- const [time, setTime] = (0, import_react24.useState)(first.time);
+- const [weekdays, setWeekdays] = (0, import_react24.useState)(first.weekdays);
+- const [count, setCount] = (0, import_react24.useState)(first.count);
+- const [unit, setUnit] = (0, import_react24.useState)(first.unit);
+- const [busy, setBusy] = (0, import_react24.useState)(false);
+- const [error, setError] = (0, import_react24.useState)("");
+- const timing = () => {
+- if (kind === "cron") return { kind: "keep" };
+- if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone };
+- if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
+- return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} };
++ apply2();
++ const offSessions = sessions.subscribe(apply2);
++ const offRoster = roster.subscribe(apply2);
++ return () => {
++ offSessions?.();
++ offRoster?.();
++ for (const held of slots.values()) held.off();
++ slots.clear();
+ };
+- const submit = (event) => {
+- event.preventDefault();
+- if (busy || target === "") return;
+- setBusy(true);
+- setError("");
+- let when3;
++}
++
++// src/client/composer.js
++var import_react21 = require("react");
++var import_react_dom7 = require("react-dom");
++var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition;
++var keepComposerFocus = (event) => event.preventDefault();
++function useDictation(onText) {
++ const [listening, setListening] = (0, import_react21.useState)(false);
++ const [note, setNote] = (0, import_react21.useState)("");
++ const recognition = (0, import_react21.useRef)(null);
++ const latest = (0, import_react21.useRef)(onText);
++ latest.current = onText;
++ (0, import_react21.useEffect)(() => () => recognition.current?.abort(), []);
++ (0, import_react21.useEffect)(() => {
++ if (!note) return void 0;
++ const timer = setTimeout(() => setNote(""), 2600);
++ return () => clearTimeout(timer);
++ }, [note]);
++ const start = ({ continuous = true, onEnd } = {}) => {
++ const Api = speechApi();
++ if (!Api) {
++ setNote(t("Dictation is not available in this browser"));
++ return false;
++ }
++ const engine = new Api();
++ engine.lang = navigator.language || "zh-CN";
++ engine.interimResults = true;
++ engine.continuous = continuous;
++ engine.onresult = (event) => {
++ let interim = "";
++ for (let index = event.resultIndex; index < event.results.length; index += 1) {
++ const result = event.results[index];
++ const text2 = result[0]?.transcript ?? "";
++ if (result.isFinal) {
++ if (text2.trim()) latest.current(text2, true);
++ } else interim += text2;
++ }
++ if (interim) latest.current(interim, false);
++ };
++ engine.onerror = (event) => {
++ if (event.error === "aborted" || event.error === "no-speech") return;
++ setNote(event.error === "not-allowed" || event.error === "service-not-allowed" ? t("Microphone access is blocked") : t("Couldn't hear that"));
++ };
++ engine.onend = () => {
++ setListening(false);
++ recognition.current = null;
++ onEnd?.();
++ };
++ recognition.current = engine;
+ try {
+- when3 = timing();
++ engine.start();
++ setListening(true);
++ return true;
+ } catch {
+- setError(t("Pick a date and time"));
+- setBusy(false);
+- return;
++ setNote(t("Couldn't start dictation"));
++ return false;
+ }
+- const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 });
+- void saved.then(onDone, (failure) => {
+- setError(hostText(failure?.message ?? String(failure)));
+- setBusy(false);
+- });
+ };
+- const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
+- return (0, import_react24.createElement)(
+- "form",
+- { className: "bt-set-card bt-task-form", onSubmit: submit },
+- (0, import_react24.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
+- (0, import_react24.createElement)(
+- "select",
+- { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
+- (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
+- (0, import_react24.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react24.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
+- roster.rooms.length > 0 ? (0, import_react24.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react24.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
+- )
+- ),
+- (0, import_react24.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+- (0, import_react24.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
+- ),
+- (0, import_react24.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
+- (0, import_react24.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
+- ),
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("When"))),
+- (0, import_react24.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
+- (0, import_react24.createElement)(
+- "div",
+- { key: kind, className: "bt-task-when bt-veil" },
+- kind === "cron" ? (0, import_react24.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
+- kind === "at" ? (0, import_react24.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
+- kind === "weekly" ? (0, import_react24.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react24.createElement)("button", {
+- key: day,
+- type: "button",
+- className: "bt-task-day",
+- "aria-pressed": weekdays.includes(day),
+- onClick: () => toggleDay(day)
+- }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
+- kind === "daily" || kind === "weekly" ? (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
+- kind === "every" ? (0, import_react24.createElement)(
+- import_react24.Fragment,
+- null,
+- (0, import_react24.createElement)("span", { className: "bt-task-every" }, t("Every")),
+- (0, import_react24.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
+- (0, import_react24.createElement)(
+- "select",
+- { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
+- UNITS.map(([id, , label]) => (0, import_react24.createElement)("option", { key: id, value: id }, t(label)))
+- )
+- ) : null
+- )
+- ),
+- error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-task-foot" },
+- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+- (0, import_react24.createElement)(
+- "button",
+- { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" },
+- task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task")
+- )
+- )
+- );
+-}
+-function openPlugins(actions) {
+- actions.closeSettings();
+- actions.selectPanel("plugins");
+-}
+-function AutomationOff({ actions }) {
+- return (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-card bt-task-off" },
+- (0, import_react24.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react24.createElement)(ClockIcon)),
+- (0, import_react24.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
+- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
+- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+- );
++ const stop = () => recognition.current?.stop();
++ return { listening, note, start, stop };
+ }
+-function QuietHoursCard({ view, actions }) {
+- const quiet = view.quietHours ?? { on: false };
+- const [busy, setBusy] = (0, import_react24.useState)(false);
+- const [error, setError] = (0, import_react24.useState)("");
+- const [from, setFrom] = (0, import_react24.useState)(quiet.from ?? "22:00");
+- const [to, setTo] = (0, import_react24.useState)(quiet.to ?? "07:00");
+- const unchecked = view.quietUnchecked === true;
+- const refresh = () => {
+- void Promise.resolve(actions.schedules?.()).then((next) => {
+- if (next) publish(next);
+- }).catch(() => {
+- });
+- };
+- const save = (next) => {
+- if (busy) return;
+- setBusy(true);
+- setError("");
+- const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false };
+- void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => {
+- publish({ ...view, quietHours: shown });
+- refresh();
+- }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
+- };
+- return (0, import_react24.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react24.createElement)("h3", null, t("Quiet hours")),
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-card" },
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react24.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react24.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")),
+- (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists."))
+- ),
+- (0, import_react24.createElement)("button", {
+- type: "button",
+- className: "bt-soft bt-soft-sm",
+- disabled: busy,
+- "aria-pressed": quiet.on,
+- onClick: () => save(quiet.on ? { on: false } : { on: true, from, to })
+- }, quiet.on ? t("Turn off") : t("Turn on"))
+- ),
+- quiet.on ? (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Window"))),
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-task-when" },
+- (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }),
+- (0, import_react24.createElement)("span", { className: "bt-task-every" }, "–"),
+- (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }),
+- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window"))
+- )
+- ) : null,
+- error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
+- quiet.on && unchecked ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null
+- )
++function ComposerIdentity({ sessionId, useRoster }) {
++ const roster = useRoster((value) => value);
++ const room2 = roster.roomsById?.[sessionId];
++ const self = roster.self;
++ const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0);
++ if (room2?.viewerRole === "invited-owner") {
++ return (0, import_react21.createElement)(
++ "span",
++ { className: "bt-composer-as", title: t("You can remove your Bot from this room") },
++ t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") })
++ );
++ }
++ if (!self || !room2 || humans.length < 2) return null;
++ return (0, import_react21.createElement)(
++ "span",
++ { className: "bt-composer-as", title: t("Messages in this room are attributed to you") },
++ t("Sending as {name}", { name: self.displayName || self.memberId })
+ );
+ }
+-function SchedulesPage({ roster, actions, start }) {
+- const view = useSchedules(actions);
+- const [adding, setAdding] = (0, import_react24.useState)(Boolean(start));
+- const [editing, setEditing] = (0, import_react24.useState)(null);
+- if (view === null) return (0, import_react24.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
+- if (!view.available) return (0, import_react24.createElement)(AutomationOff, { actions });
+- const shown = (next) => {
+- publish(next);
+- setAdding(false);
+- setEditing(null);
+- };
+- const edit = (task) => {
+- setAdding(false);
+- setEditing(task);
+- };
+- const byChat = [
+- ...roster.bots.map((bot) => [bot.id, (0, import_react24.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
+- ...roster.rooms.map((room2) => [room2.id, (0, import_react24.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
+- ].filter(([, , , tasks]) => tasks.length > 0);
+- return [
+- (0, import_react24.createElement)(QuietHoursCard, { key: "quiet", view, actions }),
+- adding ? (0, import_react24.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react24.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
+- setEditing(null);
+- setAdding(true);
+- } }, (0, import_react24.createElement)(PlusIcon), t("New task"))),
+- byChat.length === 0 && !adding ? (0, import_react24.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react24.createElement)(
+- "section",
+- { key: id, className: "bt-set-section" },
+- (0, import_react24.createElement)("h3", { className: "bt-task-head" }, avatar, name),
+- (0, import_react24.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
+- ))
+- ];
+-}
+-function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) {
+- const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0;
+- const [copying, setCopying] = (0, import_react24.useState)(null);
+- const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0;
+- const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 }));
+- return (0, import_react24.createElement)(
+- import_react24.Fragment,
++function ComposerVoice({ sessionId, inputActions, actions, useUi }) {
++ const dictation = useDictation((text2, final) => {
++ if (final && inputActions) inputActions.insertText(text2, inputActions.captureInsertion());
++ });
++ const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null);
++ (0, import_react21.useEffect)(() => {
++ if (!quote || !inputActions) return;
++ actions.clearQuote(quote.token);
++ const excerpt = quote.text.replace(/\s+/g, " ").trim();
++ const line2 = `> ${excerpt.length > 200 ? `${excerpt.slice(0, 200)}…` : excerpt}
++
++`;
++ inputActions.insertText(line2, inputActions.captureInsertion());
++ const box = document.querySelector('[class*="_centerCol"] [role=textbox][contenteditable=true]');
++ if (box instanceof HTMLElement) {
++ box.focus();
++ const selection = window.getSelection();
++ selection?.selectAllChildren(box);
++ selection?.collapseToEnd();
++ }
++ }, [quote?.token]);
++ if (sessionId === void 0) return null;
++ const pluginMic = actions.speech() !== void 0;
++ return (0, import_react21.createElement)(
++ import_react21.Fragment,
+ null,
+- open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
+- source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => {
+- setCopying(null);
+- onDone(next);
+- }, onCancel: () => setCopying(null) }) : null,
+- rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
++ pluginMic ? null : (0, import_react21.createElement)("button", {
++ type: "button",
++ className: "bt-mic",
++ "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"),
++ "aria-pressed": dictation.listening,
++ title: dictation.listening ? t("Stop dictation") : t("Dictate"),
++ onMouseDown: keepComposerFocus,
++ onClick: () => dictation.listening ? dictation.stop() : dictation.start()
++ }, (0, import_react21.createElement)(MicIcon)),
++ (0, import_react21.createElement)("button", {
++ type: "button",
++ className: "bt-voice",
++ "aria-label": t("Start voice chat"),
++ title: t("Voice chat"),
++ onMouseDown: keepComposerFocus,
++ onClick: () => actions.openVoice(sessionId)
++ }, (0, import_react21.createElement)(WaveIcon, { size: 18 })),
++ dictation.note ? (0, import_react21.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null
+ );
+ }
+-function CopyRow({ task, roster, actions, onView, onCancel }) {
+- const others = [
+- ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]),
+- ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name])
+- ];
+- const [target, setTarget] = (0, import_react24.useState)("");
+- const [busy, setBusy] = (0, import_react24.useState)(false);
+- const [error, setError] = (0, import_react24.useState)("");
+- const submit = (event) => {
+- event.preventDefault();
+- if (busy || target === "") return;
+- setBusy(true);
+- setError("");
+- void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => {
+- setBusy(false);
+- setError(hostText(failure?.message ?? String(failure)));
+- });
++function menuIcons(roster) {
++ const marks = /* @__PURE__ */ new Map();
++ return (row) => {
++ if (!row.bot) return PeopleIcon;
++ const id = row.bot.id;
++ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react21.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size }));
++ return marks.get(id);
+ };
+- return (0, import_react24.createElement)(
+- "form",
+- { className: "bt-set-row bt-task-copy", onSubmit: submit },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))),
+- (0, import_react24.createElement)(
+- "select",
+- { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) },
+- (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
+- others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name))
+- ),
+- (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")),
+- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+- error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null
+- );
+ }
+-function BotSchedules({ bot, roster, actions }) {
+- const view = useSchedules(actions);
+- const [editing, setEditing] = (0, import_react24.useState)(null);
+- if (view === null) return null;
+- const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
+- return (0, import_react24.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react24.createElement)("h3", null, t("Scheduled tasks")),
+- !view.available ? (0, import_react24.createElement)(
++function ComposerPlus({ sessionId, inputActions, useRoster, actions }) {
++ const roster = useRoster((value) => value);
++ const [open, setOpen] = (0, import_react21.useState)(null);
++ const [shown, leaving] = useLinger(open, 120);
++ const button = (0, import_react21.useRef)(null);
++ const menu = (0, import_react21.useRef)(null);
++ (0, import_react21.useEffect)(() => {
++ if (!open) return void 0;
++ const onDown = (event) => {
++ if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null);
++ };
++ const onKey = (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ setOpen(null);
++ }
++ };
++ window.addEventListener("mousedown", onDown, true);
++ window.addEventListener("keydown", onKey, true);
++ return () => {
++ window.removeEventListener("mousedown", onDown, true);
++ window.removeEventListener("keydown", onKey, true);
++ };
++ }, [open]);
++ const target = roster.byId?.[sessionId]?.id ?? roster.roomsById?.[sessionId]?.id;
++ if (sessionId === void 0 || target === void 0) return null;
++ const toggle = () => {
++ if (open) {
++ setOpen(null);
++ return;
++ }
++ const rect = button.current.getBoundingClientRect();
++ setOpen({ left: rect.left, bottom: window.innerHeight - rect.top + 8 });
++ };
++ const attach = () => {
++ setOpen(null);
++ button.current?.closest("[data-composer-card]")?.querySelector("input[type=file]")?.click();
++ };
++ const schedule = () => {
++ setOpen(null);
++ actions.openSettings("schedules", target);
++ };
++ const reference = () => {
++ setOpen(null);
++ actions.referenceChat(sessionId, inputActions?.captureInsertion?.());
++ };
++ const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat();
++ return (0, import_react21.createElement)(
++ import_react21.Fragment,
++ null,
++ (0, import_react21.createElement)("button", {
++ ref: button,
++ type: "button",
++ className: "bt-plus",
++ "aria-label": t("Add"),
++ title: t("Add"),
++ "aria-haspopup": "menu",
++ "aria-expanded": Boolean(open),
++ onMouseDown: keepComposerFocus,
++ onClick: toggle
++ }, (0, import_react21.createElement)(PlusIcon)),
++ shown ? (0, import_react_dom7.createPortal)((0, import_react21.createElement)(
+ "div",
+- { className: "bt-set-card" },
+- (0, import_react24.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
+- (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+- )
+- ) : (0, import_react24.createElement)(TaskList, {
+- tasks,
+- editing,
+- roster,
+- actions,
+- onEdit: setEditing,
+- onDone: (next) => {
+- publish(next);
+- setEditing(null);
++ {
++ ref: menu,
++ className: "bt-menu bt-menu-up bt-plus-menu",
++ role: "menu",
++ "aria-label": t("Add"),
++ style: { left: shown.left, bottom: shown.bottom },
++ "data-leaving": leaving || void 0
+ },
+- onCancel: () => setEditing(null),
+- extra: [(0, import_react24.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react24.createElement)(PlusIcon), t("New task"))]
+- })
++ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react21.createElement)(PaperclipIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Add files"))),
++ canReference ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react21.createElement)(ChatsIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null,
++ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react21.createElement)(ClockIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task")))
++ ), document.body) : null
+ );
+ }
+-function ScheduleSummary({ bot, actions }) {
+- const view = useSchedules(actions);
+- const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
+- return (0, import_react24.createElement)(
+- "div",
+- null,
+- (0, import_react24.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
+- tasks.length === 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react24.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react24.createElement)(
+- "button",
+- { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") },
+- (0, import_react24.createElement)(ClockIcon),
+- (0, import_react24.createElement)("span", { className: "bt-task-line-title" }, task.title),
+- (0, import_react24.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
+- )))
+- );
+-}
+-var SCHEDULE_CSS = `
+-.bt-task{gap:10px}
+-.bt-task-copy{flex-wrap:wrap;gap:8px}
+-.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%}
+-.bt-task[data-done]{opacity:.6}
+-.bt-task[data-busy]{opacity:.4}
+-.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere}
+-.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+-.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
+-.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
+-.bt-task-remove svg{width:14px;height:14px}
+-.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
+-.bt-task-tool svg{width:14px;height:14px}
+-.bt-task-pause{height:26px;color:var(--bt-ink-2)}
+-.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere}
+-.bt-task-last[data-failed]{color:#c21d2e}
+-.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)}
+-.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135}
+-.bt-task-head{display:flex;align-items:center;gap:6px}
+-.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
+-.bt-task-bot{width:auto;min-width:160px;max-width:58%}
+-.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
+-.bt-task-when .bt-input{width:auto;height:32px}
+-.bt-task-time{min-width:110px}
+-.bt-task-count{width:72px!important}
+-.bt-task-unit{width:auto;height:32px}
+-.bt-task-every{font-size:13px;color:var(--bt-ink-2)}
+-.bt-task-days{display:flex;gap:4px}
+-.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease}
+-.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)}
+-.bt-task-foot{justify-content:flex-end;gap:8px}
+-.bt-task-foot .bt-send{height:28px;font-size:12px}
+-.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center}
+-.bt-task-off>*+*{border-top:0}
+-.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
+-.bt-task-off-mark svg{width:20px;height:20px}
+-.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600}
+-.bt-task-off .bt-set-hint{max-width:380px}
+-.bt-task-list{display:flex;flex-direction:column;gap:2px}
+-.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease}
+-.bt-task-line:hover{background:var(--bt-hover)}
+-.bt-task-line svg{flex:none;color:var(--bt-ink-3)}
+-.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)}
+-@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}}
+-`;
+
+-// src/client/capability-settings.js
+-var import_react25 = require("react");
+-var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] });
+-var parseMap = (text2, label) => {
+- let value;
+- try {
+- value = JSON.parse(text2 || "{}");
+- } catch {
+- throw new Error(t("{label} must be valid JSON.", { label }));
+- }
+- if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label }));
+- return value;
++// src/client/at-menu.js
++var BOTS_SOURCE = "ds-bot";
++var CHATS_SOURCE = "ds-bot-chats";
++var ROOM_ALL = ["all", "everyone", "所有人", "全体"];
++var OTHER_CHATS_MAX = 8;
++var conversationOf = (roster, sessionId) => {
++ if (!roster.ready) return null;
++ const room2 = roster.roomsById[sessionId];
++ if (room2) return { room: room2 };
++ const self = roster.byId[sessionId];
++ return self ? { self } : null;
+ };
+-var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…");
+-function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) {
+- return (0, import_react25.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t(label))),
+- (0, import_react25.createElement)(multiline ? "textarea" : "input", {
+- className: multiline ? "bt-input bt-input-area" : "bt-input",
+- value,
+- disabled,
+- maxLength: label === "Name" ? 32 : void 0,
+- placeholder,
+- spellCheck: false,
+- onChange: (event) => onChange(event.target.value)
+- })
+- );
++function mentionRows(roster, sessionId, query = "") {
++ const here = conversationOf(roster, sessionId);
++ if (here === null) return null;
++ const face = isFaceRoom(here.room);
++ const live = (here.room?.participants ?? []).filter((p) => p.leftSeq === void 0);
++ const bots = here.room ? [
++ ...here.room.members.map((id) => roster.byId[id]).filter(Boolean).map((bot) => face ? { ...bot, label: faceLabel(myOwnerName(roster, live, bot), bot.name) } : bot),
++ ...live.filter((p) => p.kind === "human").map((p) => ({ id: p.id, name: p.displayName || p.id, kind: "human", role: p.role })),
++ // A remote seat already listed under its handle (a duplicate participant record) is offered once.
++ ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({
++ id: p.handle || p.id,
++ // A face room inserts the disambiguated title. An ordinary group inserts the name the
++ // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)".
++ name: face ? remoteBotTitle(p) : remoteCallName(p),
++ kind: "bot",
++ remote: p,
++ handle: p.handle,
++ label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p)
++ }))
++ ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id);
++ const needle = query.trim().toLowerCase();
++ const rows = bots.filter((bot) => bot.name.toLowerCase().includes(needle) || String(bot.label ?? "").toLowerCase().includes(needle) || bot.remote && normHandle(remoteRecord(bot)?.handle || bot.handle).includes(normHandle(needle))).map((bot) => ({ bot, text: `@${bot.name} ` }));
++ const everyone = t("Everyone");
++ if (here.room && bots.length > 1 && [everyone, ...ROOM_ALL].some((word) => word.toLowerCase().startsWith(needle))) {
++ rows.push({ room: here.room, count: bots.filter((bot) => bot.kind !== "human").length, text: `@${everyone} ` });
++ }
++ if (face) return rows;
++ const seatHandles = new Set(bots.filter((bot) => bot.remote).map((bot) => normHandle(remoteRecord(bot)?.handle)).filter(Boolean));
++ if (here.room) return [...rows, ...remoteMentionRows(roster, query).filter((row) => !seatHandles.has(normHandle(row.remote.handle)))];
++ const remoteHandles = new Set(remoteMentionRows(roster, "").map((row) => normHandle(row.remote.handle)));
++ return [
++ ...rows.filter((row) => !(row.bot && remoteHandles.has(normHandle(remoteRecord(row.bot)?.handle)))),
++ ...remoteMentionRows(roster, query)
++ ];
+ }
+-function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
+- const [error, setError] = (0, import_react25.useState)("");
+- const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
+- const submit = async (event) => {
+- event.preventDefault();
+- setError("");
+- let payload;
+- try {
+- payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description };
+- if (draft.kind === "mcp" && draft.transport === "stdio") {
+- payload.transport = "stdio";
+- payload.command = draft.command;
+- payload.args = draft.argsText.split("\n").filter((line2) => line2 !== "");
+- payload.env = parseMap(draft.envText, t("Environment"));
+- } else if (draft.kind === "mcp") {
+- payload.transport = "streamable-http";
+- payload.url = draft.url;
+- payload.headers = parseMap(draft.headersText, t("Headers"));
+- } else if (draft.kind === "skill") payload.content = draft.content;
+- else payload.items = draft.items;
+- } catch (failure) {
+- setError(failure?.message ?? String(failure));
+- return;
+- }
+- try {
+- await actions.saveCapability(payload);
+- onClose();
+- } catch (failure) {
+- setError(failure?.message ?? String(failure));
+- }
+- };
+- const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
+- return (0, import_react25.createElement)(
+- "form",
+- { className: "bt-connector-form", onSubmit: submit },
+- (0, import_react25.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
+- (0, import_react25.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
+- (0, import_react25.createElement)(
+- "select",
+- { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
+- ["mcp", "skill", "pack"].map((kind) => (0, import_react25.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
+- )
+- ),
+- (0, import_react25.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
+- draft.kind === "mcp" ? (0, import_react25.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
+- (0, import_react25.createElement)(
+- "select",
+- { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) },
+- (0, import_react25.createElement)("option", { value: "stdio" }, t("Local command")),
+- (0, import_react25.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
+- )
+- ) : null,
+- draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react25.createElement)(
+- "div",
+- null,
+- (0, import_react25.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
+- (0, import_react25.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
+- (0, import_react25.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+- ) : null,
+- draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react25.createElement)(
+- "div",
+- null,
+- (0, import_react25.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
+- (0, import_react25.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
+- ) : null,
+- draft.kind === "skill" ? (0, import_react25.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
+- draft.kind === "pack" ? (0, import_react25.createElement)(
+- "div",
+- { className: "bt-set-card" },
+- available.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react25.createElement)(
+- "label",
+- { key: item.name, className: "bt-set-row bt-bs-row" },
+- (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, item.name)),
+- (0, import_react25.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
+- ))
+- ) : null,
+- error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
+- (0, import_react25.createElement)(
+- "div",
+- { className: "bt-connector-form-row" },
+- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
+- (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
+- )
+- );
++var isFaceRoom = (room2) => room2?.origin === "face-to-face";
++var memberCount = (count) => t(count === 1 ? "{count} member" : "{count} members", { count });
++var botCount = (count) => count === 1 ? t("1 Bot") : t("{count} Bots", { count });
++var normHandle = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
++var remoteRecord = (bot) => bot?.remote ? bot.remote === true ? bot : bot.remote : null;
++var myOwnerName = (roster, live, bot) => {
++ const seat = live.find((p) => !p.remote && p.kind !== "human" && (p.id === bot.id || bot.handle && p.handle === bot.handle));
++ return seatOwnerName(seat?.ownerName, seat?.handle) || cleanPeerName(roster.self?.displayName, "");
++};
++var uniqueBy = (list, key) => {
++ const seen = /* @__PURE__ */ new Set();
++ return list.filter((item) => {
++ const k = key(item);
++ if (!k || seen.has(k)) return !k;
++ seen.add(k);
++ return true;
++ });
++};
++var faceLabel = (person, bot) => person ? `${bot} (${person})` : bot;
++function chatRows(roster, list, sessionId) {
++ const here = conversationOf(roster, sessionId);
++ if (here === null) return [];
++ const byId = list?.byId ?? {};
++ const bots = roster.bots.filter((bot) => bot.id !== here.self?.id).map((bot) => {
++ const id = currentPart(bot);
++ return { sessionId: id, label: t("Chat with {name}", { name: bot.name }), bot, updatedAt: byId[id]?.updatedAt };
++ });
++ const team = /* @__PURE__ */ new Set([
++ ...roster.bots.flatMap((bot) => [bot.id, currentPart(bot), ...bot.parts ?? []]),
++ ...roster.rooms.flatMap((room2) => [room2.id, ...room2.parts ?? []])
++ ]);
++ const others = (list?.ids ?? []).map((id) => byId[id]).filter((row) => row !== void 0 && row.id !== sessionId && !team.has(row.id) && row.origin !== "subagent" && row.blank !== true).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)).slice(0, OTHER_CHATS_MAX).map((row) => ({ sessionId: row.id, label: row.displayTitle || row.title || row.id, updatedAt: row.updatedAt }));
++ return [...bots, ...others];
+ }
+-function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
+- const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
+- const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
+- return (0, import_react25.createElement)(
+- "div",
+- { className: "bt-connector" },
+- (0, import_react25.createElement)(
+- "div",
+- { className: "bt-connector-head" },
+- (0, import_react25.createElement)(
+- "span",
+- { className: "bt-connector-text" },
+- (0, import_react25.createElement)("span", { className: "bt-connector-name" }, item.name),
+- (0, import_react25.createElement)("span", { className: "bt-connector-status" }, secondary)
+- ),
+- (0, import_react25.createElement)(
+- "span",
+- { className: "bt-connector-actions" },
+- item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
+- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
+- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
+- )
+- )
+- );
++function sessionMention(sessionId, label) {
++ let binary = "";
++ for (const byte of new TextEncoder().encode(JSON.stringify(sessionId))) binary += String.fromCharCode(byte);
++ const payload = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
++ return `@[${label.replace(/[\\\]]/g, (mark) => `\\${mark}`)}](dsh-session:${payload})`;
+ }
+-function CapabilitiesPage({ roster, actions, run }) {
+- const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name));
+- const [draft, setDraft] = (0, import_react25.useState)(null);
+- const [confirming, setConfirming] = (0, import_react25.useState)("");
+- (0, import_react25.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(""), 4e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const startNew = () => setDraft(blank());
+- const edit = (item) => setDraft({
+- ...blank(),
+- ...item,
+- originalName: item.name,
+- argsText: (item.args ?? []).join("\n"),
+- envText: JSON.stringify(item.env ?? {}, null, 2),
+- headersText: JSON.stringify(item.headers ?? {}, null, 2),
+- items: [...item.items ?? []]
+- });
+- const remove = (item) => {
+- if (confirming !== item.name) {
+- setConfirming(item.name);
+- return;
+- }
+- setConfirming("");
+- void run(actions.deleteCapability(item.name));
++function atSources({ roster, sessions, launched, iconFor }) {
++ const bots = {
++ trigger: "@",
++ name: BOTS_SOURCE,
++ order: -20,
++ showGroupTitle: false,
++ async candidates(session, { query }) {
++ const snapshot = roster.getSnapshot();
++ const face = isFaceRoom(snapshot.roomsById?.[session.sessionId]);
++ const team = (mentionRows(snapshot, session.sessionId, query) ?? []).map((row) => ({
++ // A friend's Bot by the one name the row inserts, "Chief (Bob)" (#967); its handle is the
++ // second line (#384). A seat carries its own name; a friend's chat is named as its sidebar
++ // row and header (#498).
++ name: row.bot?.remote && !face ? row.bot.kind === "bot" ? remoteBotTitle(remoteRecord(row.bot)) : friendBotTitle(row.bot) : row.bot ? row.bot.label ?? row.bot.name : row.remote ? row.text.replace(/^@/, "").trimEnd() : t("Everyone"),
++ ...row.bot?.role && !face ? { description: row.bot.role } : {},
++ ...row.remote ? { description: row.remote.handle } : {},
++ ...row.bot?.remote && !face && remoteRecord(row.bot).handle ? { description: remoteRecord(row.bot).handle } : {},
++ // A face room counts the Bots in it, mine and everyone else's (#305, #457).
++ ...row.room ? { description: face ? botCount(row.count) : memberCount(row.count) } : {},
++ icon: iconFor(row),
++ section: t("Bots"),
++ value: row.text
++ }));
++ if (mentionRows(snapshot, session.sessionId, "") === null || face) return team;
++ const extra = (await mentionCandidates(query, { sessionId: session.sessionId, roster: snapshot })).map((row) => ({
++ name: row.name,
++ ...row.description ? { description: row.description } : {},
++ icon: row.icon ?? "session",
++ section: row.section ?? "FlyBot",
++ value: row.value
++ }));
++ return [...team, ...extra];
++ },
++ onPick: ({ candidate }) => candidate.value ? { text: candidate.value } : void 0
+ };
+- return (0, import_react25.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react25.createElement)("h3", null, t("Shared capabilities")),
+- (0, import_react25.createElement)(
+- "div",
+- { className: "bt-actions" },
+- (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
+- ),
+- draft ? (0, import_react25.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
+- (0, import_react25.createElement)(
+- "div",
+- { className: "bt-set-card bt-connectors-card" },
+- items.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react25.createElement)(CapabilityRow, {
+- key: item.name,
+- item,
+- confirming: confirming === item.name,
+- onEdit: () => edit(item),
+- onDelete: () => remove(item),
+- onApprove: () => void run(actions.approveCapability(item.name))
+- }))
+- )
+- );
+-}
+-function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
+- const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name));
+- const attached = new Set(bot.capabilities ?? []);
+- const statuses = roster.capabilityStatuses?.[bot.id] ?? {};
+- if (items.length === 0) return null;
+- return (0, import_react25.createElement)(
+- Card2,
+- { title: t("Capabilities") },
+- items.map((item) => (0, import_react25.createElement)(SwitchRow2, {
+- key: item.name,
+- label: item.name,
+- hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0,
+- on: attached.has(item.name),
+- onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled))
+- }))
+- );
++ const chats = {
++ trigger: "@",
++ name: CHATS_SOURCE,
++ order: -10,
++ showGroupTitle: false,
++ async candidates(session) {
++ if (!launched(session.sessionId)) return [];
++ return chatRows(roster.getSnapshot(), sessions.list.getSnapshot(), session.sessionId).map((row) => ({
++ name: row.bot ? row.bot.name : row.label,
++ ...row.updatedAt ? { description: relativeShort(row.updatedAt) } : {},
++ icon: row.bot ? iconFor(row) : "session",
++ section: row.bot ? t("Bots") : t("Conversations"),
++ value: JSON.stringify({ sessionId: row.sessionId, label: row.label })
++ }));
++ },
++ onPick({ candidate }) {
++ const { sessionId, label } = JSON.parse(candidate.value);
++ const mention = sessionMention(sessionId, label);
++ return { insert: { source: CHATS_SOURCE, ref: mention, label, appearance: "session", clipboardText: mention } };
++ },
++ codec: { clipboardText: (ref) => ref, serialize: (ref) => Promise.resolve(ref) }
++ };
++ return [bots, chats];
+ }
+
+-// src/client/connector-bots.js
+-var import_react26 = require("react");
+-function ownBots(roster) {
+- const me = roster?.self?.memberId;
+- return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me));
+-}
+-var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender" && !(connector.kind === "channel" && connector.mode !== "app"));
+-var isOn = (bot, id) => bot.connectors?.[id] !== false;
+-function ownHint(bot, id) {
+- return isOn(bot, id) ? t("On. This Bot can use it on every turn.") : t("Off. This Bot will not use it.");
+-}
+-function usageText(roster, id) {
+- const bots = ownBots(roster);
+- const own = bots.filter((bot) => isOn(bot, id)).length;
+- return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length });
++// src/client/face-at-filter.js
++var REFERENCE_SOURCE = "reference";
++var optionId = (source, index) => `dsh-slash-option-${source}-${index}`;
++var isSessionRow = (group, item) => group.source === REFERENCE_SOURCE && item?.icon === "session";
++var same = (a, b) => a != null && b != null && a.source === b.source && a.index === b.index;
++function planFaceMenu(state, previous = null) {
++ const hidden = [];
++ const ready = [];
++ let shown = 0;
++ let loading = false;
++ for (const group of state?.groups ?? []) {
++ const items = group.items ?? [];
++ if (group.status !== "ready" && items.length === 0) loading = true;
++ items.forEach((item, index) => {
++ const hide = isSessionRow(group, item);
++ if (hide) hidden.push({ source: group.source, index, title: item.section !== void 0 && item.section !== items[index - 1]?.section });
++ else shown += 1;
++ if (group.status === "ready") ready.push({ source: group.source, index, hide });
++ });
++ }
++ const open = state?.open === true;
++ const blank3 = open && hidden.length > 0 && shown === 0 && !loading;
++ const dismiss = blank3 && (state.groups ?? []).every((group) => group.status === "ready");
++ return { hidden, blank: blank3, dismiss, target: dismiss ? null : steer(ready, state?.highlight ?? null, previous) };
+ }
+-function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) {
+- return (0, import_react26.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
++function steer(ready, highlight, previous) {
++ const at = ready.findIndex((row) => same(row, highlight));
++ if (at < 0 || !ready[at].hide) return null;
++ const from = ready.findIndex((row) => same(row, previous));
++ const n = ready.length;
++ const dir = from >= 0 && from !== at && (from - at + n) % n === 1 ? -1 : 1;
++ for (let step = 1; step < n; step += 1) {
++ const row = ready[((at + dir * step) % n + n) % n];
++ if (!row.hide) return { source: row.source, index: row.index };
++ }
++ return null;
+ }
+-function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) {
+- const bots = ownBots(roster);
+- return (0, import_react26.createElement)(
+- "div",
+- { className: "bt-connector-bots" },
+- bots.length === 0 ? (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react26.createElement)(
+- "div",
+- { key: bot.id, className: "bt-connector-bot" },
+- (0, import_react26.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
+- )),
+- (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
+- );
++function faceMenuCss({ hidden, blank: blank3 }) {
++ if (hidden.length === 0) return "";
++ const rows = hidden.map((row) => `[data-trigger-menu] [id="${optionId(row.source, row.index)}"]`);
++ const titles = hidden.filter((row) => row.title).map((row) => `[data-trigger-menu] [role="presentation"]:has(+ [id="${optionId(row.source, row.index)}"])`);
++ const css = [`${[...rows, ...titles].join(",")}{display:none!important}`];
++ if (blank3) css.push("[data-trigger-menu]{display:none!important}");
++ return css.join("\n");
+ }
+-function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
+- const connected = connectedConnectors(roster);
+- if (connected.length === 0) return null;
+- return (0, import_react26.createElement)(
+- Card2,
+- { title: t("Connectors") },
+- connected.map((connector) => (0, import_react26.createElement)(
+- "div",
+- { key: connector.id, className: "bt-connector-bot" },
+- (0, import_react26.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
+- ))
+- );
++function watchFaceMenu(controller, doc = globalThis.document) {
++ if (typeof controller?.menu?.subscribe !== "function" || !doc?.head) return () => {
++ };
++ const style = doc.createElement("style");
++ style.setAttribute("data-bt-face-at", "");
++ doc.head.appendChild(style);
++ let previous = null;
++ let stopped = false;
++ const later = typeof queueMicrotask === "function" ? queueMicrotask : (fn) => Promise.resolve().then(fn);
++ const apply2 = () => {
++ if (stopped) return;
++ const state = controller.menu.getSnapshot();
++ const plan = planFaceMenu(state, previous);
++ const css = faceMenuCss(plan);
++ if (style.textContent !== css) style.textContent = css;
++ if (plan.dismiss) later(() => {
++ if (!stopped && planFaceMenu(controller.menu.getSnapshot(), previous).dismiss) controller.dismiss();
++ });
++ else if (plan.target) later(() => {
++ if (!stopped) controller.hover(plan.target.source, plan.target.index);
++ });
++ else previous = state.open ? state.highlight : null;
++ };
++ const unsubscribe = controller.menu.subscribe(apply2);
++ apply2();
++ return () => {
++ stopped = true;
++ unsubscribe?.();
++ style.remove();
++ };
+ }
+
+-// src/client/memory-panel.js
+-var import_react27 = require("react");
+-var POLL_MS5 = 1500;
+-var MENU_EXIT_MS2 = 120;
+-var counts = /* @__PURE__ */ new Map();
+-var countListeners = /* @__PURE__ */ new Set();
+-function noteCount(botId, count) {
+- if (counts.get(botId) === count) return;
+- counts.set(botId, count);
+- for (const listener of countListeners) listener();
+-}
+-function useMemoryCount(botId, actions) {
+- const [count, setCount] = (0, import_react27.useState)(counts.get(botId) ?? null);
+- (0, import_react27.useEffect)(() => {
+- const listener = () => setCount(counts.get(botId) ?? null);
+- countListeners.add(listener);
+- listener();
+- let stop = false;
+- void actions.memoryView?.(botId).then((view) => {
+- if (!stop) noteCount(botId, view.entries.length);
+- }).catch(() => {
+- });
+- return () => {
+- stop = true;
+- countListeners.delete(listener);
+- };
+- }, [botId]);
+- return count;
++// src/client/speech.js
++var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle";
++var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]);
++async function speechCatalog(speech) {
++ const result = await speech.catalog();
++ if (!result?.ok) throw new Error(result?.error?.message ?? "Speech catalog failed");
++ const catalog = result.value;
++ const provider = catalog.providers.find((item) => item.id === catalog.selection.providerId);
++ return { catalog, usable: USABLE.has(provider?.preparation?.phase) };
+ }
+-function MemoryRow({ bot, actions }) {
+- const count = useMemoryCount(bot.id, actions);
+- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
+- return (0, import_react27.createElement)(
+- "div",
+- null,
+- (0, import_react27.createElement)("div", { className: "bt-section-title" }, t("Memory")),
+- (0, import_react27.createElement)(
+- "button",
+- { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
+- (0, import_react27.createElement)(MemoryIcon),
+- (0, import_react27.createElement)("span", { className: "bt-mem-row-copy" }, label),
+- (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon))
+- )
+- );
+-}
+-function ago(at, now) {
+- const seconds = Math.max(0, Math.round((now - at) / 1e3));
+- if (seconds < 60) return null;
+- const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" });
+- const minutes = Math.round(seconds / 60);
+- if (minutes < 60) return format.format(-minutes, "minute");
+- const hours = Math.round(minutes / 60);
+- if (hours < 24) return format.format(-hours, "hour");
+- return format.format(-Math.round(hours / 24), "day");
++function waveBytes(samples, rate = 16e3) {
++ const bytes = new Uint8Array(44 + samples.length * 2);
++ const view = new DataView(bytes.buffer);
++ const ascii = (at, text2) => {
++ for (let index = 0; index < text2.length; index += 1) bytes[at + index] = text2.charCodeAt(index);
++ };
++ ascii(0, "RIFF");
++ view.setUint32(4, 36 + samples.length * 2, true);
++ ascii(8, "WAVEfmt ");
++ view.setUint32(16, 16, true);
++ view.setUint16(20, 1, true);
++ view.setUint16(22, 1, true);
++ view.setUint32(24, rate, true);
++ view.setUint32(28, rate * 2, true);
++ view.setUint16(32, 2, true);
++ view.setUint16(34, 16, true);
++ ascii(36, "data");
++ view.setUint32(40, samples.length * 2, true);
++ samples.forEach((sample, index) => {
++ const value = Math.max(-1, Math.min(1, sample));
++ view.setInt16(44 + index * 2, value < 0 ? Math.round(value * 32768) : Math.round(value * 32767), true);
++ });
++ return bytes;
+ }
+-function Skeleton({ label }) {
+- return (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-skel", "aria-busy": true },
+- [0, 1].map((block) => (0, import_react27.createElement)(
+- "div",
+- { key: block, className: "bt-mem-skel-block" },
+- (0, import_react27.createElement)("span", { className: "bt-mem-skel-head" }),
+- (0, import_react27.createElement)("span", null),
+- (0, import_react27.createElement)("span", null),
+- (0, import_react27.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
+- )),
+- label ? (0, import_react27.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
+- );
++function base64Of(bytes) {
++ let text2 = "";
++ for (let index = 0; index < bytes.length; index += 32768) text2 += String.fromCharCode(...bytes.subarray(index, index + 32768));
++ return btoa(text2);
+ }
+-function Summary({ bot, view, regenerate }) {
+- if (view.entries.length === 0) {
+- return (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-empty" },
+- (0, import_react27.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react27.createElement)(MemoryIcon)),
+- (0, import_react27.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
+- (0, import_react27.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
+- );
++var captureError = (kind, cause) => Object.assign(new Error(kind), { kind, cause });
++async function openCapture() {
++ const devices = navigator.mediaDevices;
++ if (!devices?.getUserMedia || typeof MediaRecorder === "undefined") throw captureError("unavailable");
++ let stream;
++ try {
++ stream = await devices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true }, video: false });
++ } catch (error) {
++ throw captureError(error?.name === "NotAllowedError" ? "blocked" : "failed", error);
+ }
+- const failed = view.error && !view.summarizing ? (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-failed", role: "alert" },
+- (0, import_react27.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
+- (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
+- ) : null;
+- if (!view.summary) return (0, import_react27.createElement)(import_react27.Fragment, null, failed, failed ? null : (0, import_react27.createElement)(Skeleton, { label: t("Writing the summary…") }));
+- return (0, import_react27.createElement)(
+- import_react27.Fragment,
+- null,
+- failed,
+- (0, import_react27.createElement)(
+- "div",
+- { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
+- summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react27.createElement)("h3", { key: index }, block.text) : (0, import_react27.createElement)("p", { key: index }, block.text))
+- )
+- );
++ const audio = new AudioContext();
++ const analyser = audio.createAnalyser();
++ analyser.fftSize = 512;
++ audio.createMediaStreamSource(stream).connect(analyser);
++ const frame = new Float32Array(analyser.fftSize);
++ const recorder = new MediaRecorder(stream);
++ const chunks = [];
++ recorder.ondataavailable = (event) => {
++ if (event.data.size > 0) chunks.push(event.data);
++ };
++ recorder.start();
++ let closed = false;
++ const close = () => {
++ if (closed) return;
++ closed = true;
++ if (recorder.state !== "inactive") recorder.stop();
++ stream.getTracks().forEach((track) => track.stop());
++ void audio.close().catch(() => {
++ });
++ };
++ return {
++ level() {
++ if (closed) return 0;
++ analyser.getFloatTimeDomainData(frame);
++ let sum = 0;
++ for (const sample of frame) sum += sample * sample;
++ return Math.min(1, Math.sqrt(sum / frame.length) * 4);
++ },
++ cancel: close,
++ async finish(maxSeconds) {
++ if (closed || recorder.state === "inactive") {
++ close();
++ return null;
++ }
++ try {
++ await new Promise((resolve) => {
++ recorder.onstop = resolve;
++ recorder.stop();
++ });
++ stream.getTracks().forEach((track) => track.stop());
++ const blob = new Blob(chunks, { type: recorder.mimeType });
++ if (blob.size === 0) return null;
++ const decoded = await audio.decodeAudioData(await blob.arrayBuffer());
++ const length = Math.max(1, Math.floor(Math.min(decoded.duration, maxSeconds) * 16e3));
++ const offline = new OfflineAudioContext(1, length, 16e3);
++ const source = offline.createBufferSource();
++ source.buffer = decoded;
++ source.connect(offline.destination);
++ source.start();
++ return waveBytes((await offline.startRendering()).getChannelData(0));
++ } finally {
++ close();
++ }
++ }
++ };
+ }
+-var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
+- const key = `${entry.scope}:${entry.topic ?? ""}`;
+- const group = groups.find((item) => item.key === key);
+- if (group) group.entries.push(entry);
+- else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
+- return groups;
+-}, []);
+-function EntryRow({ entry, remove }) {
+- const [confirming, setConfirming] = (0, import_react27.useState)(false);
+- const [busy, setBusy] = (0, import_react27.useState)(false);
+- (0, import_react27.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 3e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
+- const meta = [entry.added, by].filter(Boolean).join(" · ");
+- const click = async () => {
+- if (!confirming) {
+- setConfirming(true);
+- return;
++async function transcribeWave(speech, catalog, wave, signal) {
++ if (wave.byteLength > catalog.maxAudioBytes) throw captureError("too-long");
++ const result = await speech.transcribe({ audioBase64: base64Of(wave), ...catalog.selection }, signal);
++ if (!result?.ok) throw Object.assign(new Error(result?.error?.message ?? "Transcription failed"), { kind: "service" });
++ return String(result.value?.text ?? "").trim();
++}
++function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) {
++ let spokeAt = 0;
++ let quietSince = 0;
++ return (level, now) => {
++ if (level >= loud) {
++ spokeAt ||= now;
++ quietSince = 0;
++ return "speech";
+ }
+- setBusy(true);
+- if (!await remove(entry)) setBusy(false);
++ if (!spokeAt) return "waiting";
++ if (level < quiet) quietSince ||= now;
++ return quietSince && now - quietSince >= quietMs ? "done" : "speech";
+ };
+- return (0, import_react27.createElement)(
+- "li",
+- { className: "bt-mem-entry", "data-busy": busy || void 0 },
+- (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-entry-copy" },
+- (0, import_react27.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
+- meta ? (0, import_react27.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
+- ),
+- (0, import_react27.createElement)("button", {
+- type: "button",
+- className: "bt-mem-remove",
+- "data-confirm": confirming || void 0,
+- disabled: busy,
+- "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
+- title: confirming ? void 0 : t("Remove from memory"),
+- onClick: click
+- }, confirming ? t("Remove") : (0, import_react27.createElement)(TrashIcon))
+- );
+ }
+-function Entries({ bot, view, remove }) {
+- const groups = groupEntries(view.entries);
+- const sections = [
+- { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
+- { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
++
++// src/client/model-menu.js
++var import_react22 = require("react");
++var import_react_dom8 = require("react-dom");
++var MENU_EXIT_MS = 120;
++function groupModels(models) {
++ const groups = /* @__PURE__ */ new Map();
++ for (const model of models ?? []) {
++ const provider = model.provider ?? model.providerName ?? model.ref.split("/")[0];
++ if (!groups.has(provider)) groups.set(provider, []);
++ groups.get(provider).push(model);
++ }
++ const names = /* @__PURE__ */ new Map();
++ for (const [provider, list] of groups) {
++ const name = list[0].providerName ?? provider;
++ names.set(name, (names.get(name) ?? 0) + 1);
++ }
++ return [...groups].map(([provider, list]) => {
++ const name = list[0].providerName ?? provider;
++ return [names.get(name) > 1 ? `${name} (${provider})` : name, list];
++ });
++}
++var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref);
++var effortCache = /* @__PURE__ */ new Map();
++function useEfforts(served, ref, actions) {
++ const [fetched, setFetched] = (0, import_react22.useState)(void 0);
++ (0, import_react22.useEffect)(() => {
++ setFetched(void 0);
++ if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0;
++ let live = true;
++ if (effortCache.has(ref)) setFetched(effortCache.get(ref));
++ else void Promise.resolve(actions.efforts(ref)).then((value) => {
++ const reasoning = value?.efforts ? value : null;
++ effortCache.set(ref, reasoning);
++ if (live) setFetched(reasoning);
++ }).catch(() => {
++ });
++ return () => {
++ live = false;
++ };
++ }, [ref, served?.reasoning !== void 0, actions]);
++ return served?.reasoning ?? fetched;
++}
++function effortRows(reasoning) {
++ if (reasoning?.efforts === void 0 || reasoning.efforts.length === 0) return [];
++ return [
++ { effort: void 0, label: t("Default") },
++ ...reasoning.efforts.map((effort) => ({ effort: effort.id, label: effort.name }))
+ ];
+- return (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-entries" },
+- sections.map((section) => (0, import_react27.createElement)(
+- "section",
+- { key: section.scope, className: "bt-mem-section" },
+- (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-section-head" },
+- (0, import_react27.createElement)("h3", null, section.title),
+- section.hint ? (0, import_react27.createElement)("span", null, section.hint) : null,
+- (0, import_react27.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
+- ),
+- groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react27.createElement)(
+- import_react27.Fragment,
+- { key: group.key },
+- group.topic ? (0, import_react27.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
+- (0, import_react27.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react27.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
+- )) : (0, import_react27.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
++}
++function EffortRows({ reasoning, current, onPick }) {
++ return (0, import_react22.createElement)(
++ import_react22.Fragment,
++ null,
++ (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
++ (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")),
++ effortRows(reasoning).map((row) => (0, import_react22.createElement)(
++ "button",
++ {
++ key: row.effort ?? "default",
++ type: "button",
++ role: "menuitemradio",
++ "aria-checked": row.effort === current,
++ onClick: () => onPick(row.effort)
++ },
++ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, row.label),
++ (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react22.createElement)(CheckIcon) : null)
+ ))
+ );
+ }
+-function changeLine(change) {
+- const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
+- if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
+- if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
+- if (change.op === "edit") return t("Updated: {text}", { text: text2 });
+- return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
+-}
+-function Reply({ answer, dismiss }) {
+- return (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-reply", role: "status" },
+- (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-reply-head" },
+- (0, import_react27.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
+- answer.pending ? null : (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react27.createElement)(CloseIcon))
+- ),
+- answer.pending ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react27.createElement)(
+- import_react27.Fragment,
+- null,
+- answer.reply ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
+- answer.changes.length > 0 ? (0, import_react27.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react27.createElement)(
+- "li",
+- { key: index, "data-ok": change.ok || void 0 },
+- change.ok ? (0, import_react27.createElement)(CheckIcon) : (0, import_react27.createElement)(CloseIcon),
+- (0, import_react27.createElement)("span", null, changeLine(change))
+- ))) : null
+- )
+- );
+-}
+-function useMemoryView(botId, actions) {
+- const [view, setView] = (0, import_react27.useState)(null);
+- const [error, setError] = (0, import_react27.useState)("");
+- const timer = (0, import_react27.useRef)(0);
+- const latest = (0, import_react27.useRef)(0);
+- const alive = (0, import_react27.useRef)(true);
+- const show = (0, import_react27.useCallback)((next) => {
+- setView(next);
+- setError("");
+- noteCount(botId, next.entries.length);
+- }, [botId]);
+- const load = (0, import_react27.useCallback)(async (flags = {}) => {
+- clearTimeout(timer.current);
+- const ticket = ++latest.current;
+- try {
+- const next = await actions.memoryView(botId, flags);
+- if (!alive.current || ticket !== latest.current) return;
+- show(next);
+- if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
+- } catch (failure) {
+- if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
+- }
+- }, [botId]);
+- (0, import_react27.useEffect)(() => {
+- alive.current = true;
+- void load({ summarize: true });
+- return () => {
+- alive.current = false;
+- clearTimeout(timer.current);
+- };
+- }, [botId]);
+- return { view, error, load, show };
+-}
+-function useNow3(every) {
+- const [now, setNow] = (0, import_react27.useState)(Date.now());
+- (0, import_react27.useEffect)(() => {
+- const id = setInterval(() => setNow(Date.now()), every);
+- return () => clearInterval(id);
+- }, [every]);
+- return now;
++function ModelRows({ roster, current, onPick }) {
++ return groupModels(roster.models).map(([provider, list]) => (0, import_react22.createElement)(
++ import_react22.Fragment,
++ { key: list[0].provider ?? provider },
++ (0, import_react22.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider),
++ list.map((model) => (0, import_react22.createElement)(
++ "button",
++ {
++ key: model.ref,
++ type: "button",
++ role: "menuitemradio",
++ "aria-checked": model.ref === current,
++ title: model.ref,
++ onClick: () => onPick(model.ref)
++ },
++ (0, import_react22.createElement)("span", { className: "bt-menu-label" }, model.name),
++ model.ref === roster.defaultModel ? (0, import_react22.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null,
++ (0, import_react22.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react22.createElement)(CheckIcon) : null)
++ ))
++ ));
+ }
+-function MemoryDialog({ bot, actions, leaving = false }) {
+- const { view, error, load, show } = useMemoryView(bot.id, actions);
+- const [page, setPage] = (0, import_react27.useState)("summary");
+- const [menu, setMenu] = (0, import_react27.useState)(false);
+- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2);
+- const [text2, setText] = (0, import_react27.useState)("");
+- const [answer, setAnswer] = (0, import_react27.useState)(null);
+- const menuRef = (0, import_react27.useRef)(null);
+- const dotsRef = (0, import_react27.useRef)(null);
+- const inputRef = (0, import_react27.useRef)(null);
+- const now = useNow3(3e4);
+- const close = (0, import_react27.useCallback)(() => actions.closeMemory(), []);
+- useEscape(close);
+- (0, import_react27.useEffect)(() => {
+- if (!menu) return void 0;
++var Caret = () => (0, import_react22.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react22.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" }));
++function useAnchoredMenu() {
++ const [place, setPlace] = (0, import_react22.useState)(null);
++ const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS);
++ const button = (0, import_react22.useRef)(null);
++ const menu = (0, import_react22.useRef)(null);
++ const close = () => setPlace(null);
++ (0, import_react22.useEffect)(() => {
++ if (place === null) return void 0;
+ const onDown = (event) => {
+- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return;
+- setMenu(false);
++ if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return;
++ close();
+ };
+ const onKey = (event) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+- setMenu(false);
+- dotsRef.current?.focus();
++ close();
++ button.current?.focus();
+ }
+ };
++ const onScroll = (event) => {
++ if (!menu.current?.contains(event.target)) close();
++ };
+ window.addEventListener("mousedown", onDown);
+ window.addEventListener("keydown", onKey, true);
++ window.addEventListener("scroll", onScroll, true);
+ return () => {
+ window.removeEventListener("mousedown", onDown);
+ window.removeEventListener("keydown", onKey, true);
++ window.removeEventListener("scroll", onScroll, true);
+ };
+- }, [menu]);
+- const regenerate = () => {
+- setMenu(false);
+- setPage("summary");
+- void load({ regenerate: true });
++ }, [place]);
++ const open = () => {
++ const rect = button.current.getBoundingClientRect();
++ const width = Math.max(240, rect.width);
++ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
++ const below = window.innerHeight - rect.bottom;
++ setPlace(below >= 260 || below >= rect.top ? { left, width, top: rect.bottom + 4, maxHeight: below - 16, "--bt-origin": "top right" } : { left, width, bottom: window.innerHeight - rect.top + 4, maxHeight: rect.top - 16, "--bt-origin": "bottom right" });
+ };
+- const openEntries = () => {
+- setMenu(false);
+- setPage("entries");
++ return { isOpen: place !== null, shownPlace, leaving, button, menu, open, close };
++}
++function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffort }) {
++ if (bot?.remote !== void 0) return null;
++ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
++ const shown = modelOf(bot, roster);
++ const current = bot.model ?? bot.appliedModel;
++ const served = servedOf(roster, current);
++ const reasoning = useEfforts(served, current, actions);
++ const picked = reasoning?.efforts?.some((effort) => effort.id === bot.effort) ? bot.effort : void 0;
++ const currentEffort = picked ?? reasoning?.defaultEffort;
++ const effortLabel = currentEffort === void 0 ? reasoning?.efforts?.length > 0 ? t("Default") : void 0 : reasoning.efforts.find((effort) => effort.id === currentEffort)?.name ?? currentEffort;
++ const toggle = () => {
++ if (isOpen) {
++ close();
++ return;
++ }
++ void actions?.refreshModels?.();
++ open();
+ };
+- const backToSummary = () => {
+- setPage("summary");
+- void load({ summarize: true });
++ const pick2 = (ref) => {
++ close();
++ if (onPick) onPick(ref);
++ else if (ref !== current || !bot.model) void run(actions.updateBot(bot.id, { model: ref }));
+ };
+- const remove = async (entry) => {
+- try {
+- show(await actions.memoryForget(bot.id, entry));
+- return true;
+- } catch (failure) {
+- setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) });
+- return false;
+- }
++ const pickEffort = (effort) => {
++ close();
++ if (onPickEffort) onPickEffort(effort);
++ else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null }));
+ };
+- const ask = async (event) => {
+- event.preventDefault();
+- const asked = text2.trim();
+- if (asked === "" || answer?.pending) return;
+- setText("");
+- setAnswer({ asked, pending: true });
+- try {
+- const result = await actions.memoryAsk(bot.id, asked);
+- setAnswer({ asked, reply: result.reply, changes: result.changes });
+- show(result.view);
+- if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" });
+- } catch (failure) {
+- setAnswer({ asked, error: failure?.message ?? String(failure) });
+- }
+- inputRef.current?.focus();
++ const models = roster.models ?? [];
++ return (0, import_react22.createElement)(
++ import_react22.Fragment,
++ null,
++ (0, import_react22.createElement)(
++ "button",
++ {
++ ref: button,
++ type: "button",
++ className: "bt-model-trigger",
++ "data-wide": wide || void 0,
++ "aria-haspopup": "menu",
++ "aria-expanded": isOpen,
++ "aria-label": t("Model: {model}", { model: shown?.name ?? t("none") }),
++ "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0,
++ onClick: toggle
++ },
++ (0, import_react22.createElement)(
++ "span",
++ { className: "bt-model-copy" },
++ (0, import_react22.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")),
++ shown ? (0, import_react22.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null
++ ),
++ (0, import_react22.createElement)(Caret)
++ ),
++ shownPlace ? (0, import_react_dom8.createPortal)((0, import_react22.createElement)(
++ "div",
++ { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 },
++ models.length > 0 ? (0, import_react22.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react22.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")),
++ effortRows(reasoning).length > 0 ? (0, import_react22.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null,
++ (0, import_react22.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
++ (0, import_react22.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
++ close();
++ openHarnessModels();
++ } }, (0, import_react22.createElement)(SlidersIcon), (0, import_react22.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…")))
++ ), document.body) : null
++ );
++}
++function ModelChoiceDock({ sessionId, useRoster, actions }) {
++ const roster = useRoster((value) => value);
++ const bot = roster.byId[sessionId];
++ const [busy, setBusy] = (0, import_react22.useState)("");
++ const [error, setError] = (0, import_react22.useState)("");
++ const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready;
++ (0, import_react22.useEffect)(() => {
++ if (shown) void actions.refreshModels?.();
++ }, [shown, sessionId]);
++ if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null;
++ if (bot.remote) return bot.remote.gone ? (0, import_react22.createElement)(RemoteGoneDock, { bot }) : null;
++ const models = roster.models ?? [];
++ const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false;
++ if (bot.model && (!lost || models.length === 0)) return null;
++ const pick2 = (ref) => {
++ setBusy(ref);
++ setError("");
++ void Promise.resolve(actions.updateBot(bot.id, { model: ref })).catch((failure) => {
++ setError(failure?.message ?? String(failure));
++ void actions.refreshModels?.();
++ }).finally(() => setBusy(""));
+ };
+- const count = view?.entries.length ?? 0;
+- const when3 = view?.summary ? ago(view.summary.at, now) : null;
+- const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : "";
+- const busy = answer?.pending === true;
+- return (0, import_react27.createElement)(
++ const text2 = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name });
++ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" })] : [
++ ...groupModels(models).map(([provider, list]) => (0, import_react22.createElement)(
++ "div",
++ { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider },
++ (0, import_react22.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider),
++ (0, import_react22.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react22.createElement)("button", {
++ key: model.ref,
++ type: "button",
++ className: "bt-model-chip",
++ disabled: busy !== "",
++ "aria-busy": busy === model.ref || void 0,
++ "aria-label": `${model.name} · ${provider}`,
++ title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref,
++ onClick: () => pick2(model.ref)
++ }, model.name, busy === model.ref ? (0, import_react22.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null)))
++ )),
++ (0, import_react22.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react22.createElement)(SlidersIcon), t("Manage models"))
++ ];
++ return (0, import_react22.createElement)(
+ "div",
+- { className: "bt-mem-layer", "data-leaving": leaving || void 0 },
+- (0, import_react27.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
+- (0, import_react27.createElement)(
++ { className: "bt-model-dock" },
++ (0, import_react22.createElement)(
+ "div",
+- { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) },
+- (0, import_react27.createElement)(
++ { className: "bt-astack", "data-lead": "" },
++ (0, import_react22.createElement)(
+ "div",
+- { className: "bt-mem-head" },
+- page === "entries" ? (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react27.createElement)(ChevronLeftIcon)) : null,
+- (0, import_react27.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
+- (0, import_react27.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
+- (0, import_react27.createElement)("span", { className: "bt-mem-head-gap" }),
+- (0, import_react27.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react27.createElement)(DotsIcon)),
+- (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react27.createElement)(CloseIcon)),
+- menuShown ? (0, import_react27.createElement)(
++ { className: "bt-msg" },
++ (0, import_react22.createElement)(
+ "div",
+- { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 },
+- (0, import_react27.createElement)(
+- "button",
+- { type: "button", role: "menuitem", onClick: openEntries },
+- (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
+- (0, import_react27.createElement)("span", { className: "bt-menu-hint" }, String(count))
+- ),
+- (0, import_react27.createElement)(
+- "button",
+- { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate },
+- (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
+- )
+- ) : null
+- ),
+- (0, import_react27.createElement)(
+- "div",
+- { className: "bt-mem-body" },
+- error ? (0, import_react27.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
+- view === null ? error ? null : (0, import_react27.createElement)(Skeleton) : page === "entries" ? (0, import_react27.createElement)(Entries, { bot, view, remove }) : (0, import_react27.createElement)(Summary, { bot, view, regenerate })
+- ),
+- (0, import_react27.createElement)(
++ { className: "bt-msg-line" },
++ (0, import_react22.createElement)(BotMark, { bot, size: 22 }),
++ (0, import_react22.createElement)("div", { className: "bt-bubble" }, text2)
++ )
++ )
++ ),
++ (0, import_react22.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices),
++ error ? (0, import_react22.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null
++ );
++}
++function RemoteGoneDock({ bot }) {
++ return (0, import_react22.createElement)(
++ "div",
++ { className: "bt-model-dock", "data-flybot": "remote-gone" },
++ (0, import_react22.createElement)(
++ "div",
++ { className: "bt-astack", "data-lead": "" },
++ (0, import_react22.createElement)(
+ "div",
+- { className: "bt-mem-foot" },
+- answer ? (0, import_react27.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
+- (0, import_react27.createElement)(
+- "form",
+- { className: "bt-mem-ask", onSubmit: ask },
+- (0, import_react27.createElement)("input", {
+- ref: inputRef,
+- className: "bt-mem-input",
+- value: text2,
+- maxLength: 2e3,
+- disabled: view === null,
+- placeholder: t("Ask or update"),
+- "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }),
+- onChange: (event) => setText(event.target.value)
+- }),
+- (0, import_react27.createElement)(
+- "button",
+- { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") },
+- busy ? (0, import_react27.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react27.createElement)(ArrowUpIcon)
+- )
++ { className: "bt-msg" },
++ (0, import_react22.createElement)(
++ "div",
++ { className: "bt-msg-line" },
++ (0, import_react22.createElement)(BotMark, { bot, size: 22 }),
++ (0, import_react22.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle }))
+ )
+ )
+ )
+ );
+ }
+-
+-// src/client/standing-approvals.js
+-var import_react28 = require("react");
+-var standingOf = (target, roster) => {
+- const me = roster?.self?.memberId;
+- return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me);
+-};
+-function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card2 }) {
+- const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? [];
+- if (grants.length === 0) return null;
+- const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking.");
+- return (0, import_react28.createElement)(
+- Card2,
+- { title: t("Always allowed") },
+- grants.map((grant) => (0, import_react28.createElement)(
+- "div",
+- { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind },
+- (0, import_react28.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react28.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
+- (0, import_react28.createElement)("span", { className: "bt-set-hint" }, hint)
+- ),
+- (0, import_react28.createElement)("button", {
+- type: "button",
+- className: "bt-soft bt-soft-sm",
+- "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }),
+- onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind))
+- }, t("Remove"))
+- ))
+- );
+-}
+-
+-// src/client/bot-settings.js
+-var import_react29 = require("react");
+-var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
+-async function avatarImage(file) {
+- if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
+- if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
+- const bitmap = await createImageBitmap(file);
+- const side = 160;
+- const canvas = document.createElement("canvas");
+- canvas.width = side;
+- canvas.height = side;
+- const context = canvas.getContext("2d");
+- context.imageSmoothingQuality = "high";
+- const crop = Math.min(bitmap.width, bitmap.height);
+- context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
+- bitmap.close?.();
+- for (const quality of [0.9, 0.75, 0.6]) {
+- const url = canvas.toDataURL("image/webp", quality);
+- if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
+- }
+- const png = canvas.toDataURL("image/png");
+- if (png.length < 15e4) return png;
+- throw new Error(t("That image is too large"));
+-}
+-function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) {
+- const [draft, setDraft] = (0, import_react29.useState)(null);
+- const reverting = (0, import_react29.useRef)(false);
+- const commit = () => {
+- const next = draft?.trim();
+- setDraft(null);
+- if (reverting.current) {
+- reverting.current = false;
+- return;
+- }
+- if (next === void 0 || next === (value ?? "") || required && next === "") return;
+- void onSave(next);
+- };
+- return (0, import_react29.createElement)(multiline ? "textarea" : "input", {
+- className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
+- value: draft ?? value ?? "",
+- placeholder,
+- maxLength,
+- required,
+- "aria-label": label,
+- spellCheck: false,
+- onChange: (event) => setDraft(event.target.value),
+- onBlur: commit,
+- onKeyDown: (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- reverting.current = true;
+- event.currentTarget.blur();
+- }
+- if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur();
+- }
+- });
+-}
+-function Row({ label, hint, stack = false, children }) {
+- return (0, import_react29.createElement)(
+- "label",
+- { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+- children
+- );
+-}
+-function Card({ title, children }) {
+- return (0, import_react29.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react29.createElement)("h3", null, title) : null, (0, import_react29.createElement)("div", { className: "bt-set-card" }, children));
+-}
+-function Switch({ on, label, disabled, onChange }) {
+- return (0, import_react29.createElement)(
+- "button",
+- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
+- (0, import_react29.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
+- );
+-}
+-function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) {
+- return (0, import_react29.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+- (0, import_react29.createElement)(Switch, { on, label, disabled, onChange })
+- );
++function setupChoices(actions) {
++ return [
++ actions.canSignIn?.() ? [t("Sign in to DeepSeek"), () => actions.signIn()] : null,
++ [t("Enter a DeepSeek API key"), () => openHarnessOnboarding("deepseek-official")],
++ [t("Open settings to set up a custom model"), openHarnessModels]
++ ].filter(Boolean);
+ }
+-function modelHint(bot, roster) {
+- const shown = modelOf(bot, roster);
+- if (shown === void 0) return t("Pick the model this Bot runs on.");
+- if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one.");
+- if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it.");
+- return t("Applies from the next message.");
++var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md";
++function KeyHelpLink() {
++ return (0, import_react22.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key"));
+ }
+-function ShapeRing({ shape, size }) {
+- const maskId = (0, import_react29.useRef)("");
+- if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`;
+- const def = shapeOf(shape);
+- const unit = def.size / size;
+- const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`;
+- return (0, import_react29.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
++function missingModelKey(message) {
++ const text2 = String(message ?? "");
++ if (!/\bno api key\b|\bmissing (?:an? )?api key\b|\bapi key (?:is )?(?:missing|not set|not configured)\b/i.test(text2)) return void 0;
++ return { route: /provider route "([^"]+)"/i.exec(text2)?.[1] ?? "" };
+ }
+-function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
+- const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))];
+- if (!shapes.includes(look.shape)) shapes.push(look.shape);
+- const rows = [];
+- for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS));
+- return (0, import_react29.createElement)(
+- import_react29.Fragment,
+- null,
+- (0, import_react29.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react29.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react29.createElement)("button", {
+- key: id,
+- type: "button",
+- className: "bt-shape",
+- "aria-label": t(shapeOf(id).label),
+- title: t(shapeOf(id).label),
+- "aria-pressed": !hasPhoto && look.shape === id,
+- onClick: () => onShape(id)
+- }, (0, import_react29.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react29.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
+- (0, import_react29.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react29.createElement)("button", {
+- key: color,
+- type: "button",
+- className: "bt-color",
+- "aria-label": t(capitalize(color)),
+- title: t(capitalize(color)),
+- "aria-pressed": look.color === color,
+- onClick: () => onColor(color)
+- }, (0, import_react29.createElement)("span", { style: { background: paintCss(color) } }))))
+- );
++function modelKeyChoices(actions, route) {
++ if (/^deepseek/i.test(route)) return setupChoices(actions);
++ return [[t("Manage models"), openHarnessModels]];
+ }
+-function AvatarEditor({ bot, actions, run, onClose }) {
+- const look = lookOf(bot);
+- const [tab, setTab] = (0, import_react29.useState)(look.image ? "upload" : "bot");
+- const [over, setOver] = (0, import_react29.useState)(false);
+- const ref = (0, import_react29.useRef)(null);
+- const fileRef = (0, import_react29.useRef)(null);
+- const upload = (file) => {
+- if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
++function TurnErrorCell({ node, t: chatT, actions }) {
++ const data = node?.data ?? {};
++ const code = typeof data.code === "string" && data.code ? data.code : void 0;
++ const raw = String(data.message ?? "");
++ const shell = (key, fallback) => {
++ const text2 = typeof chatT === "function" ? chatT(key) : void 0;
++ return typeof text2 === "string" && text2 !== "" && text2 !== key ? text2 : fallback;
+ };
+- (0, import_react29.useEffect)(() => {
+- const onDown = (event) => {
+- if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
+- onClose();
+- };
+- const onKey = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- onClose();
+- }
+- };
+- window.addEventListener("mousedown", onDown);
+- window.addEventListener("keydown", onKey, true);
+- return () => {
+- window.removeEventListener("mousedown", onDown);
+- window.removeEventListener("keydown", onKey, true);
+- };
+- }, [onClose]);
+- (0, import_react29.useEffect)(() => {
+- if (tab !== "upload") return void 0;
+- const onPaste = (event) => {
+- const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/"));
+- if (file) {
+- event.preventDefault();
+- upload(file);
+- }
+- };
+- window.addEventListener("paste", onPaste);
+- return () => window.removeEventListener("paste", onPaste);
+- }, [tab, bot]);
+- const hasPhoto = Boolean(look.image);
+- const glide = useGlide(tab);
+- return (0, import_react29.createElement)(
++ const missing = missingModelKey(raw);
++ const title = code === "ACCOUNT_SIGNED_OUT" ? shell("message.accountStopped", t("This turn failed")) : shell("message.turnError", t("This turn failed"));
++ const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw;
++ const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : [];
++ const deepseek = missing ? /^deepseek/i.test(missing.route) : false;
++ return (0, import_react22.createElement)(
+ "div",
+- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
+- (0, import_react29.createElement)(
++ { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 },
++ (0, import_react22.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }),
++ (0, import_react22.createElement)(
+ "div",
+- { className: "bt-avedit-top" },
+- (0, import_react29.createElement)(
+- "div",
+- { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
+- (0, import_react29.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react29.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
+- ),
+- (0, import_react29.createElement)("button", {
+- type: "button",
+- className: "bt-avedit-tab bt-avedit-reset",
+- title: t("Reset character to default"),
+- onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
+- }, t("Reset"))
++ { className: "bt-turn-error-copy" },
++ (0, import_react22.createElement)("span", { className: "bt-turn-error-title" }, title),
++ (0, import_react22.createElement)("span", { className: "bt-turn-error-message" }, message),
++ code ? (0, import_react22.createElement)("code", { className: "bt-turn-error-code" }, code) : null
+ ),
+- tab === "bot" ? (0, import_react29.createElement)(CharacterGrid, {
+- look,
+- hasPhoto,
+- onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })),
+- onColor: (color) => run(actions.updateBot(bot.id, { color }))
+- }) : null,
+- tab === "upload" ? (0, import_react29.createElement)(
++ choices.length > 0 ? (0, import_react22.createElement)(
+ "div",
+- {
+- className: "bt-dropzone",
+- "data-over": over || void 0,
+- onDragOver: (event) => {
+- event.preventDefault();
+- setOver(true);
+- },
+- onDragLeave: () => setOver(false),
+- onDrop: (event) => {
+- event.preventDefault();
+- setOver(false);
+- upload(event.dataTransfer.files?.[0]);
+- }
+- },
+- hasPhoto ? (0, import_react29.createElement)("img", { src: look.image, alt: "" }) : null,
+- (0, import_react29.createElement)("div", null, t("Drag, drop, or paste an image")),
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-actions", style: { justifyContent: "center" } },
+- (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
+- hasPhoto ? (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
+- ),
+- (0, import_react29.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+- const file = event.target.files?.[0];
+- event.target.value = "";
+- upload(file);
+- } })
++ { className: "bt-turn-error-actions" },
++ choices.map(([label, run]) => (0, import_react22.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)),
++ deepseek ? (0, import_react22.createElement)(KeyHelpLink, { key: "key-help" }) : null
+ ) : null
+ );
+ }
+-function MemoryCard({ bot, actions }) {
+- const count = useMemoryCount(bot.id, actions);
+- const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
+- return (0, import_react29.createElement)(
+- Card,
+- { title: t("Memory") },
+- (0, import_react29.createElement)(
+- "button",
+- { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label)),
+- (0, import_react29.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react29.createElement)(ChevronRightIcon))
+- )
+- );
++function DeepSeekSignIn({ useUi, actions }) {
++ const open = useUi((state) => state.signIn);
++ const entry = open ? actions.signInEntry?.() : null;
++ if (!entry) return null;
++ return (0, import_react22.createElement)(SignInHost, { key: open, entry, actions });
+ }
+-function BotSettingsForm({ bot, roster, actions, run }) {
+- const [editing, setEditing] = (0, import_react29.useState)(false);
+- const [confirming, setConfirming] = (0, import_react29.useState)(false);
+- const closeEditor = (0, import_react29.useCallback)(() => setEditing(false), []);
+- (0, import_react29.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 4e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- if (bot.remote) return null;
+- const isMainBot = isMainOf(roster, bot.id);
+- const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1;
+- const isTile = roster.mainBotId === bot.id;
+- const save = (patch) => run(actions.updateBot(bot.id, patch));
+- const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id)));
+- const remove = () => {
+- if (confirming) void run(actions.deleteBot(bot.id));
+- else setConfirming(true);
++function SignInHost({ entry, actions }) {
++ const [ops] = (0, import_react22.useState)(() => entry.inject?.() ?? {});
++ const close = () => actions.closeSignIn();
++ const useHook = (source) => (selector) => (0, import_react22.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot()));
++ const [hooks] = (0, import_react22.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) }));
++ return (0, import_react22.createElement)(entry.component, {
++ ...ops,
++ ...hooks,
++ t: actions.accountT(),
++ complete: close,
++ useApiKey: () => {
++ close();
++ openHarnessOnboarding("deepseek-official");
++ }
++ });
++}
++var MODEL_CSS = `
++.bt-turn-error{display:grid;grid-template-columns:10px minmax(0,1fr);column-gap:8px;row-gap:8px;padding:2px 0;font-size:13px;line-height:20px}
++.bt-turn-error-dot{width:6px;height:6px;margin-top:7px;border-radius:50%;background:#e02135}
++.bt-turn-error-copy{min-width:0;overflow-wrap:anywhere}
++.bt-turn-error-title{margin-right:6px;font-weight:600;color:#e02135}
++.bt-turn-error-message{color:var(--bt-ink-2)}
++.bt-turn-error-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
++.bt-turn-error-actions{grid-column:2;display:flex;flex-wrap:wrap;align-items:center;gap:6px}
++.bt-model-help{display:inline-flex;align-items:center;min-height:28px;padding:0 4px;font-size:12px;line-height:16px;color:var(--bt-ink-2);text-decoration:underline;text-underline-offset:2px}
++.bt-model-help:hover{color:var(--bt-ink)}
++.bt-model-help:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px;border-radius:4px}
++.bt-model-trigger{display:inline-flex;align-items:center;gap:8px;max-width:100%;min-width:0;height:auto;min-height:32px;padding:4px 8px 4px 10px;border:0;border-radius:8px;background:var(--bt-hover);color:var(--bt-ink);font:inherit;text-align:left;cursor:pointer;transition:background-color .12s ease}
++.bt-model-trigger:hover,.bt-model-trigger[aria-expanded=true]{background:var(--bt-active)}
++.bt-model-trigger:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
++.bt-model-trigger[data-missing] .bt-model-provider{color:#c21d2e}
++.bt-model-trigger[data-wide]{display:flex;width:100%;min-height:48px;padding:7px 12px;justify-content:space-between;border-radius:12px;background:var(--bt-card);box-shadow:inset 0 0 0 1px var(--bt-line)}
++.bt-model-trigger[data-wide]:hover,.bt-model-trigger[data-wide][aria-expanded=true]{background:var(--bt-hover)}
++.bt-model-copy{display:flex;flex-direction:column;min-width:0}
++.bt-model-name{font-size:13px;line-height:18px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-model-provider{font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-model-caret{flex:none;color:var(--bt-ink-3);transition:${spring(SPRING_SHAPE, "transform")}}
++.bt-model-trigger[aria-expanded=true] .bt-model-caret{transform:rotate(180deg)}
++.bt-model-menu{overflow-y:auto;scrollbar-width:thin}
++.bt-model-group{flex:none;padding:8px 8px 2px;font-size:11px;line-height:14px;font-weight:500;color:var(--bt-ink-3);text-transform:none}
++.bt-model-group:first-child{padding-top:2px}
++.bt-model-tick{flex:none;width:16px;height:16px;display:inline-flex;color:var(--bt-ink-2)}
++.bt-model-tick svg{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} 60ms backwards}
++.bt-model-none{padding:8px;font-size:13px;line-height:18px;color:var(--bt-ink-2)}
++.bt-model-dock{width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:0 0 10px;box-sizing:border-box;animation:bt-model-in ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards}
++.bt-model-chips{display:flex;flex-wrap:wrap;gap:6px;margin-left:30px;max-width:min(560px,calc(100% - 30px));max-height:min(240px,36vh);overflow-y:auto;scrollbar-width:thin}
++.bt-model-choice-group{display:flex;flex-direction:column;gap:6px;width:100%;min-width:0}
++.bt-model-choice-provider{padding-top:4px;font-size:12px;line-height:16px;font-weight:500;color:var(--bt-ink-2);overflow-wrap:anywhere}
++.bt-model-choice-chips{display:flex;flex-wrap:wrap;gap:6px}
++.bt-model-chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:5px 12px;border:.5px solid var(--bt-line-2);border-radius:999px;background:var(--bt-surface,transparent);color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;cursor:pointer;transition:background-color .12s ease,border-color .12s ease}
++.bt-model-chip:hover:not(:disabled){background:var(--bt-hover)}
++.bt-model-chip:focus-visible{outline:2px solid var(--bt-accent);outline-offset:1px}
++.bt-model-chip-quiet{color:var(--bt-ink-2)}
++.bt-model-chip svg{width:14px;height:14px}
++.bt-model-chip:disabled{cursor:default}
++.bt-model-chip:disabled:not([aria-busy]){opacity:.5}
++.bt-model-error{margin-left:30px}
++@keyframes bt-model-in{from{opacity:0;transform:translateY(6px)}}
++.bt-model-spin{flex:none;width:14px;height:14px;border-radius:50%;border:1.5px solid var(--bt-line-2);border-top-color:var(--bt-ink-2);animation:bt-model-spin .7s linear infinite}
++@keyframes bt-model-spin{to{transform:rotate(360deg)}}
++@media (prefers-reduced-motion:reduce){.bt-model-dock{animation:none}.bt-model-spin{animation-duration:2s}.bt-model-caret{transition:none}.bt-model-tick svg{animation:none}}
++`;
++
++// src/client/model-test.js
++var import_react23 = require("react");
++var FAILURE_COPY = {
++ NO_MODEL: "Pick a model first.",
++ NO_KEY: "This model has no API key yet. Set one up in the model settings.",
++ AUTH: "The API key was rejected. Check it in the model settings.",
++ QUOTA: "The account is out of quota.",
++ RATE_LIMIT: "Too many requests right now. Try again in a moment.",
++ TIMEOUT: "The model did not answer within 30 seconds.",
++ NOT_SERVED: "This model is not available now. Check its provider in the model settings.",
++ MODEL_NOT_FOUND: "The provider does not know this model. Check the model name.",
++ TRANSPORT: "Could not reach the model service. Check the endpoint address and your network.",
++ SERVER: "The model service failed. Try again later.",
++ INVALID_REQUEST: "The model service refused the request. Check the endpoint and the model.",
++ UNKNOWN: "The test failed."
++};
++function testOutcome(result, modelName) {
++ if (result?.ok === true) {
++ const seconds = typeof result.ms === "number" ? Math.max(0.1, Math.round(result.ms / 100) / 10) : void 0;
++ return { ok: true, text: seconds === void 0 ? t("Works. {model} answered.", { model: modelName }) : t("Works. {model} answered in {seconds} s.", { model: modelName, seconds }) };
++ }
++ const code = Object.hasOwn(FAILURE_COPY, result?.code) ? result.code : "UNKNOWN";
++ return { ok: false, code, text: t(FAILURE_COPY[code]) };
++}
++var testModel = (id) => call("test-model", { id });
++function ModelTestRow({ bot, modelName, test = testModel }) {
++ const [state, setState] = (0, import_react23.useState)({ busy: false, outcome: void 0 });
++ const live = (0, import_react23.useRef)(true);
++ (0, import_react23.useEffect)(() => () => {
++ live.current = false;
++ }, []);
++ (0, import_react23.useEffect)(() => {
++ setState({ busy: false, outcome: void 0 });
++ }, [bot.id, bot.model]);
++ const start = () => {
++ if (state.busy) return;
++ setState({ busy: true, outcome: void 0 });
++ Promise.resolve().then(() => test(bot.id)).then(
++ (result) => testOutcome(result, modelName ?? result?.ref ?? ""),
++ (error) => ({ ok: false, text: String(error?.message ?? t("The test failed.")) })
++ ).then((outcome2) => {
++ if (live.current) setState({ busy: false, outcome: outcome2 });
++ });
+ };
+- (0, import_react29.useEffect)(() => {
+- void actions.refreshModels?.();
+- }, [bot.id]);
+- return (0, import_react29.createElement)(
++ const { busy, outcome } = state;
++ return (0, import_react23.createElement)(
+ "div",
+- { className: "bt-bs" },
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-bs-hero" },
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-avatar-anchor" },
+- (0, import_react29.createElement)(
+- "button",
+- { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) },
+- (0, import_react29.createElement)(BotMark, { bot, size: 64, live: true }),
+- (0, import_react29.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react29.createElement)(PencilIcon))
+- ),
+- editing ? (0, import_react29.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
+- ),
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-bs-who" },
+- (0, import_react29.createElement)("span", { className: "bt-bs-name" }, bot.name),
+- (0, import_react29.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
+- )
+- ),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("Profile") },
+- (0, import_react29.createElement)(Row, { label: t("Name") }, (0, import_react29.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
+- (0, import_react29.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react29.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
+- ),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("Model") },
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
+- (0, import_react29.createElement)(ModelMenu, { bot, roster, actions, run })
+- ),
+- bot.model ? (0, import_react29.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
+- ),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("Working folder") },
+- (0, import_react29.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+- (0, import_react29.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
+- )
+- ),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("Instructions") },
+- (0, import_react29.createElement)(
+- Row,
+- { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true },
+- (0, import_react29.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
++ { className: "bt-set-row bt-bs-row bt-model-test" },
++ (0, import_react23.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")),
++ (0, import_react23.createElement)(
++ "span",
++ { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) },
++ busy ? t("Testing…") : outcome === void 0 ? null : outcome.text,
++ outcome?.code ? (0, import_react23.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null
+ )
+ ),
+- // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406).
+- (0, import_react29.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react29.createElement)(Card, { title: t("Sharing") }, children) }),
+- (0, import_react29.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
+- (0, import_react29.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
+- roster.memory === false ? null : (0, import_react29.createElement)(MemoryCard, { bot, actions }),
+- (0, import_react29.createElement)(BotSchedules, { bot, roster, actions }),
+- (0, import_react29.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("Main Bot") },
+- onlyMain ? (0, import_react29.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react29.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
+- ),
+- (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
+- ) : (0, import_react29.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
+- ),
+- // The Main Bot tile is always first and always shown, like in the context menu.
+- isTile ? null : (0, import_react29.createElement)(
+- Card,
+- { title: t("Sidebar") },
+- (0, import_react29.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
+- isMainBot ? null : (0, import_react29.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
+- ),
+- (0, import_react29.createElement)(
+- Card,
+- { title: t("More") },
+- (0, import_react29.createElement)(
+- "button",
+- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate },
+- (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
+- (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon))
+- ),
+- isMainBot ? null : (0, import_react29.createElement)(
+- "button",
+- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
+- (0, import_react29.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react29.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
+- (0, import_react29.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
+- ),
+- (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon))
+- )
+- )
++ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection"))
+ );
+ }
+-var BOT_SETTINGS_CSS = `
+-.bt-bs{display:flex;flex-direction:column;gap:18px}
+-.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px}
+-.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px}
+-.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+-.bt-bs .bt-avedit{left:0;transform:none}
+-.bt-bs-row{min-height:48px}
+-label.bt-bs-row{cursor:text}
+-.bt-bs-label{font-size:13px;line-height:18px;font-weight:500}
+-/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to
+- break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test
+- connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under
+- a label is prose, so it wraps and is never cut: the row grows instead. */
+-.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch}
+-.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+-.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap}
+-.bt-bs-row .bt-model-trigger{flex:none;max-width:58%}
+-.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
+-${MODEL_TEST_CSS}
+-.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease}
+-.bt-bs-input:hover{background:var(--bt-hover)}
+-.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left}
+-.bt-bs-input::placeholder{color:var(--bt-ink-3)}
+-.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px}
+-.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)}
+-.bt-bs-area:hover{background:var(--bt-active)}
+-.bt-bs-danger .bt-bs-label{color:#c21d2e}
+-.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)}
+-.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)}
+-.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease}
+-.bt-switch[aria-checked=true]{background:var(--bt-accent)}
+-.bt-switch:disabled{opacity:.45;cursor:default}
+-.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
+-.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
+-.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
+-.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
+-.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
+-.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
+-@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
+-/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
+- 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
+- keeps its own words. Nothing is clipped to fit a width that is not there. */
+-@container bt-panel (max-width:300px){
+- .bt-bs-row{flex-wrap:wrap;row-gap:6px}
+- .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
+- .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
+-}
+-`;
+-
+-// src/client/group-settings.js
+-var import_react30 = require("react");
+-var ROOM_MODES = [
+- ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
+- ["everyone", "Everyone", "Every member answers in turn, admin first."]
+-];
+-var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
+-function faceSeatBot(seat, roster) {
+- if (!seat) return void 0;
+- const record = seat.record && typeof seat.record === "object" ? seat.record : {};
+- const extra = {};
+- if (typeof record.color === "string" && record.color) extra.color = record.color;
+- if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar;
+- if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local;
+- const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : "";
+- return lookBotForRemote({
+- handle,
+- displayName: record.displayName || seat.bot,
+- name: seat.bot,
+- label: record.label,
+- id: record.id || seat.key,
+- ...extra
+- }, roster);
+-}
+-function faceMemberLook(bot) {
+- if (!bot) return void 0;
+- return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot);
+-}
+-function faceMarks(person, roster, size) {
+- const marks = [];
+- for (const seat of person?.seats ?? []) {
+- const bot = faceSeatBot(seat, roster);
+- if (!bot) continue;
+- marks.push((0, import_react30.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
+- }
+- if (marks.length === 0) return [(0, import_react30.createElement)(PersonMark, { key: "person", size, name: person?.name })];
+- return marks;
+-}
+-function faceRoomCount(room2, roster) {
+- const people2 = facePeople(room2, roster).length;
+- const seats = faceSeats(room2, roster).length;
+- const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
+- const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
+- return `${persons} · ${bots}`;
+-}
+-var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
+-var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
+-function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
+- const bots = person.seats.flatMap((seat, index) => {
+- const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
+- const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
+- return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
+- });
+- return (0, import_react30.createElement)(
+- "div",
+- { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
+- (0, import_react30.createElement)(
+- "span",
+- { className: "bt-gs-who bt-face-person" },
+- (0, import_react30.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
+- (0, import_react30.createElement)(
+- "span",
+- { className: "bt-face-person-text" },
+- (0, import_react30.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
+- (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots)
+- )
+- )
+- );
++var MODEL_TEST_CSS = `
++.bt-model-test-result{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
++.bt-model-test-result:empty{display:none}
++.bt-model-test-result[data-ok=true]{color:#1a7f37}
++.bt-model-test-result[data-ok=false]{color:#c21d2e}
++.bt-model-test-code{margin-left:6px;font-size:11px;color:var(--bt-ink-3)}
++/* #1347: the hint beside "Test connection" is prose and wraps; the button next to it keeps its own
++ words ("Test connection" on one line) and takes the room it needs. */
++.bt-model-test>.bt-set-copy{flex:1 1 12ch;min-width:12ch}
++.bt-model-test>.bt-soft{flex:none;white-space:nowrap}
++@container bt-panel (max-width:300px){
++ .bt-model-test{flex-wrap:wrap;row-gap:6px}
++ .bt-model-test>.bt-set-copy{flex:1 1 100%}
+ }
+-function FaceLead({ room: room2, roster, pick: pick2 }) {
+- const lead = room2.face?.lead;
+- const [open, setOpen] = (0, import_react30.useState)(false);
+- const [busy, setBusy] = (0, import_react30.useState)(false);
+- const [error, setError] = (0, import_react30.useState)("");
+- const seats = faceSeats(room2, roster);
+- const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
+- if (!current) return null;
+- const canPick = lead.canPick === true && typeof pick2 === "function";
+- const choose = async (handle) => {
+- setBusy(true);
+- setError("");
+- try {
+- await pick2(handle);
+- setOpen(false);
+- } catch (err) {
+- setError(err?.message || t("Could not change the room lead."));
+- } finally {
+- setBusy(false);
++`;
++
++// src/client/folder-menu.js
++var import_react24 = require("react");
++var import_react_dom9 = require("react-dom");
++var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path);
++var Tick = ({ on }) => (0, import_react24.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react24.createElement)(CheckIcon) : null);
++function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) {
++ const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu();
++ const [info, setInfo] = (0, import_react24.useState)(null);
++ const [view, setView] = (0, import_react24.useState)("list");
++ const [draft, setDraft] = (0, import_react24.useState)("");
++ const [listing, setListing] = (0, import_react24.useState)(null);
++ const [problem, setProblem] = (0, import_react24.useState)("");
++ const fallback = group ? t("Each Bot's own folder") : t("Team workspace");
++ const toggle = () => {
++ if (isOpen) {
++ close();
++ return;
+ }
++ setView("list");
++ setProblem("");
++ void Promise.resolve(actions.folders()).then(setInfo).catch(() => setInfo((last) => last ?? { team: null, picker: null, folders: [] }));
++ open();
+ };
+- return (0, import_react30.createElement)(
++ const choose = (path) => {
++ close();
++ if (path !== (value ?? "")) void onPick(path);
++ };
++ const browse = (path) => {
++ setProblem("");
++ void Promise.resolve(actions.folderList(path)).then((next) => {
++ setListing(next);
++ setView("browse");
++ }).catch((failure) => setProblem(failure?.message ?? String(failure)));
++ };
++ const pickNative = () => {
++ close();
++ void run(Promise.resolve(actions.folderPick()).then((path) => path ? onPick(path) : void 0));
++ };
++ const typed = () => {
++ const path = draft.trim();
++ if (path !== "") choose(path);
++ };
++ const known = info?.folders ?? [];
++ const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known;
++ const listView = (0, import_react24.createElement)(
+ "div",
+- { className: "bt-face-lead", "data-flybot": "face-lead" },
+- (0, import_react30.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-set-row" },
+- (0, import_react30.createElement)(
++ { key: "list", className: "bt-folder-view" },
++ (0, import_react24.createElement)(
++ "button",
++ { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") },
++ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, fallback),
++ (0, import_react24.createElement)(Tick, { on: !value })
++ ),
++ folders.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null,
++ folders.map((folder) => (0, import_react24.createElement)(
++ "button",
++ { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) },
++ (0, import_react24.createElement)("span", { className: "bt-folder-copy" }, (0, import_react24.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react24.createElement)("span", { className: "bt-folder-path" }, folder.path)),
++ (0, import_react24.createElement)(Tick, { on: folder.path === value })
++ )),
++ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
++ info?.picker === "native" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null,
++ info?.picker === "browse" ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null,
++ (0, import_react24.createElement)("button", { type: "button", role: "menuitem", onClick: () => {
++ setDraft(value ?? "");
++ setView("type");
++ } }, (0, import_react24.createElement)(PencilIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…")))
++ );
++ const typeView = (0, import_react24.createElement)(
++ "div",
++ { key: "type", className: "bt-folder-view" },
++ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
++ (0, import_react24.createElement)("input", {
++ className: "bt-folder-input",
++ autoFocus: true,
++ value: draft,
++ spellCheck: false,
++ "aria-label": t("Folder path"),
++ placeholder: t("Full path of a folder"),
++ onChange: (event) => setDraft(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Enter" && !event.nativeEvent.isComposing) typed();
++ }
++ }),
++ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder"))
++ );
++ const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0;
++ const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? [];
++ const browseView = listing ? (0, import_react24.createElement)(
++ "div",
++ { key: `browse:${listing.path}`, className: "bt-folder-view" },
++ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react24.createElement)(ChevronLeftIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Back"))),
++ (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path),
++ parent ? (0, import_react24.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react24.createElement)(FolderIcon), (0, import_react24.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null,
++ inside.map((entry) => (0, import_react24.createElement)(
++ "button",
++ { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) },
++ (0, import_react24.createElement)(FolderIcon),
++ (0, import_react24.createElement)("span", { className: "bt-menu-label" }, entry.name),
++ (0, import_react24.createElement)(ChevronRightIcon)
++ )),
++ inside.length === 0 ? (0, import_react24.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null,
++ listing.truncated ? (0, import_react24.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null,
++ (0, import_react24.createElement)("div", { className: "bt-menu-sep", role: "separator" }),
++ (0, import_react24.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder"))
++ ) : null;
++ const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView;
++ return (0, import_react24.createElement)(
++ import_react24.Fragment,
++ null,
++ (0, import_react24.createElement)(
++ "button",
++ {
++ ref: button,
++ type: "button",
++ className: "bt-model-trigger",
++ "data-wide": wide || void 0,
++ "aria-haspopup": "menu",
++ "aria-expanded": isOpen,
++ "aria-label": t("Working folder: {folder}", { folder: value ?? fallback }),
++ title: value,
++ onClick: toggle
++ },
++ (0, import_react24.createElement)(
+ "span",
+- { className: "bt-set-copy" },
+- (0, import_react30.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
+- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
++ { className: "bt-model-copy" },
++ (0, import_react24.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback),
++ value ? (0, import_react24.createElement)("span", { className: "bt-model-provider" }, value) : null
+ ),
+- canPick ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
++ (0, import_react24.createElement)(Caret)
+ ),
+- open && canPick ? seats.map((seat) => (0, import_react30.createElement)(
++ shownPlace ? (0, import_react_dom9.createPortal)((0, import_react24.createElement)(
+ "div",
+- { key: seat.key, className: "bt-set-row" },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
+- seat === current ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
+- )) : null,
+- open && canPick && lead.picked ? (0, import_react30.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
+- canPick || !lead.supported ? null : (0, import_react30.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
+- error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
++ { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 },
++ shownView,
++ problem ? (0, import_react24.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null
++ ), document.body) : null
+ );
+ }
+-function seatedCount(room2) {
+- const live = room2?.presentedParticipants ?? room2?.participants ?? [];
+- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length;
+- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length;
+- return humans + remotes + (room2?.members?.length ?? 0);
++var FOLDER_CSS = `
++.bt-folder-view{display:flex;flex-direction:column;gap:2px;animation:bt-veil-in .14s ease-out}
++.bt-folder-copy{flex:1;display:flex;flex-direction:column;min-width:0}
++.bt-folder-copy .bt-menu-label{flex:none}
++.bt-folder-path{min-width:0;font-size:11px;line-height:14px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-folder-here{padding:0 8px 4px;white-space:normal;overflow-wrap:anywhere}
++.bt-menu .bt-folder-back{color:var(--bt-ink-2);font-weight:500}
++.bt-folder-input{height:30px;margin:2px 4px;padding:0 8px;border:1px solid var(--bt-line-2);border-radius:8px;background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;outline:none;box-sizing:border-box}
++.bt-folder-input:focus{border-color:var(--bt-accent)}
++.bt-menu .bt-folder-use{justify-content:center;font-weight:500;color:var(--bt-accent)}
++.bt-menu .bt-folder-use:disabled{color:var(--bt-ink-3);cursor:default;background:none}
++.bt-folder-error{padding:4px 8px;font-size:12px;line-height:16px;color:#c21d2e;overflow-wrap:anywhere}
++@media (prefers-reduced-motion:reduce){.bt-folder-view{animation:none}}
++`;
++
++// src/client/schedules.js
++var import_react25 = require("react");
++var POLL_MS4 = 3e4;
++var shared = null;
++var listeners = /* @__PURE__ */ new Set();
++function publish(view) {
++ shared = view;
++ for (const listener of listeners) listener(view);
+ }
+-function roomSubtitle(room2, roster) {
+- if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
+- const admin = roster.byId[room2.admin];
+- const count = seatedCount(room2);
+- return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
++function useSchedules(actions) {
++ const [view, setView] = (0, import_react25.useState)(shared);
++ (0, import_react25.useEffect)(() => {
++ listeners.add(setView);
++ let stopped = false;
++ let timer;
++ const load = () => actions.schedules?.().then((next) => {
++ if (!stopped) publish(next);
++ }).catch(() => {
++ }).finally(() => {
++ if (!stopped) timer = setTimeout(load, POLL_MS4);
++ });
++ load();
++ return () => {
++ stopped = true;
++ clearTimeout(timer);
++ listeners.delete(setView);
++ };
++ }, []);
++ return view;
+ }
+-function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
+- const [adding, setAdding] = (0, import_react30.useState)(false);
+- const [confirming, setConfirming] = (0, import_react30.useState)(false);
+- (0, import_react30.useEffect)(() => {
+- if (!confirming) return void 0;
+- const timer = setTimeout(() => setConfirming(false), 4e3);
+- return () => clearTimeout(timer);
+- }, [confirming]);
+- const live = room2.presentedParticipants ?? room2.participants ?? [];
+- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
+- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
+- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+- const face = room2.origin === "face-to-face";
+- const people2 = face ? facePeople(room2, roster) : [];
+- const shared2 = humans.length > 1;
+- const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
+- const outside = roster.bots.filter((bot) => {
+- if (bot.remote) return false;
+- if (room2.members.includes(bot.id)) return false;
+- if (shared2) return chiefs.has(bot.id);
+- return true;
+- });
+- const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
+- const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
+- const admin = roster.byId[room2.admin];
+- const mode = shownMode(room2, roster);
+- const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
+- const update = (patch) => run(actions.updateRoom(room2.id, patch));
+- const openBot = (id) => {
+- actions.closeSettings();
+- actions.openSession(id);
+- };
++var localZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
++var clock2 = (time) => String(time ?? "").slice(0, 5);
++var when2 = (ms, options) => new Intl.DateTimeFormat(dateLocale(), options).format(ms);
++function dayAndTime(ms) {
++ const sameYear = new Date(ms).getFullYear() === (/* @__PURE__ */ new Date()).getFullYear();
++ return when2(ms, { ...sameYear ? {} : { year: "numeric" }, month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
++}
++function weekdayNames(days) {
++ const names = days.map((day) => when2(Date.UTC(2024, 0, day), { weekday: "short", timeZone: "UTC" }));
++ return names.join(dateLocale().startsWith("zh") ? "、" : ", ");
++}
++function everyText(seconds) {
++ if (seconds % 86400 === 0) return seconds === 86400 ? t("Every day") : t("Every {count} days", { count: seconds / 86400 });
++ if (seconds % 3600 === 0) return seconds === 3600 ? t("Every hour") : t("Every {count} hours", { count: seconds / 3600 });
++ if (seconds % 60 === 0) return t("Every {count} minutes", { count: seconds / 60 });
++ return t("Every {count} seconds", { count: seconds });
++}
++function timingText(task) {
++ const zone = task.timeZone && task.timeZone !== localZone() ? ` (${task.timeZone})` : "";
++ switch (task.kind) {
++ case "after":
++ case "at":
++ return t("Once, {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) });
++ case "every":
++ return everyText(task.everySeconds);
++ case "daily":
++ return `${t("Every day at {time}", { time: clock2(task.time) })}${zone}`;
++ case "weekly": {
++ const days = (task.weekdays ?? []).join();
++ const text2 = days === "1,2,3,4,5" ? t("Every weekday at {time}", { time: clock2(task.time) }) : days === "6,7" ? t("Every weekend at {time}", { time: clock2(task.time) }) : days === "1,2,3,4,5,6,7" ? t("Every day at {time}", { time: clock2(task.time) }) : t("Every {days} at {time}", { days: weekdayNames(task.weekdays ?? []), time: clock2(task.time) });
++ return `${text2}${zone}`;
++ }
++ case "cron":
++ return `${t("On the schedule {expression}", { expression: task.expression })}${zone}`;
++ default:
++ return "";
++ }
++}
++var nextText = (task) => task.status === "paused" ? t("Paused") : task.status === "active" ? t("Next: {time}", { time: dayAndTime(Date.parse(task.scheduledAt)) }) : t("Finished");
++function lastRunText(run) {
++ if (run === void 0 || !Number.isFinite(run.at)) return "";
++ const time = dayAndTime(run.at);
++ switch (run.result) {
++ case "running":
++ return t("Running since {time}", { time });
++ case "stopped":
++ return t("Last run {time} · Stopped", { time });
++ case "failed":
++ return t("Last run {time} · Failed: {reason}", { time, reason: hostText(run.reason || "Unknown error") });
++ default:
++ return t("Last run {time}", { time });
++ }
++}
++function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) {
++ const [confirming, setConfirming] = (0, import_react25.useState)(false);
++ const [busy, setBusy] = (0, import_react25.useState)(false);
++ (0, import_react25.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(false), 3e3);
++ return () => clearTimeout(timer);
++ }, [confirming]);
+ const remove = () => {
+ if (!confirming) {
+ setConfirming(true);
+ return;
+ }
+- void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
+- if (done) onDeleted?.();
++ setBusy(true);
++ void actions.scheduleDelete(task.sessionId, task.id).then(onView, () => setBusy(false));
++ };
++ const toggle = () => {
++ setBusy(true);
++ const step = task.status === "paused" ? actions.scheduleResume : actions.schedulePause;
++ void step(task.sessionId, task.id).then((next) => {
++ setBusy(false);
++ onView(next);
++ }, () => setBusy(false));
++ };
++ const [retryError, setRetryError] = (0, import_react25.useState)("");
++ const retry = () => {
++ setBusy(true);
++ setRetryError("");
++ void actions.scheduleRetry(task.sessionId, task.id).then((next) => {
++ setBusy(false);
++ onView(next);
++ }, (error) => {
++ setBusy(false);
++ setRetryError(hostText(error?.message ?? String(error)));
+ });
+ };
+- return (0, import_react30.createElement)(
++ const room2 = task.roomId ? roster.roomsById[task.roomId] : void 0;
++ const stoppable = task.status === "active" || task.status === "paused";
++ const failed = task.lastRun?.result === "failed";
++ const last = lastRunText(task.lastRun);
++ return (0, import_react25.createElement)(
+ "div",
+- { className: "bt-bs" },
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-bs-hero" },
+- (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-bs-who" },
+- (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name),
+- (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
+- )
++ { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 },
++ showBot ? (0, import_react25.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null,
++ (0, import_react25.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react25.createElement)("span", { className: "bt-task-title" }, task.title),
++ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")),
++ task.prompt && task.prompt !== task.title ? (0, import_react25.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null,
++ last ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null,
++ retryError ? (0, import_react25.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null
+ ),
+- (0, import_react30.createElement)(
+- Card,
+- { title: t("Profile") },
+- (0, import_react30.createElement)(
+- Row,
+- { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
+- (0, import_react30.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
+- ),
+- (0, import_react30.createElement)(
+- Row,
+- { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
+- (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
++ (0, import_react25.createElement)("span", { className: "bt-task-next" }, nextText(task)),
++ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null,
++ onEdit ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react25.createElement)(PencilIcon)) : null,
++ onCopy ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react25.createElement)(DuplicateIcon)) : null,
++ stoppable ? (0, import_react25.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null,
++ (0, import_react25.createElement)("button", {
++ type: "button",
++ className: "bt-mini bt-task-remove",
++ "data-confirm": confirming || void 0,
++ disabled: busy,
++ "aria-label": confirming ? t("Click again to delete") : t("Delete task"),
++ title: confirming ? void 0 : t("Delete task"),
++ onClick: remove
++ }, confirming ? t("Delete") : (0, import_react25.createElement)(TrashIcon))
++ );
++}
++var pad = (number) => String(number).padStart(2, "0");
++function inAnHour() {
++ const date = new Date(Date.now() + 36e5);
++ date.setMinutes(0, 0, 0);
++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:00`;
++}
++var KINDS = [["at", "Once"], ["daily", "Every day"], ["weekly", "Every week"], ["every", "Repeat"]];
++var UNITS = [["seconds", 1, "seconds"], ["minutes", 60, "minutes"], ["hours", 3600, "hours"], ["days", 86400, "days"]];
++function localInput(ms) {
++ const date = new Date(ms);
++ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
++}
++function formStart(task) {
++ const seconds = task?.kind === "every" ? Number(task.everySeconds) : 3600;
++ const [unit, size] = seconds % 86400 === 0 ? ["days", 86400] : seconds % 3600 === 0 ? ["hours", 3600] : seconds % 60 === 0 ? ["minutes", 60] : ["seconds", 1];
++ const once = task?.kind === "at" || task?.kind === "after";
++ return {
++ title: task?.title ?? "",
++ prompt: task?.prompt ?? "",
++ kind: once ? "at" : task?.kind ?? "daily",
++ at: once && !Number.isNaN(Date.parse(task.scheduledAt)) ? localInput(Date.parse(task.scheduledAt)) : inAnHour(),
++ time: task?.time ? clock2(task.time) : "09:00",
++ weekdays: task?.kind === "weekly" && Array.isArray(task.weekdays) ? task.weekdays : [1, 2, 3, 4, 5],
++ count: Math.max(1, Math.round(seconds / size)),
++ unit,
++ timeZone: task?.timeZone || localZone()
++ };
++}
++function TaskForm({ roster, actions, start, task, onDone, onCancel }) {
++ const first = formStart(task);
++ const [target, setTarget] = (0, import_react25.useState)(task ? task.botId ?? task.roomId : start ?? "");
++ const [title, setTitle] = (0, import_react25.useState)(first.title);
++ const [prompt, setPrompt] = (0, import_react25.useState)(first.prompt);
++ const [kind, setKind] = (0, import_react25.useState)(first.kind);
++ const [at, setAt] = (0, import_react25.useState)(first.at);
++ const [time, setTime] = (0, import_react25.useState)(first.time);
++ const [weekdays, setWeekdays] = (0, import_react25.useState)(first.weekdays);
++ const [count, setCount] = (0, import_react25.useState)(first.count);
++ const [unit, setUnit] = (0, import_react25.useState)(first.unit);
++ const [busy, setBusy] = (0, import_react25.useState)(false);
++ const [error, setError] = (0, import_react25.useState)("");
++ const timing = () => {
++ if (kind === "cron") return { kind: "keep" };
++ if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone };
++ if (kind === "every") return { kind, seconds: Math.round(Number(count) * UNITS.find(([id]) => id === unit)[1]) };
++ return { kind, time, timeZone: first.timeZone, ...kind === "weekly" ? { weekdays } : {} };
++ };
++ const submit = (event) => {
++ event.preventDefault();
++ if (busy || target === "") return;
++ setBusy(true);
++ setError("");
++ let when3;
++ try {
++ when3 = timing();
++ } catch {
++ setError(t("Pick a date and time"));
++ setBusy(false);
++ return;
++ }
++ const saved = task ? actions.scheduleUpdate({ sessionId: task.sessionId, id: task.id, target, title, prompt, when: when3 }) : actions.scheduleCreate({ target, title, prompt, when: when3 });
++ void saved.then(onDone, (failure) => {
++ setError(hostText(failure?.message ?? String(failure)));
++ setBusy(false);
++ });
++ };
++ const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort());
++ return (0, import_react25.createElement)(
++ "form",
++ { className: "bt-set-card bt-task-form", onSubmit: submit },
++ (0, import_react25.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Send to"))),
++ (0, import_react25.createElement)(
++ "select",
++ { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) },
++ (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
++ (0, import_react25.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react25.createElement)("option", { key: bot.id, value: bot.id }, bot.name))),
++ roster.rooms.length > 0 ? (0, import_react25.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react25.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null
+ )
+ ),
+- (0, import_react30.createElement)(
+- Card,
+- { title: t("Who answers") },
+- (0, import_react30.createElement)(
++ (0, import_react25.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
++ (0, import_react25.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) })
++ ),
++ (0, import_react25.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("What to do"))),
++ (0, import_react25.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) })
++ ),
++ (0, import_react25.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("When"))),
++ (0, import_react25.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react25.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))),
++ (0, import_react25.createElement)(
+ "div",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", {
+- key: id,
++ { key: kind, className: "bt-task-when bt-veil" },
++ kind === "cron" ? (0, import_react25.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null,
++ kind === "at" ? (0, import_react25.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null,
++ kind === "weekly" ? (0, import_react25.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react25.createElement)("button", {
++ key: day,
+ type: "button",
+- "aria-pressed": mode === id,
+- disabled: id === "admin" && !admin,
+- title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
+- onClick: () => {
+- if (mode !== id) void update({ mode: id });
+- }
+- }, t(label)))),
+- (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
++ className: "bt-task-day",
++ "aria-pressed": weekdays.includes(day),
++ onClick: () => toggleDay(day)
++ }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null,
++ kind === "daily" || kind === "weekly" ? (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null,
++ kind === "every" ? (0, import_react25.createElement)(
++ import_react25.Fragment,
++ null,
++ (0, import_react25.createElement)("span", { className: "bt-task-every" }, t("Every")),
++ (0, import_react25.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }),
++ (0, import_react25.createElement)(
++ "select",
++ { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) },
++ UNITS.map(([id, , label]) => (0, import_react25.createElement)("option", { key: id, value: id }, t(label)))
++ )
++ ) : null
+ )
+ ),
+- (0, import_react30.createElement)(
+- Card,
+- { title: t("Working folder") },
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row" },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
+- (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
++ error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
++ (0, import_react25.createElement)(
++ "div",
++ { className: "bt-set-row bt-task-foot" },
++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
++ (0, import_react25.createElement)(
++ "button",
++ { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" },
++ task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task")
+ )
+- ),
+- face ? (0, import_react30.createElement)(
+- Card,
+- { title: t("Members · {count}", { count: people2.length }) },
+- people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
+- ) : null,
+- face ? null : (0, import_react30.createElement)(
+- Card,
+- { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
+- humans.map((person) => (0, import_react30.createElement)(
+- "div",
+- { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+- (0, import_react30.createElement)(
+- "span",
+- { className: "bt-gs-who" },
+- (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
+- (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")),
+- person.role === "admin" ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
+- ),
+- humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
+- )),
+- members.map((member) => {
+- const leads = room2.admin === member.id;
+- const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share";
+- const taskLead = room2.taskLead === member.id;
+- return (0, import_react30.createElement)(
+- "div",
+- { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+- (0, import_react30.createElement)(
+- "button",
+- { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
+- (0, import_react30.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
+- (0, import_react30.createElement)("span", { className: "bt-row-title" }, member.name),
+- leads ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
+- taskLead ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null,
+- (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
+- ),
+- leads ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
+- boardHere ? taskLead ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null,
+- members.length > 2 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
+- );
+- }),
+- remotes.map((person) => (0, import_react30.createElement)(
++ )
++ );
++}
++function openPlugins(actions) {
++ actions.closeSettings();
++ actions.selectPanel("plugins");
++}
++function AutomationOff({ actions }) {
++ return (0, import_react25.createElement)(
++ "div",
++ { className: "bt-set-card bt-task-off" },
++ (0, import_react25.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react25.createElement)(ClockIcon)),
++ (0, import_react25.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")),
++ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")),
++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
++ );
++}
++function QuietHoursCard({ view, actions }) {
++ const quiet = view.quietHours ?? { on: false };
++ const [busy, setBusy] = (0, import_react25.useState)(false);
++ const [error, setError] = (0, import_react25.useState)("");
++ const [from, setFrom] = (0, import_react25.useState)(quiet.from ?? "22:00");
++ const [to, setTo] = (0, import_react25.useState)(quiet.to ?? "07:00");
++ const unchecked = view.quietUnchecked === true;
++ const refresh = () => {
++ void Promise.resolve(actions.schedules?.()).then((next) => {
++ if (next) publish(next);
++ }).catch(() => {
++ });
++ };
++ const save = (next) => {
++ if (busy) return;
++ setBusy(true);
++ setError("");
++ const shown = next.on ? { on: true, from: next.from ?? from, to: next.to ?? to } : { on: false };
++ void Promise.resolve(actions.setPrefs({ quietHours: next })).then(() => {
++ publish({ ...view, quietHours: shown });
++ refresh();
++ }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false));
++ };
++ return (0, import_react25.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react25.createElement)("h3", null, t("Quiet hours")),
++ (0, import_react25.createElement)(
++ "div",
++ { className: "bt-set-card" },
++ (0, import_react25.createElement)(
+ "div",
+- { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
+- (0, import_react30.createElement)(
+- "span",
+- { className: "bt-gs-who" },
+- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
+- (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
+- // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
+- // labelled as such; a Bot with no Bot Social identity can never be written to at all.
+- room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
+- ),
+- // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
+- room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
+- )),
+- room2.origin === "face-to-face" ? null : adding ? [
+- ...outsidePeople.map((person) => (0, import_react30.createElement)(
+- "button",
+- { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+- setAdding(false);
+- void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
+- } },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
+- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
+- )),
+- ...outside.map((bot) => (0, import_react30.createElement)(
+- "button",
+- { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+- setAdding(false);
+- void update({ add: [bot.id] });
+- } },
+- (0, import_react30.createElement)(BotMark, { bot, size: 22 }),
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, bot.name)),
+- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add"))
+- )),
+- ...outsideRemotes.map((bot) => (0, import_react30.createElement)(
+- "button",
+- { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
+- setAdding(false);
+- void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
+- } },
+- (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
+- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
+- )),
+- (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
+- ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null,
+- (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)(
+- "span",
+- { className: "bt-set-hint" },
+- // The host's two-Bot floor guards membership changes; this hint warns before one.
+- members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
+- )),
+- // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
+- // the note on who leads, so that note stays with the members it is about (#892).
+- room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
+- ),
+- (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
+- (0, import_react30.createElement)(
+- Card,
+- { title: t("Sidebar") },
+- (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
+- (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
+- ),
+- (0, import_react30.createElement)(
+- Card,
+- { title: t("More") },
+- (0, import_react30.createElement)(
+- "button",
+- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
+- (0, import_react30.createElement)(
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react25.createElement)(
+ "span",
+ { className: "bt-set-copy" },
+- (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
+- (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
++ (0, import_react25.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")),
++ (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists."))
+ ),
+- (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
+- )
++ (0, import_react25.createElement)("button", {
++ type: "button",
++ className: "bt-soft bt-soft-sm",
++ disabled: busy,
++ "aria-pressed": quiet.on,
++ onClick: () => save(quiet.on ? { on: false } : { on: true, from, to })
++ }, quiet.on ? t("Turn off") : t("Turn on"))
++ ),
++ quiet.on ? (0, import_react25.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Window"))),
++ (0, import_react25.createElement)(
++ "div",
++ { className: "bt-task-when" },
++ (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }),
++ (0, import_react25.createElement)("span", { className: "bt-task-every" }, "–"),
++ (0, import_react25.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }),
++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window"))
++ )
++ ) : null,
++ error ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null,
++ quiet.on && unchecked ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null
+ )
+ );
+ }
+-function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
+- const [admin, setAdmin] = (0, import_react30.useState)(null);
+- const [members, setMembers] = (0, import_react30.useState)([]);
+- const [name, setName] = (0, import_react30.useState)("");
+- const [busy, setBusy] = (0, import_react30.useState)(false);
+- const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
+- const pickAdmin = (id) => {
+- setAdmin(id);
+- setMembers((list) => list.filter((item) => item !== id));
++function SchedulesPage({ roster, actions, start }) {
++ const view = useSchedules(actions);
++ const [adding, setAdding] = (0, import_react25.useState)(Boolean(start));
++ const [editing, setEditing] = (0, import_react25.useState)(null);
++ if (view === null) return (0, import_react25.createElement)("div", { className: "bt-set-hint" }, t("Loading…"));
++ if (!view.available) return (0, import_react25.createElement)(AutomationOff, { actions });
++ const shown = (next) => {
++ publish(next);
++ setAdding(false);
++ setEditing(null);
+ };
+- const create = async () => {
+- if (busy || admin === null || members.length === 0) return;
++ const edit = (task) => {
++ setAdding(false);
++ setEditing(task);
++ };
++ const byChat = [
++ ...roster.bots.map((bot) => [bot.id, (0, import_react25.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]),
++ ...roster.rooms.map((room2) => [room2.id, (0, import_react25.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)])
++ ].filter(([, , , tasks]) => tasks.length > 0);
++ return [
++ (0, import_react25.createElement)(QuietHoursCard, { key: "quiet", view, actions }),
++ adding ? (0, import_react25.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react25.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => {
++ setEditing(null);
++ setAdding(true);
++ } }, (0, import_react25.createElement)(PlusIcon), t("New task"))),
++ byChat.length === 0 && !adding ? (0, import_react25.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react25.createElement)(
++ "section",
++ { key: id, className: "bt-set-section" },
++ (0, import_react25.createElement)("h3", { className: "bt-task-head" }, avatar, name),
++ (0, import_react25.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) })
++ ))
++ ];
++}
++function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) {
++ const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0;
++ const [copying, setCopying] = (0, import_react25.useState)(null);
++ const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0;
++ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react25.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 }));
++ return (0, import_react25.createElement)(
++ import_react25.Fragment,
++ null,
++ open ? (0, import_react25.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null,
++ source ? (0, import_react25.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => {
++ setCopying(null);
++ onDone(next);
++ }, onCancel: () => setCopying(null) }) : null,
++ rows.length + extra.length > 0 ? (0, import_react25.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null
++ );
++}
++function CopyRow({ task, roster, actions, onView, onCancel }) {
++ const others = [
++ ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]),
++ ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name])
++ ];
++ const [target, setTarget] = (0, import_react25.useState)("");
++ const [busy, setBusy] = (0, import_react25.useState)(false);
++ const [error, setError] = (0, import_react25.useState)("");
++ const submit = (event) => {
++ event.preventDefault();
++ if (busy || target === "") return;
+ setBusy(true);
+- let room2;
+- const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
+- room2 = value;
+- }));
+- if (done && room2) onCreated(room2);
+- else setBusy(false);
++ setError("");
++ void actions.scheduleCreate({ target, copyFrom: { sessionId: task.sessionId, id: task.id } }).then(onView, (failure) => {
++ setBusy(false);
++ setError(hostText(failure?.message ?? String(failure)));
++ });
+ };
+- const chip = (bot, on, onClick, role) => (0, import_react30.createElement)("button", {
+- key: bot.id,
+- type: "button",
+- role,
+- className: "bt-ng-chip",
+- "aria-checked": on,
+- onClick
+- }, (0, import_react30.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react30.createElement)(AdminStar) : null);
+- return (0, import_react30.createElement)(
+- "div",
+- { className: "bt-set-card bt-ng" },
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-set-row bt-bs-row bt-bs-stack" },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react30.createElement)(AdminStar), t("Choose the group admin"))),
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
+- roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
+- )
++ return (0, import_react25.createElement)(
++ "form",
++ { className: "bt-set-row bt-task-copy", onSubmit: submit },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))),
++ (0, import_react25.createElement)(
++ "select",
++ { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) },
++ (0, import_react25.createElement)("option", { value: "" }, t("Pick a Bot or group chat")),
++ others.map(([id, name]) => (0, import_react25.createElement)("option", { key: id, value: id }, name))
+ ),
+- admin === null ? null : (0, import_react30.createElement)(
++ (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")),
++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
++ error ? (0, import_react25.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null
++ );
++}
++function BotSchedules({ bot, roster, actions }) {
++ const view = useSchedules(actions);
++ const [editing, setEditing] = (0, import_react25.useState)(null);
++ if (view === null) return null;
++ const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : [];
++ return (0, import_react25.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react25.createElement)("h3", null, t("Scheduled tasks")),
++ !view.available ? (0, import_react25.createElement)(
+ "div",
+- { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
+- (0, import_react30.createElement)(
++ { className: "bt-set-card" },
++ (0, import_react25.createElement)(
+ "div",
+- { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
+- roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))),
++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins"))
+ )
+- ),
+- admin === null || members.length === 0 ? null : (0, import_react30.createElement)(
+- "label",
+- { className: "bt-set-row bt-bs-row bt-ng-step" },
+- (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
+- (0, import_react30.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
+- ),
+- (0, import_react30.createElement)(
+- "div",
+- { className: "bt-set-row bt-task-foot" },
+- (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
+- (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
+- )
++ ) : (0, import_react25.createElement)(TaskList, {
++ tasks,
++ editing,
++ roster,
++ actions,
++ onEdit: setEditing,
++ onDone: (next) => {
++ publish(next);
++ setEditing(null);
++ },
++ onCancel: () => setEditing(null),
++ extra: [(0, import_react25.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react25.createElement)(PlusIcon), t("New task"))]
++ })
+ );
+ }
+-var GROUP_SETTINGS_CSS = `
+-.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
+-.bt-ng-step{animation:bt-veil-in .22s ease-out}
+-.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
+-.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
+-.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
+-.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
+-.bt-ng-chip:active{scale:.96}
+-.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
+-.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
+-.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
+-.bt-gs-member{gap:8px}
+-.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
+-.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
+-.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
+-.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
+-.bt-face-marks>.bt-mark{flex:none}
+-.bt-face-person{cursor:default}
+-.bt-face-person:hover .bt-row-title{text-decoration:none}
+-.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
+-.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+-.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
+-button.bt-face-bot{cursor:pointer}
+-button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
+-.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
+-.bt-bs-stack .bt-seg{align-self:stretch}
+-@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
++function ScheduleSummary({ bot, actions }) {
++ const view = useSchedules(actions);
++ const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : [];
++ return (0, import_react25.createElement)(
++ "div",
++ null,
++ (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")),
++ tasks.length === 0 ? (0, import_react25.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react25.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react25.createElement)(
++ "button",
++ { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") },
++ (0, import_react25.createElement)(ClockIcon),
++ (0, import_react25.createElement)("span", { className: "bt-task-line-title" }, task.title),
++ (0, import_react25.createElement)("span", { className: "bt-task-line-when" }, timingText(task))
++ )))
++ );
++}
++var SCHEDULE_CSS = `
++.bt-task{gap:10px}
++.bt-task-copy{flex-wrap:wrap;gap:8px}
++.bt-task-copy .bt-task-bot{flex:1;min-width:140px;max-width:100%}
++.bt-task[data-done]{opacity:.6}
++.bt-task[data-busy]{opacity:.4}
++.bt-task-title{font-size:13px;line-height:18px;font-weight:500;overflow-wrap:anywhere}
++.bt-task-prompt{font-size:12px;line-height:16px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.bt-task-next{flex:none;font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
++.bt-task-remove{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
++.bt-task-remove svg{width:14px;height:14px}
++.bt-task-tool{display:inline-flex;align-items:center;justify-content:center;min-width:26px;height:26px;color:var(--bt-ink-3)}
++.bt-task-tool svg{width:14px;height:14px}
++.bt-task-pause{height:26px;color:var(--bt-ink-2)}
++.bt-task-last{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow-wrap:anywhere}
++.bt-task-last[data-failed]{color:#c21d2e}
++.bt-task[data-paused] .bt-task-title,.bt-task[data-paused] .bt-task-prompt{color:var(--bt-ink-3)}
++.bt-task-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135}
++.bt-task-head{display:flex;align-items:center;gap:6px}
++.bt-task-form{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
++.bt-task-bot{width:auto;min-width:160px;max-width:58%}
++.bt-task-when{display:flex;align-items:center;flex-wrap:wrap;gap:8px}
++.bt-task-when .bt-input{width:auto;height:32px}
++.bt-task-time{min-width:110px}
++.bt-task-count{width:72px!important}
++.bt-task-unit{width:auto;height:32px}
++.bt-task-every{font-size:13px;color:var(--bt-ink-2)}
++.bt-task-days{display:flex;gap:4px}
++.bt-task-day{width:30px;height:30px;border-radius:50%;border:1px solid var(--bt-line);background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease}
++.bt-task-day[aria-pressed=true]{background:var(--bt-user);border-color:var(--bt-user);color:var(--bt-user-ink)}
++.bt-task-foot{justify-content:flex-end;gap:8px}
++.bt-task-foot .bt-send{height:28px;font-size:12px}
++.bt-task-off{align-items:center;gap:8px;padding:28px 20px;text-align:center}
++.bt-task-off>*+*{border-top:0}
++.bt-task-off-mark{width:44px;height:44px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2)}
++.bt-task-off-mark svg{width:20px;height:20px}
++.bt-task-off-title{font-size:14px;line-height:20px;font-weight:600}
++.bt-task-off .bt-set-hint{max-width:380px}
++.bt-task-list{display:flex;flex-direction:column;gap:2px}
++.bt-task-line{display:flex;align-items:center;gap:8px;width:100%;min-width:0;padding:6px 8px;border:0;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:left;cursor:pointer;transition:background-color .12s ease}
++.bt-task-line:hover{background:var(--bt-hover)}
++.bt-task-line svg{flex:none;color:var(--bt-ink-3)}
++.bt-task-line-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-task-line-when{flex:none;font-size:12px;color:var(--bt-ink-3)}
++@media (prefers-reduced-motion:reduce){.bt-task-form{animation:none}}
+ `;
+
+-// src/client/palette.js
+-var import_react31 = require("react");
+-var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
+-var paletteText = (raw, names) => readableLine(String(raw ?? ""), names);
+-var PALETTE_EXIT_MS = 160;
+-function CommandPalette({ start, token, roster, actions, useSessions }) {
+- const [query, setQuery] = (0, import_react31.useState)(start ?? "");
+- const [cursor, setCursor] = (0, import_react31.useState)(0);
+- const [leaving, setLeaving] = (0, import_react31.useState)(false);
+- const wanted = query.trim().toLowerCase();
+- const close = () => {
+- if (leaving) return;
+- setLeaving(true);
+- setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
+- };
+- useEscape(close);
+- const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
+- const names = botNames(roster);
+- const found = useSessions((list) => {
+- if (wanted.length < 2) return "[]";
+- const hits = [];
+- for (const id of ids) {
+- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
+- if (!Array.isArray(outline)) continue;
+- const hidden = roster.exchangeTurns[id] ?? [];
+- for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
+- const entry = outline[index2];
+- if (hidden.includes(entry.turn)) continue;
+- for (const raw of [entry.response, entry.prompt]) {
+- const text2 = paletteText(raw, names);
+- const at = text2.toLowerCase().indexOf(wanted);
+- if (at < 0) continue;
+- const from = Math.max(0, at - 24);
+- hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]);
+- break;
+- }
+- }
++// src/client/capability-settings.js
++var import_react26 = require("react");
++var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] });
++var parseMap = (text2, label) => {
++ let value;
++ try {
++ value = JSON.parse(text2 || "{}");
++ } catch {
++ throw new Error(t("{label} must be valid JSON.", { label }));
++ }
++ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(t("{label} must be a JSON object.", { label }));
++ return value;
++};
++var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…");
++function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) {
++ return (0, import_react26.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t(label))),
++ (0, import_react26.createElement)(multiline ? "textarea" : "input", {
++ className: multiline ? "bt-input bt-input-area" : "bt-input",
++ value,
++ disabled,
++ maxLength: label === "Name" ? 32 : void 0,
++ placeholder,
++ spellCheck: false,
++ onChange: (event) => onChange(event.target.value)
++ })
++ );
++}
++function CapabilityForm({ draft, setDraft, items, actions, onClose }) {
++ const [error, setError] = (0, import_react26.useState)("");
++ const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value }));
++ const submit = async (event) => {
++ event.preventDefault();
++ setError("");
++ let payload;
++ try {
++ payload = { name: draft.name.trim(), kind: draft.kind, description: draft.description };
++ if (draft.kind === "mcp" && draft.transport === "stdio") {
++ payload.transport = "stdio";
++ payload.command = draft.command;
++ payload.args = draft.argsText.split("\n").filter((line2) => line2 !== "");
++ payload.env = parseMap(draft.envText, t("Environment"));
++ } else if (draft.kind === "mcp") {
++ payload.transport = "streamable-http";
++ payload.url = draft.url;
++ payload.headers = parseMap(draft.headersText, t("Headers"));
++ } else if (draft.kind === "skill") payload.content = draft.content;
++ else payload.items = draft.items;
++ } catch (failure) {
++ setError(failure?.message ?? String(failure));
++ return;
++ }
++ try {
++ await actions.saveCapability(payload);
++ onClose();
++ } catch (failure) {
++ setError(failure?.message ?? String(failure));
+ }
+- return JSON.stringify(hits);
+- });
+- const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
+- const sections = [];
+- const people2 = [
+- ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
+- key: bot.id,
+- avatar: (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
+- title: pickerBotLabel(bot),
+- subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
+- badge: bot.hidden ? t("Hidden") : void 0,
+- run: async () => {
+- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
+- actions.openSession(bot.id);
+- }
+- })),
+- ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
+- key: room2.id,
+- avatar: (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+- title: room2.name,
+- subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }),
+- badge: room2.hidden ? t("Hidden") : void 0,
+- run: async () => {
+- if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
+- actions.openSession(room2.id);
+- }
+- }))
+- ];
+- if (people2.length) sections.push([t("Bots"), people2]);
+- const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
+- key: agent.id,
+- avatar: (0, import_react31.createElement)("span", { className: "bt-palette-agent" }, (0, import_react31.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
+- title: "DSH Agent",
+- subtitle: t("Plain dsh session"),
+- run: () => actions.openAgent(agent.id)
+- }));
+- if (agents.length) sections.push(["DSH Agent", agents]);
+- const messages = JSON.parse(found).map(([id, turn, snippet]) => {
+- const bot = roster.byId[id];
+- const room2 = roster.roomsById[id];
+- return {
+- key: `${id}:${turn}`,
+- title: bot?.name ?? room2?.name ?? t("Bot"),
+- subtitle: snippet,
+- avatar: bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
+- run: () => actions.openSession(id)
+- };
+- });
+- if (messages.length) sections.push([t("Messages"), messages]);
+- const commands = [
+- { key: "cmd:new", icon: (0, import_react31.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
+- { key: "cmd:bot", icon: (0, import_react31.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
+- { key: "cmd:group", icon: (0, import_react31.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
+- { key: "cmd:settings", icon: (0, import_react31.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
+- ].filter((command) => matchesName(command.title));
+- if (commands.length) sections.push([t("Commands"), commands]);
+- const rows = sections.flatMap(([, items]) => items);
+- const active = Math.min(cursor, Math.max(rows.length - 1, 0));
+- const shown = rows.map((row) => row.key).join("\n");
+- const glide = useGlide(`${active}
+-${shown}`, { axis: "y", ...QUICK_EDGES });
+- const listRef = glide.box;
+- useFlip(listRef, shown, "[data-flip]");
+- (0, import_react31.useEffect)(() => {
+- listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
+- }, [active, query]);
+- const choose = (row) => {
+- if (!row || leaving) return;
+- void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
+- close();
+ };
+- let index = -1;
+- return (0, import_react31.createElement)(
+- "div",
+- { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
+- (0, import_react31.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
+- (0, import_react31.createElement)(
++ const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind));
++ return (0, import_react26.createElement)(
++ "form",
++ { className: "bt-connector-form", onSubmit: submit },
++ (0, import_react26.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }),
++ (0, import_react26.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t("Type"))),
++ (0, import_react26.createElement)(
++ "select",
++ { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) },
++ ["mcp", "skill", "pack"].map((kind) => (0, import_react26.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack")))
++ )
++ ),
++ (0, import_react26.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }),
++ draft.kind === "mcp" ? (0, import_react26.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, t("Connection"))),
++ (0, import_react26.createElement)(
++ "select",
++ { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) },
++ (0, import_react26.createElement)("option", { value: "stdio" }, t("Local command")),
++ (0, import_react26.createElement)("option", { value: "streamable-http" }, t("HTTP server"))
++ )
++ ) : null,
++ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react26.createElement)(
+ "div",
+- { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
+- (0, import_react31.createElement)(
+- "div",
+- { className: "bt-palette-head" },
+- (0, import_react31.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)),
+- (0, import_react31.createElement)("input", {
+- autoFocus: true,
+- value: query,
+- placeholder: t("Search Bots, chats and messages"),
+- "aria-label": t("Search"),
+- role: "combobox",
+- "aria-expanded": true,
+- "aria-controls": "bt-palette-list",
+- onChange: (event) => {
+- setQuery(event.target.value);
+- setCursor(0);
+- },
+- onKeyDown: (event) => {
+- if (event.nativeEvent.isComposing) return;
+- if (event.key === "ArrowDown") {
+- event.preventDefault();
+- setCursor(Math.min(active + 1, rows.length - 1));
+- } else if (event.key === "ArrowUp") {
+- event.preventDefault();
+- setCursor(Math.max(active - 1, 0));
+- } else if (event.key === "Enter") {
+- event.preventDefault();
+- choose(rows[active]);
+- }
+- }
+- }),
+- query ? (0, import_react31.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
+- setQuery("");
+- setCursor(0);
+- } }, (0, import_react31.createElement)(CloseIcon)) : null
+- ),
+- (0, import_react31.createElement)(
+- "div",
+- { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
+- (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+- rows.length === 0 ? (0, import_react31.createElement)(
+- "div",
+- { className: "bt-palette-empty" },
+- (0, import_react31.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)),
+- (0, import_react31.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
+- (0, import_react31.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
+- ) : sections.map(([title, items]) => (0, import_react31.createElement)(
+- import_react31.Fragment,
+- { key: title },
+- (0, import_react31.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
+- items.map((row) => {
+- index += 1;
+- const at = index;
+- return (0, import_react31.createElement)(
+- "button",
+- {
+- key: row.key,
+- type: "button",
+- role: "option",
+- className: "bt-palette-row",
+- "aria-selected": at === active,
+- "data-flip": row.key,
+- onMouseMove: () => {
+- if (cursor !== at) setCursor(at);
+- },
+- onClick: () => choose(row)
+- },
+- (0, import_react31.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react31.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
+- (0, import_react31.createElement)(
+- "span",
+- { className: "bt-palette-text" },
+- (0, import_react31.createElement)("span", { className: "bt-palette-title" }, row.title),
+- row.subtitle ? (0, import_react31.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
+- ),
+- row.badge ? (0, import_react31.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
+- );
+- })
+- ))
+- )
++ null,
++ (0, import_react26.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }),
++ (0, import_react26.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }),
++ (0, import_react26.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
++ ) : null,
++ draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react26.createElement)(
++ "div",
++ null,
++ (0, import_react26.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }),
++ (0, import_react26.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') })
++ ) : null,
++ draft.kind === "skill" ? (0, import_react26.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null,
++ draft.kind === "pack" ? (0, import_react26.createElement)(
++ "div",
++ { className: "bt-set-card" },
++ available.length === 0 ? (0, import_react26.createElement)("div", { className: "bt-set-row" }, (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react26.createElement)(
++ "label",
++ { key: item.name, className: "bt-set-row bt-bs-row" },
++ (0, import_react26.createElement)("span", { className: "bt-set-copy" }, (0, import_react26.createElement)("span", { className: "bt-bs-label" }, item.name)),
++ (0, import_react26.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) })
++ ))
++ ) : null,
++ error ? (0, import_react26.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null,
++ (0, import_react26.createElement)(
++ "div",
++ { className: "bt-connector-form-row" },
++ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")),
++ (0, import_react26.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability"))
+ )
+ );
+ }
+-
+-// src/client/bot-draft.js
+-function freshBotName(roster, base = "New Bot") {
+- const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
+- if (!taken.has(base.toLowerCase())) return base;
+- let index = 2;
+- while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
+- return `${base} ${index}`;
+-}
+-function startModel(roster) {
+- const models = roster.models ?? [];
+- const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
+- const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
+- return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
+-}
+-function createPayload({ name, look, image, model, effort }) {
+- const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
+- if (model) payload.model = model;
+- if (effort) payload.effort = effort;
+- return payload;
+-}
+-async function afterCreate({ id, text: text2, actions }) {
+- actions.openSession(id);
+- actions.showDetails(id, "settings");
+- const line2 = String(text2 ?? "").trim();
+- if (line2 === "") return;
+- try {
+- await actions.send(id, line2);
+- } catch (failure) {
+- actions.notify?.(failure?.message ?? String(failure));
+- }
+-}
+-
+-// src/client/create-bot.js
+-var import_react32 = require("react");
+-var pick = (list) => list[Math.floor(Math.random() * list.length)];
+-var randomLook = (avoid = {}) => {
+- const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
+- const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
+- return { shape: pick(shapes), color: pick(colors) };
+-};
+-function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
+- const [look, setLook] = (0, import_react32.useState)(() => randomLook());
+- const [image, setImage] = (0, import_react32.useState)(null);
+- const [name, setName] = (0, import_react32.useState)(start?.name ?? "");
+- const [model, setModel] = (0, import_react32.useState)(() => startModel(roster));
+- const [effort, setEffort] = (0, import_react32.useState)(void 0);
+- const [busy, setBusy] = (0, import_react32.useState)(false);
+- const [error, setError] = (0, import_react32.useState)("");
+- const fileRef = (0, import_react32.useRef)(null);
+- const frame = (0, import_react32.useRef)(null);
+- useEscape(onBack);
+- (0, import_react32.useEffect)(() => {
+- if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
+- }, []);
+- const fallbackName = freshBotName(roster, t("New Bot"));
+- const finalName = name.trim() || fallbackName;
+- const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
+- const problem = nameProblem(finalName);
+- const create = async () => {
+- if (busy || clash || problem) return;
+- setError("");
+- setBusy(true);
+- try {
+- const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
+- await onCreated(bot);
+- } catch (failure) {
+- setError(failure?.message ?? String(failure));
+- setBusy(false);
+- }
+- };
+- const upload = (file) => {
+- if (!file) return;
+- setError("");
+- avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
+- };
+- const previewLook = image ? { ...look, image } : look;
+- const card = (0, import_react32.createElement)(
++function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) {
++ const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill");
++ const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail;
++ return (0, import_react26.createElement)(
+ "div",
+- { className: "bt-create-card" },
+- (0, import_react32.createElement)(
++ { className: "bt-connector" },
++ (0, import_react26.createElement)(
+ "div",
+- { className: "bt-create-preview" },
+- // The key remounts the mark, so every new pick plays the pop.
+- (0, import_react32.createElement)(
++ { className: "bt-connector-head" },
++ (0, import_react26.createElement)(
+ "span",
+- { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
+- (0, import_react32.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
++ { className: "bt-connector-text" },
++ (0, import_react26.createElement)("span", { className: "bt-connector-name" }, item.name),
++ (0, import_react26.createElement)("span", { className: "bt-connector-status" }, secondary)
+ ),
+- (0, import_react32.createElement)("span", { className: "bt-create-name" }, finalName)
+- ),
+- (0, import_react32.createElement)("div", { className: "bt-create-step" }, t("Look")),
+- (0, import_react32.createElement)(
+- "div",
+- { className: "bt-create-looks" },
+- (0, import_react32.createElement)(CharacterGrid, {
+- look,
+- hasPhoto: Boolean(image),
+- onShape: (shape) => {
+- setImage(null);
+- setLook((current) => ({ ...current, shape }));
+- },
+- onColor: (color) => {
+- setImage(null);
+- setLook((current) => ({ ...current, color }));
+- }
+- }),
+- (0, import_react32.createElement)(
+- "div",
+- { className: "bt-actions bt-create-lookbar" },
+- (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+- setImage(null);
+- setLook((current) => randomLook(current));
+- } }, t("Shuffle")),
+- (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
+- image ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
+- (0, import_react32.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
+- const file = event.target.files?.[0];
+- event.target.value = "";
+- upload(file);
+- } })
++ (0, import_react26.createElement)(
++ "span",
++ { className: "bt-connector-actions" },
++ item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null,
++ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")),
++ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete"))
+ )
+- ),
+- (0, import_react32.createElement)(
+- "label",
+- { className: "bt-field" },
+- (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Name")),
+- (0, import_react32.createElement)("input", {
+- className: "bt-input",
+- autoFocus: !inline,
+- value: name,
+- placeholder: fallbackName,
+- maxLength: 40,
+- "aria-label": t("Name"),
+- onChange: (event) => setName(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Enter" && !event.nativeEvent.isComposing) {
+- event.preventDefault();
+- void create();
+- }
+- }
+- }),
+- problem ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
+- ),
+- (0, import_react32.createElement)(
++ )
++ );
++}
++function CapabilitiesPage({ roster, actions, run }) {
++ const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name));
++ const [draft, setDraft] = (0, import_react26.useState)(null);
++ const [confirming, setConfirming] = (0, import_react26.useState)("");
++ (0, import_react26.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(""), 4e3);
++ return () => clearTimeout(timer);
++ }, [confirming]);
++ const startNew = () => setDraft(blank());
++ const edit = (item) => setDraft({
++ ...blank(),
++ ...item,
++ originalName: item.name,
++ argsText: (item.args ?? []).join("\n"),
++ envText: JSON.stringify(item.env ?? {}, null, 2),
++ headersText: JSON.stringify(item.headers ?? {}, null, 2),
++ items: [...item.items ?? []]
++ });
++ const remove = (item) => {
++ if (confirming !== item.name) {
++ setConfirming(item.name);
++ return;
++ }
++ setConfirming("");
++ void run(actions.deleteCapability(item.name));
++ };
++ return (0, import_react26.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react26.createElement)("h3", null, t("Shared capabilities")),
++ (0, import_react26.createElement)(
+ "div",
+- { className: "bt-field" },
+- (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Model")),
+- (0, import_react32.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
+- setModel(ref);
+- setEffort(void 0);
+- }, onPickEffort: setEffort }),
+- (0, import_react32.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
++ { className: "bt-actions" },
++ (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability"))
+ ),
+- error ? (0, import_react32.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
+- (0, import_react32.createElement)(
++ draft ? (0, import_react26.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null,
++ (0, import_react26.createElement)(
+ "div",
+- { className: "bt-create-foot" },
+- // The dialog's header already has Back and Close (#230); only the inline form needs its own.
+- inline ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
+- (0, import_react32.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
++ { className: "bt-set-card bt-connectors-card" },
++ items.length === 0 ? (0, import_react26.createElement)("div", { className: "bt-set-row" }, (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react26.createElement)(CapabilityRow, {
++ key: item.name,
++ item,
++ confirming: confirming === item.name,
++ onEdit: () => edit(item),
++ onDelete: () => remove(item),
++ onApprove: () => void run(actions.approveCapability(item.name))
++ }))
+ )
+ );
+- if (inline) return (0, import_react32.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
+- return (0, import_react32.createElement)(
+- "div",
+- { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") },
+- (0, import_react32.createElement)(
+- "div",
+- { className: "bt-create-head" },
+- canGoBack ? (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react32.createElement)(ChevronLeftIcon)) : (0, import_react32.createElement)("span", { className: "bt-create-gap" }),
+- (0, import_react32.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
+- (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react32.createElement)(CloseIcon))
+- ),
+- (0, import_react32.createElement)("div", { className: "bt-create-body" }, card)
++}
++function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
++ const items = [...roster.capabilities ?? []].sort((a, b) => Number(b.kind === "pack") - Number(a.kind === "pack") || a.name.localeCompare(b.name));
++ const attached = new Set(bot.capabilities ?? []);
++ const statuses = roster.capabilityStatuses?.[bot.id] ?? {};
++ if (items.length === 0) return null;
++ return (0, import_react26.createElement)(
++ Card2,
++ { title: t("Capabilities") },
++ items.map((item) => (0, import_react26.createElement)(SwitchRow2, {
++ key: item.name,
++ label: item.name,
++ hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0,
++ on: attached.has(item.name),
++ onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled))
++ }))
+ );
+ }
+
+-// src/client/new-chat.js
+-var import_react33 = require("react");
+-function NewChat({ mode, roster, actions }) {
+- const [query, setQuery] = (0, import_react33.useState)("");
+- const [picked, setPicked] = (0, import_react33.useState)([]);
+- const [group, setGroup] = (0, import_react33.useState)(mode === "group");
+- const [compose, setCompose] = (0, import_react33.useState)(mode !== "new");
+- const [admin, setAdmin] = (0, import_react33.useState)(null);
+- const [message, setMessage] = (0, import_react33.useState)("");
+- const [busy, setBusy] = (0, import_react33.useState)(false);
+- const [error, setError] = (0, import_react33.useState)("");
+- const [fix, setFix] = (0, import_react33.useState)(null);
+- const [fixNote, setFixNote] = (0, import_react33.useState)("");
+- const [inviteLink, setInviteLink] = (0, import_react33.useState)("");
+- const [cursor, setCursor] = (0, import_react33.useState)(0);
+- const [draft, setDraft] = (0, import_react33.useState)(mode === "create" ? { name: "", fromList: false } : null);
+- const inputRef = (0, import_react33.useRef)(null);
+- const close = () => actions.closeOverlay();
+- useEscape(close);
+- const paneLeft = usePaneLeft();
+- const wanted = query.trim().toLowerCase();
+- const bots = listedChatBots(roster);
+- const choosingAdmin = group && admin === null;
+- const pool = choosingAdmin ? bots.filter((bot) => !bot.remote) : bots;
+- const matches = pool.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted)));
+- const exact = bots.some((bot) => bot.name.toLowerCase() === wanted);
+- const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean);
+- const run = async (task) => {
+- if (busy) return;
+- setError("");
+- setFix(null);
+- setFixNote("");
+- setInviteLink("");
+- setBusy(true);
+- try {
+- await task();
+- } catch (failure) {
+- setError(failure?.message ?? String(failure));
+- setFix(ungrantedLocalFix(failure?.details) ?? null);
+- } finally {
+- setBusy(false);
++// src/client/connector-bots.js
++var import_react27 = require("react");
++function ownBots(roster) {
++ const me = roster?.self?.memberId;
++ return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me));
++}
++var connectedConnectors = (roster) => (roster?.connectors ?? []).filter((connector) => connector.state !== "off" && connector.kind !== "sender" && !(connector.kind === "channel" && connector.mode !== "app"));
++var isOn = (bot, id) => bot.connectors?.[id] !== false;
++function ownHint(bot, id) {
++ return isOn(bot, id) ? t("On. This Bot can use it on every turn.") : t("Off. This Bot will not use it.");
++}
++function usageText(roster, id) {
++ const bots = ownBots(roster);
++ const own = bots.filter((bot) => isOn(bot, id)).length;
++ return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length });
++}
++function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) {
++ return (0, import_react27.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) });
++}
++function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) {
++ const bots = ownBots(roster);
++ return (0, import_react27.createElement)(
++ "div",
++ { className: "bt-connector-bots" },
++ bots.length === 0 ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react27.createElement)(
++ "div",
++ { key: bot.id, className: "bt-connector-bot" },
++ (0, import_react27.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 })
++ )),
++ (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it."))
++ );
++}
++function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) {
++ const connected = connectedConnectors(roster);
++ if (connected.length === 0) return null;
++ return (0, import_react27.createElement)(
++ Card2,
++ { title: t("Connectors") },
++ connected.map((connector) => (0, import_react27.createElement)(
++ "div",
++ { key: connector.id, className: "bt-connector-bot" },
++ (0, import_react27.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 })
++ ))
++ );
++}
++
++// src/client/memory-panel.js
++var import_react28 = require("react");
++var POLL_MS5 = 1500;
++var MENU_EXIT_MS2 = 120;
++var counts = /* @__PURE__ */ new Map();
++var countListeners = /* @__PURE__ */ new Set();
++function noteCount(botId, count) {
++ if (counts.get(botId) === count) return;
++ counts.set(botId, count);
++ for (const listener of countListeners) listener();
++}
++function useMemoryCount(botId, actions) {
++ const [count, setCount] = (0, import_react28.useState)(counts.get(botId) ?? null);
++ (0, import_react28.useEffect)(() => {
++ const listener = () => setCount(counts.get(botId) ?? null);
++ countListeners.add(listener);
++ listener();
++ let stop = false;
++ void actions.memoryView?.(botId).then((view) => {
++ if (!stop) noteCount(botId, view.entries.length);
++ }).catch(() => {
++ });
++ return () => {
++ stop = true;
++ countListeners.delete(listener);
++ };
++ }, [botId]);
++ return count;
++}
++function MemoryRow({ bot, actions }) {
++ const count = useMemoryCount(bot.id, actions);
++ const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
++ return (0, import_react28.createElement)(
++ "div",
++ null,
++ (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Memory")),
++ (0, import_react28.createElement)(
++ "button",
++ { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
++ (0, import_react28.createElement)(MemoryIcon),
++ (0, import_react28.createElement)("span", { className: "bt-mem-row-copy" }, label),
++ (0, import_react28.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react28.createElement)(ChevronRightIcon))
++ )
++ );
++}
++function ago(at, now) {
++ const seconds = Math.max(0, Math.round((now - at) / 1e3));
++ if (seconds < 60) return null;
++ const format = new Intl.RelativeTimeFormat(dateLocale(), { numeric: "auto" });
++ const minutes = Math.round(seconds / 60);
++ if (minutes < 60) return format.format(-minutes, "minute");
++ const hours = Math.round(minutes / 60);
++ if (hours < 24) return format.format(-hours, "hour");
++ return format.format(-Math.round(hours / 24), "day");
++}
++function Skeleton({ label }) {
++ return (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-skel", "aria-busy": true },
++ [0, 1].map((block) => (0, import_react28.createElement)(
++ "div",
++ { key: block, className: "bt-mem-skel-block" },
++ (0, import_react28.createElement)("span", { className: "bt-mem-skel-head" }),
++ (0, import_react28.createElement)("span", null),
++ (0, import_react28.createElement)("span", null),
++ (0, import_react28.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } })
++ )),
++ label ? (0, import_react28.createElement)("div", { className: "bt-mem-skel-label" }, label) : null
++ );
++}
++function Summary({ bot, view, regenerate }) {
++ if (view.entries.length === 0) {
++ return (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-empty" },
++ (0, import_react28.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react28.createElement)(MemoryIcon)),
++ (0, import_react28.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })),
++ (0, import_react28.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name }))
++ );
++ }
++ const failed = view.error && !view.summarizing ? (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-failed", role: "alert" },
++ (0, import_react28.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })),
++ (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again"))
++ ) : null;
++ if (!view.summary) return (0, import_react28.createElement)(import_react28.Fragment, null, failed, failed ? null : (0, import_react28.createElement)(Skeleton, { label: t("Writing the summary…") }));
++ return (0, import_react28.createElement)(
++ import_react28.Fragment,
++ null,
++ failed,
++ (0, import_react28.createElement)(
++ "div",
++ { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 },
++ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react28.createElement)("h3", { key: index }, block.text) : (0, import_react28.createElement)("p", { key: index }, block.text))
++ )
++ );
++}
++var groupEntries = (entries2) => entries2.reduce((groups, entry) => {
++ const key = `${entry.scope}:${entry.topic ?? ""}`;
++ const group = groups.find((item) => item.key === key);
++ if (group) group.entries.push(entry);
++ else groups.push({ key, scope: entry.scope, topic: entry.topic, entries: [entry] });
++ return groups;
++}, []);
++function EntryRow({ entry, remove }) {
++ const [confirming, setConfirming] = (0, import_react28.useState)(false);
++ const [busy, setBusy] = (0, import_react28.useState)(false);
++ (0, import_react28.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(false), 3e3);
++ return () => clearTimeout(timer);
++ }, [confirming]);
++ const by = entry.source === "memory panel" ? t("by you") : entry.by ? t("by {name}", { name: entry.by }) : null;
++ const meta = [entry.added, by].filter(Boolean).join(" · ");
++ const click = async () => {
++ if (!confirming) {
++ setConfirming(true);
++ return;
+ }
++ setBusy(true);
++ if (!await remove(entry)) setBusy(false);
+ };
+- const leaveOut = (id) => {
+- if (id === admin) setAdmin(null);
+- setPicked((current) => current.filter((other) => other !== id));
++ return (0, import_react28.createElement)(
++ "li",
++ { className: "bt-mem-entry", "data-busy": busy || void 0 },
++ (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-entry-copy" },
++ (0, import_react28.createElement)("span", { className: "bt-mem-entry-text" }, entry.text),
++ meta ? (0, import_react28.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null
++ ),
++ (0, import_react28.createElement)("button", {
++ type: "button",
++ className: "bt-mem-remove",
++ "data-confirm": confirming || void 0,
++ disabled: busy,
++ "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"),
++ title: confirming ? void 0 : t("Remove from memory"),
++ onClick: click
++ }, confirming ? t("Remove") : (0, import_react28.createElement)(TrashIcon))
++ );
++}
++function Entries({ bot, view, remove }) {
++ const groups = groupEntries(view.entries);
++ const sections = [
++ { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own },
++ { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team }
++ ];
++ return (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-entries" },
++ sections.map((section) => (0, import_react28.createElement)(
++ "section",
++ { key: section.scope, className: "bt-mem-section" },
++ (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-section-head" },
++ (0, import_react28.createElement)("h3", null, section.title),
++ section.hint ? (0, import_react28.createElement)("span", null, section.hint) : null,
++ (0, import_react28.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) }))
++ ),
++ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react28.createElement)(
++ import_react28.Fragment,
++ { key: group.key },
++ group.topic ? (0, import_react28.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null,
++ (0, import_react28.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react28.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove })))
++ )) : (0, import_react28.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet"))
++ ))
++ );
++}
++function changeLine(change) {
++ const text2 = change.text.length > 120 ? `${change.text.slice(0, 120)}…` : change.text;
++ if (!change.ok) return t("Not changed: {text} ({reason})", { text: text2, reason: hostText(change.error ?? "") });
++ if (change.op === "remove") return change.scope === "team" ? t("Removed from team memory: {text}", { text: text2 }) : t("Removed: {text}", { text: text2 });
++ if (change.op === "edit") return t("Updated: {text}", { text: text2 });
++ return change.scope === "team" ? t("Added to team memory: {text}", { text: text2 }) : t("Added: {text}", { text: text2 });
++}
++function Reply({ answer, dismiss }) {
++ return (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-reply", role: "status" },
++ (0, import_react28.createElement)(
++ "div",
++ { className: "bt-mem-reply-head" },
++ (0, import_react28.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked),
++ answer.pending ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react28.createElement)(CloseIcon))
++ ),
++ answer.pending ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react28.createElement)(
++ import_react28.Fragment,
++ null,
++ answer.reply ? (0, import_react28.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null,
++ answer.changes.length > 0 ? (0, import_react28.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react28.createElement)(
++ "li",
++ { key: index, "data-ok": change.ok || void 0 },
++ change.ok ? (0, import_react28.createElement)(CheckIcon) : (0, import_react28.createElement)(CloseIcon),
++ (0, import_react28.createElement)("span", null, changeLine(change))
++ ))) : null
++ )
++ );
++}
++function useMemoryView(botId, actions) {
++ const [view, setView] = (0, import_react28.useState)(null);
++ const [error, setError] = (0, import_react28.useState)("");
++ const timer = (0, import_react28.useRef)(0);
++ const latest = (0, import_react28.useRef)(0);
++ const alive = (0, import_react28.useRef)(true);
++ const show = (0, import_react28.useCallback)((next) => {
++ setView(next);
+ setError("");
+- setFix(null);
+- setFixNote("");
+- setInviteLink("");
+- };
+- const copyInvite = async (bot) => {
+- const social = globalThis.__flybot__?.social;
+- let link;
++ noteCount(botId, next.entries.length);
++ }, [botId]);
++ const load = (0, import_react28.useCallback)(async (flags = {}) => {
++ clearTimeout(timer.current);
++ const ticket = ++latest.current;
+ try {
+- const invite = await social.createInvite(bot.id);
+- link = invite?.link || invite?.url;
+- if (!link) throw new Error("no link");
+- } catch {
+- setInviteLink("");
+- setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot."));
+- return;
+- }
+- if (await copiedToClipboard(link)) {
+- setInviteLink("");
+- setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name }));
+- } else {
+- setInviteLink(link);
+- setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name }));
++ const next = await actions.memoryView(botId, flags);
++ if (!alive.current || ticket !== latest.current) return;
++ show(next);
++ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5);
++ } catch (failure) {
++ if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure));
+ }
++ }, [botId]);
++ (0, import_react28.useEffect)(() => {
++ alive.current = true;
++ void load({ summarize: true });
++ return () => {
++ alive.current = false;
++ clearTimeout(timer.current);
++ };
++ }, [botId]);
++ return { view, error, load, show };
++}
++function useNow3(every) {
++ const [now, setNow] = (0, import_react28.useState)(Date.now());
++ (0, import_react28.useEffect)(() => {
++ const id = setInterval(() => setNow(Date.now()), every);
++ return () => clearInterval(id);
++ }, [every]);
++ return now;
++}
++function MemoryDialog({ bot, actions, leaving = false }) {
++ const { view, error, load, show } = useMemoryView(bot.id, actions);
++ const [page, setPage] = (0, import_react28.useState)("summary");
++ const [menu, setMenu] = (0, import_react28.useState)(false);
++ const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2);
++ const [text2, setText] = (0, import_react28.useState)("");
++ const [answer, setAnswer] = (0, import_react28.useState)(null);
++ const menuRef = (0, import_react28.useRef)(null);
++ const dotsRef = (0, import_react28.useRef)(null);
++ const inputRef = (0, import_react28.useRef)(null);
++ const now = useNow3(3e4);
++ const close = (0, import_react28.useCallback)(() => actions.closeMemory(), []);
++ useEscape(close);
++ (0, import_react28.useEffect)(() => {
++ if (!menu) return void 0;
++ const onDown = (event) => {
++ if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return;
++ setMenu(false);
++ };
++ const onKey = (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ setMenu(false);
++ dotsRef.current?.focus();
++ }
++ };
++ window.addEventListener("mousedown", onDown);
++ window.addEventListener("keydown", onKey, true);
++ return () => {
++ window.removeEventListener("mousedown", onDown);
++ window.removeEventListener("keydown", onKey, true);
++ };
++ }, [menu]);
++ const regenerate = () => {
++ setMenu(false);
++ setPage("summary");
++ void load({ regenerate: true });
+ };
+- const pick2 = (bot) => {
+- setPicked((current) => [...current, bot.id]);
+- setQuery("");
+- setCursor(0);
+- inputRef.current?.focus();
+- };
+- const chooseAdmin = (bot) => {
+- setAdmin(bot.id);
+- setQuery("");
+- setCursor(0);
+- inputRef.current?.focus();
+- };
+- const openBot = (bot) => run(async () => {
+- if (choosingAdmin) {
+- chooseAdmin(bot);
+- return;
+- }
+- if (group || picked.length > 0) {
+- pick2(bot);
+- return;
+- }
+- if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
+- if (message.trim()) await actions.send(bot.id, message.trim());
+- actions.openSession(bot.id);
+- close();
+- });
+- const createBot = () => {
+- if (busy) return;
+- setError("");
+- setDraft({ name: query.trim() !== "" && !exact ? query.trim() : "", fromList: true });
+- };
+- const created = (bot) => {
+- close();
+- return afterCreate({ id: bot.id, text: message, actions });
++ const openEntries = () => {
++ setMenu(false);
++ setPage("entries");
+ };
+- const makeGroup = async () => {
+- if (admin === null || picked.length === 0) return;
+- const text2 = message.trim();
+- const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]);
+- const participants = remotes.length === 0 ? void 0 : [
+- ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })),
+- ...remotes
+- ];
+- const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} });
+- if (text2) await actions.send(room2.id, text2);
+- actions.openSession(room2.id);
+- close();
++ const backToSummary = () => {
++ setPage("summary");
++ void load({ summarize: true });
+ };
+- const createGroup = () => run(makeGroup);
+- const sendMessage = () => run(async () => {
+- const text2 = message.trim();
+- if (group && admin !== null) {
+- await makeGroup();
+- return;
+- }
+- if (text2 === "" || picked.length === 0) return;
+- if (picked.length === 1) {
+- await actions.send(picked[0], text2);
+- actions.openSession(picked[0]);
+- close();
+- return;
+- }
+- const { members, remotes } = splitRoomPicks(roster, picked);
+- const participants = remotes.length === 0 ? void 0 : [
+- ...members.map((id) => ({ kind: "bot", id })),
+- ...remotes
+- ];
+- const room2 = await actions.createRoom(members, participants ? { participants } : {});
+- await actions.send(room2.id, text2);
+- actions.openSession(room2.id);
+- close();
+- });
+- const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === "";
+- const options = [];
+- if (!group && picked.length === 0 && mode !== "search" && wanted === "") {
+- if (menuOnly) {
+- options.push({ key: "chat", icon: (0, import_react33.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
+- setCompose(true);
+- setCursor(0);
+- setTimeout(() => inputRef.current?.focus());
+- } });
+- }
+- options.push({ key: "new", icon: (0, import_react33.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
+- options.push({ key: "group", icon: (0, import_react33.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
+- setGroup(true);
+- setCompose(true);
+- setCursor(0);
+- inputRef.current?.focus();
+- } });
+- for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
+- options.push({
+- key: action.key,
+- icon: action.icon ? (0, import_react33.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react33.createElement)(PeopleIcon) }) : (0, import_react33.createElement)(PeopleIcon),
+- label: t(action.label),
+- hint: action.hint ? t(action.hint) : void 0,
+- run: () => {
+- const done = Promise.resolve().then(() => action.run({ close, actions, roster }));
+- done.then((note) => {
+- if (typeof note === "string" && note) actions.notify?.(note);
+- }).catch((failure) => setError(failure?.message ?? String(failure)));
+- }
+- });
+- }
+- if ((roster.agents ?? []).length === 0) {
+- options.push({ key: "agent", icon: (0, import_react33.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
+- await actions.addAgent();
+- close();
+- }) });
++ const remove = async (entry) => {
++ try {
++ show(await actions.memoryForget(bot.id, entry));
++ return true;
++ } catch (failure) {
++ setAnswer({ asked: entry.text, error: failure?.message ?? String(failure) });
++ return false;
+ }
+- }
+- if (!menuOnly) {
+- for (const bot of matches) {
+- options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) });
++ };
++ const ask = async (event) => {
++ event.preventDefault();
++ const asked = text2.trim();
++ if (asked === "" || answer?.pending) return;
++ setText("");
++ setAnswer({ asked, pending: true });
++ try {
++ const result = await actions.memoryAsk(bot.id, asked);
++ setAnswer({ asked, reply: result.reply, changes: result.changes });
++ show(result.view);
++ if (result.changes.some((change) => change.ok)) void load({ summarize: page === "summary" });
++ } catch (failure) {
++ setAnswer({ asked, error: failure?.message ?? String(failure) });
+ }
+- }
+- if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react33.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
+- const active = Math.min(cursor, Math.max(options.length - 1, 0));
+- const listed = options.map((option) => option.key).join("\n");
+- const glide = useGlide(`${active}
+-${listed}`, { axis: "y", ...QUICK_EDGES });
+- useFlip(glide.box, listed, "[data-flip]");
+- const drop = (0, import_react33.useRef)(null);
+- useMorph(drop, listed);
+- if (draft) {
+- return (0, import_react33.createElement)(
+- "div",
+- { className: "bt-pane", style: { left: paneLeft } },
+- (0, import_react33.createElement)(CreateBot, {
+- roster,
+- actions,
+- start: draft,
+- canGoBack: draft.fromList,
+- onBack: draft.fromList ? () => {
+- setDraft(null);
+- setTimeout(() => inputRef.current?.focus());
+- } : close,
+- onCreated: created
+- })
+- );
+- }
+- const adminBot = admin === null ? void 0 : roster.byId[admin];
+- const recipients = adminBot ? [adminBot, ...pickedBots] : pickedBots;
+- const placeholder = recipients.length > 0 ? t("Message {name}", { name: recipients.map((bot) => bot.name).join(t(" and ")) }) : t("Message Bot");
+- const unpick = () => {
+- if (picked.length > 0) setPicked((current) => current.slice(0, -1));
+- else if (admin !== null) setAdmin(null);
++ inputRef.current?.focus();
+ };
+- return (0, import_react33.createElement)(
++ const count = view?.entries.length ?? 0;
++ const when3 = view?.summary ? ago(view.summary.at, now) : null;
++ const status = view === null ? "" : page === "entries" ? count === 1 ? t("1 entry") : t("{count} entries", { count }) : view.summarizing && view.summary ? t("Updating…") : view.summary && view.fresh ? when3 ? t("Updated {time}", { time: when3 }) : t("Updated just now") : "";
++ const busy = answer?.pending === true;
++ return (0, import_react28.createElement)(
+ "div",
+- {
+- className: "bt-pane",
+- style: { left: paneLeft },
+- "data-menu": menuOnly || void 0,
+- onMouseDown: menuOnly ? (event) => {
+- if (event.target === event.currentTarget) close();
+- } : void 0
+- },
+- (0, import_react33.createElement)(
++ { className: "bt-mem-layer", "data-leaving": leaving || void 0 },
++ (0, import_react28.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }),
++ (0, import_react28.createElement)(
+ "div",
+- { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 },
+- // The popover needs no title row: Escape or a press outside closes it.
+- menuOnly ? null : (0, import_react33.createElement)(
++ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) },
++ (0, import_react28.createElement)(
+ "div",
+- { className: "bt-to" },
+- (0, import_react33.createElement)("span", { className: "bt-to-label" }, t("To:")),
+- adminBot ? (0, import_react33.createElement)(
+- "span",
+- { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") },
+- (0, import_react33.createElement)(BotMark, { bot: adminBot, size: 16 }),
+- adminBot.name,
+- (0, import_react33.createElement)(AdminStar),
+- (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
+- ) : null,
+- choosingAdmin ? (0, import_react33.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react33.createElement)(AdminStar)) : null,
+- pickedBots.map((bot) => (0, import_react33.createElement)(
+- "span",
+- { key: bot.id, className: "bt-token" },
+- (0, import_react33.createElement)(BotMark, { bot, size: 16 }),
+- bot.name,
+- (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
+- )),
+- (0, import_react33.createElement)("input", {
+- ref: inputRef,
+- autoFocus: true,
+- value: query,
+- "aria-label": t("Recipient"),
+- placeholder: mode === "search" ? t("Search Bots…") : choosingAdmin ? t("Choose the group admin") : group ? t("Who to chat with…") : t("Start a chat with…"),
+- onChange: (event) => {
+- setQuery(event.target.value);
+- setCursor(0);
+- },
+- onKeyDown: (event) => {
+- if (event.nativeEvent.isComposing) return;
+- if (event.key === "ArrowDown") {
+- event.preventDefault();
+- setCursor(Math.min(active + 1, options.length - 1));
+- } else if (event.key === "ArrowUp") {
+- event.preventDefault();
+- setCursor(Math.max(active - 1, 0));
+- } else if (event.key === "Enter" && options[active]) {
+- event.preventDefault();
+- options[active].run();
+- } else if (event.key === "Backspace" && query === "") unpick();
+- }
+- }),
+- group && admin !== null ? (0, import_react33.createElement)("button", {
+- type: "button",
+- className: "bt-send bt-to-create",
+- disabled: busy || picked.length === 0,
+- title: picked.length === 0 ? t("Add at least one more Bot") : void 0,
+- onClick: createGroup
+- }, t("Create group chat")) : null,
+- (0, import_react33.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
++ { className: "bt-mem-head" },
++ page === "entries" ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react28.createElement)(ChevronLeftIcon)) : null,
++ (0, import_react28.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")),
++ (0, import_react28.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status),
++ (0, import_react28.createElement)("span", { className: "bt-mem-head-gap" }),
++ (0, import_react28.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react28.createElement)(DotsIcon)),
++ (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react28.createElement)(CloseIcon)),
++ menuShown ? (0, import_react28.createElement)(
++ "div",
++ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 },
++ (0, import_react28.createElement)(
++ "button",
++ { type: "button", role: "menuitem", onClick: openEntries },
++ (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("All entries")),
++ (0, import_react28.createElement)("span", { className: "bt-menu-hint" }, String(count))
++ ),
++ (0, import_react28.createElement)(
++ "button",
++ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate },
++ (0, import_react28.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary"))
++ )
++ ) : null
+ ),
+- (0, import_react33.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react33.createElement)(
+- "div",
+- { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" },
+- (0, import_react33.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+- options.length === 0 ? (0, import_react33.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react33.createElement)(
+- "button",
+- {
+- key: option.key,
+- type: "button",
+- role: "option",
+- className: "bt-option",
+- "aria-selected": index === active,
+- "data-flip": option.key,
+- onMouseEnter: () => setCursor(index),
+- onClick: option.run
+- },
+- option.bot ? (0, import_react33.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react33.createElement)("span", { className: "bt-option-icon" }, option.icon),
+- // An action row's description sits under its title and is always there (#283): a long line
+- // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows.
+- // A Bot row's short hint stays beside the name and shows on the active row only.
+- option.bot || !option.hint ? (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react33.createElement)("span", { className: "bt-option-copy" }, (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react33.createElement)("span", { className: "bt-option-sub" }, option.hint)),
+- option.bot && option.hint && index === active ? (0, import_react33.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
+- ))
+- )),
+- menuOnly ? null : (0, import_react33.createElement)("div", { className: "bt-nc-spacer" }),
+- error && fix ? (0, import_react33.createElement)(
++ (0, import_react28.createElement)(
+ "div",
+- { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" },
+- (0, import_react33.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
+- (0, import_react33.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
+- typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react33.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
+- void copyInvite(bot);
+- } }, t("Copy invite link for {name}", { name: bot.name })) : null,
+- (0, import_react33.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
+- ])),
+- fixNote ? (0, import_react33.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
+- inviteLink ? (0, import_react33.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
+- ) : error ? (0, import_react33.createElement)("div", { className: "bt-error" }, error) : null,
+- menuOnly ? null : (0, import_react33.createElement)(
++ { className: "bt-mem-body" },
++ error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null,
++ view === null ? error ? null : (0, import_react28.createElement)(Skeleton) : page === "entries" ? (0, import_react28.createElement)(Entries, { bot, view, remove }) : (0, import_react28.createElement)(Summary, { bot, view, regenerate })
++ ),
++ (0, import_react28.createElement)(
+ "div",
+- { className: "bt-nc-compose" },
+- (0, import_react33.createElement)(
+- "div",
+- { className: "bt-nc-card" },
+- (0, import_react33.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react33.createElement)(PlusIcon)),
+- (0, import_react33.createElement)("textarea", {
+- rows: 1,
+- value: message,
+- placeholder,
+- "aria-label": placeholder,
+- onChange: (event) => setMessage(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+- event.preventDefault();
+- sendMessage();
+- }
+- }
++ { className: "bt-mem-foot" },
++ answer ? (0, import_react28.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null,
++ (0, import_react28.createElement)(
++ "form",
++ { className: "bt-mem-ask", onSubmit: ask },
++ (0, import_react28.createElement)("input", {
++ ref: inputRef,
++ className: "bt-mem-input",
++ value: text2,
++ maxLength: 2e3,
++ disabled: view === null,
++ placeholder: t("Ask or update"),
++ "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }),
++ onChange: (event) => setText(event.target.value)
+ }),
+- (0, import_react33.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react33.createElement)(ArrowUpIcon))
++ (0, import_react28.createElement)(
++ "button",
++ { type: "submit", className: "bt-mem-send", disabled: busy || text2.trim() === "", "aria-label": t("Send") },
++ busy ? (0, import_react28.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react28.createElement)(ArrowUpIcon)
++ )
+ )
+ )
+ )
+ );
+ }
+
+-// src/client/exchange.js
+-var import_react34 = require("react");
+-var SHEET_EXIT_MS = 220;
+-function exchangeText(text2) {
+- if (typeof text2 !== "string") return "";
+- const CLOSE = "";
+- let body = text2;
+- for (let previous; previous !== body; ) {
+- previous = body;
+- const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body;
+- body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : ""));
++// src/client/standing-approvals.js
++var import_react29 = require("react");
++var standingOf = (target, roster) => {
++ const me = roster?.self?.memberId;
++ return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me);
++};
++function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card2 }) {
++ const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? [];
++ if (grants.length === 0) return null;
++ const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking.");
++ return (0, import_react29.createElement)(
++ Card2,
++ { title: t("Always allowed") },
++ grants.map((grant) => (0, import_react29.createElement)(
++ "div",
++ { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind },
++ (0, import_react29.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react29.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind),
++ (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint)
++ ),
++ (0, import_react29.createElement)("button", {
++ type: "button",
++ className: "bt-soft bt-soft-sm",
++ "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }),
++ onClick: () => run(actions.removeStandingApproval(scope, target.id, grant.kind))
++ }, t("Remove"))
++ ))
++ );
++}
++
++// src/client/bot-settings.js
++var import_react30 = require("react");
++var capitalize = (word) => word[0].toUpperCase() + word.slice(1);
++async function avatarImage(file) {
++ if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) throw new Error(t("Choose a PNG, JPEG, WebP, or GIF image"));
++ if (file.size > 25 * 1024 * 1024) throw new Error(t("Choose an image smaller than 25 MB"));
++ const bitmap = await createImageBitmap(file);
++ const side = 160;
++ const canvas = document.createElement("canvas");
++ canvas.width = side;
++ canvas.height = side;
++ const context = canvas.getContext("2d");
++ context.imageSmoothingQuality = "high";
++ const crop = Math.min(bitmap.width, bitmap.height);
++ context.drawImage(bitmap, (bitmap.width - crop) / 2, (bitmap.height - crop) / 2, crop, crop, 0, 0, side, side);
++ bitmap.close?.();
++ for (const quality of [0.9, 0.75, 0.6]) {
++ const url = canvas.toDataURL("image/webp", quality);
++ if (url.startsWith("data:image/webp") && url.length < 15e4) return url;
+ }
+- return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim();
++ const png = canvas.toDataURL("image/png");
++ if (png.length < 15e4) return png;
++ throw new Error(t("That image is too large"));
+ }
+-function ExchangeLine({ entry, roster, actions }) {
+- const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" };
+- return (0, import_react34.createElement)(
++function InlineText({ label, value, placeholder, required = false, maxLength, multiline = false, onSave }) {
++ const [draft, setDraft] = (0, import_react30.useState)(null);
++ const reverting = (0, import_react30.useRef)(false);
++ const commit = () => {
++ const next = draft?.trim();
++ setDraft(null);
++ if (reverting.current) {
++ reverting.current = false;
++ return;
++ }
++ if (next === void 0 || next === (value ?? "") || required && next === "") return;
++ void onSave(next);
++ };
++ return (0, import_react30.createElement)(multiline ? "textarea" : "input", {
++ className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input",
++ value: draft ?? value ?? "",
++ placeholder,
++ maxLength,
++ required,
++ "aria-label": label,
++ spellCheck: false,
++ onChange: (event) => setDraft(event.target.value),
++ onBlur: commit,
++ onKeyDown: (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ reverting.current = true;
++ event.currentTarget.blur();
++ }
++ if (event.key === "Enter" && !multiline && !event.nativeEvent.isComposing) event.currentTarget.blur();
++ }
++ });
++}
++function Row({ label, hint, stack = false, children }) {
++ return (0, import_react30.createElement)(
++ "label",
++ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null),
++ children
++ );
++}
++function Card({ title, children }) {
++ return (0, import_react30.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react30.createElement)("h3", null, title) : null, (0, import_react30.createElement)("div", { className: "bt-set-card" }, children));
++}
++function Switch({ on, label, disabled, onChange }) {
++ return (0, import_react30.createElement)(
++ "button",
++ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) },
++ (0, import_react30.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true })
++ );
++}
++function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) {
++ return (0, import_react30.createElement)(
+ "div",
+- { className: "bt-group-msg" },
+- (0, import_react34.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
+- (0, import_react34.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react34.createElement)("span", { className: "bt-lead" }, (0, import_react34.createElement)(BotMark, { bot, size: 22 })) })
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react30.createElement)("span", { className: "bt-set-hint" }, hint) : null),
++ (0, import_react30.createElement)(Switch, { on, label, disabled, onChange })
+ );
+ }
+-function ExchangeDialog({ pair, roster, actions }) {
+- const [entries2, setEntries] = (0, import_react34.useState)(null);
+- const [leaving, setLeaving] = (0, import_react34.useState)(false);
+- const close = () => {
+- if (leaving) return;
+- setLeaving(true);
+- setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS);
++function modelHint(bot, roster) {
++ const shown = modelOf(bot, roster);
++ if (shown === void 0) return t("Pick the model this Bot runs on.");
++ if (!shown.served && (roster.models ?? []).length > 0) return t("This model is not available now. Pick another one.");
++ if (bot.appliedModel && bot.appliedModel === bot.model) return t("Every message of this Bot runs on it.");
++ return t("Applies from the next message.");
++}
++function ShapeRing({ shape, size }) {
++ const maskId = (0, import_react30.useRef)("");
++ if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`;
++ const def = shapeOf(shape);
++ const unit = def.size / size;
++ const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`;
++ return (0, import_react30.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } });
++}
++function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) {
++ const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))];
++ if (!shapes.includes(look.shape)) shapes.push(look.shape);
++ const rows = [];
++ for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS));
++ return (0, import_react30.createElement)(
++ import_react30.Fragment,
++ null,
++ (0, import_react30.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react30.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react30.createElement)("button", {
++ key: id,
++ type: "button",
++ className: "bt-shape",
++ "aria-label": t(shapeOf(id).label),
++ title: t(shapeOf(id).label),
++ "aria-pressed": !hasPhoto && look.shape === id,
++ onClick: () => onShape(id)
++ }, (0, import_react30.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react30.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))),
++ (0, import_react30.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react30.createElement)("button", {
++ key: color,
++ type: "button",
++ className: "bt-color",
++ "aria-label": t(capitalize(color)),
++ title: t(capitalize(color)),
++ "aria-pressed": look.color === color,
++ onClick: () => onColor(color)
++ }, (0, import_react30.createElement)("span", { style: { background: paintCss(color) } }))))
++ );
++}
++function AvatarEditor({ bot, actions, run, onClose }) {
++ const look = lookOf(bot);
++ const [tab, setTab] = (0, import_react30.useState)(look.image ? "upload" : "bot");
++ const [over, setOver] = (0, import_react30.useState)(false);
++ const ref = (0, import_react30.useRef)(null);
++ const fileRef = (0, import_react30.useRef)(null);
++ const upload = (file) => {
++ if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } })));
+ };
+- useEscape(close);
+- (0, import_react34.useEffect)(() => {
+- let stop = false;
+- let timer;
+- let seen = null;
+- const load = async () => {
+- const result = await actions.exchange(pair[0], pair[1]);
+- if (stop) return;
+- const key = JSON.stringify(result ?? []);
+- if (key !== seen) {
+- seen = key;
+- setEntries(result ?? []);
++ (0, import_react30.useEffect)(() => {
++ const onDown = (event) => {
++ if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return;
++ onClose();
++ };
++ const onKey = (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ onClose();
+ }
+- timer = setTimeout(load, document.hidden ? 8e3 : 2e3);
+ };
+- void load();
++ window.addEventListener("mousedown", onDown);
++ window.addEventListener("keydown", onKey, true);
+ return () => {
+- stop = true;
+- clearTimeout(timer);
++ window.removeEventListener("mousedown", onDown);
++ window.removeEventListener("keydown", onKey, true);
+ };
+- }, [pair[0], pair[1]]);
+- const left = roster.byId[pair[0]];
+- const right = roster.byId[pair[1]];
+- let lastDay = 0;
+- const paneLeft = usePaneLeft();
+- return (0, import_react34.createElement)(
++ }, [onClose]);
++ (0, import_react30.useEffect)(() => {
++ if (tab !== "upload") return void 0;
++ const onPaste = (event) => {
++ const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/"));
++ if (file) {
++ event.preventDefault();
++ upload(file);
++ }
++ };
++ window.addEventListener("paste", onPaste);
++ return () => window.removeEventListener("paste", onPaste);
++ }, [tab, bot]);
++ const hasPhoto = Boolean(look.image);
++ const glide = useGlide(tab);
++ return (0, import_react30.createElement)(
+ "div",
+- { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 },
+- (0, import_react34.createElement)(
++ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") },
++ (0, import_react30.createElement)(
+ "div",
+- { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") },
+- (0, import_react34.createElement)("div", { className: "bt-head" }, (0, import_react34.createElement)(
+- "span",
+- { className: "bt-pill", style: { cursor: "default" } },
+- left ? (0, import_react34.createElement)(BotMark, { bot: left, size: 22 }) : null,
+- left?.name ?? "?",
+- (0, import_react34.createElement)("span", { className: "bt-swap" }, "⇄"),
+- right ? (0, import_react34.createElement)(BotMark, { bot: right, size: 22 }) : null,
+- right?.name ?? "?"
+- )),
+- (0, import_react34.createElement)(
++ { className: "bt-avedit-top" },
++ (0, import_react30.createElement)(
+ "div",
+- { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") },
+- entries2 === null ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
+- const showDay = entry.time - lastDay > 30 * 60 * 1e3;
+- lastDay = entry.time;
+- return (0, import_react34.createElement)(
+- import_react34.Fragment,
+- { key: entry.id },
+- showDay ? (0, import_react34.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
+- (0, import_react34.createElement)(ExchangeLine, { entry, roster, actions })
+- );
+- })
++ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" },
++ (0, import_react30.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react30.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label)))
+ ),
+- (0, import_react34.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react34.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
+- )
++ (0, import_react30.createElement)("button", {
++ type: "button",
++ className: "bt-avedit-tab bt-avedit-reset",
++ title: t("Reset character to default"),
++ onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null }))
++ }, t("Reset"))
++ ),
++ tab === "bot" ? (0, import_react30.createElement)(CharacterGrid, {
++ look,
++ hasPhoto,
++ onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })),
++ onColor: (color) => run(actions.updateBot(bot.id, { color }))
++ }) : null,
++ tab === "upload" ? (0, import_react30.createElement)(
++ "div",
++ {
++ className: "bt-dropzone",
++ "data-over": over || void 0,
++ onDragOver: (event) => {
++ event.preventDefault();
++ setOver(true);
++ },
++ onDragLeave: () => setOver(false),
++ onDrop: (event) => {
++ event.preventDefault();
++ setOver(false);
++ upload(event.dataTransfer.files?.[0]);
++ }
++ },
++ hasPhoto ? (0, import_react30.createElement)("img", { src: look.image, alt: "" }) : null,
++ (0, import_react30.createElement)("div", null, t("Drag, drop, or paste an image")),
++ (0, import_react30.createElement)(
++ "div",
++ { className: "bt-actions", style: { justifyContent: "center" } },
++ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")),
++ hasPhoto ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null
++ ),
++ (0, import_react30.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
++ const file = event.target.files?.[0];
++ event.target.value = "";
++ upload(file);
++ } })
++ ) : null
+ );
+ }
+-
+-// src/client/voice.js
+-var import_react35 = require("react");
+-var VOICE_LABELS = {
+- listening: "Listening",
+- thinking: "Thinking",
+- speaking: "Speaking",
+- paused: "Paused",
+- setup: "Voice input needs its speech model",
+- unsupported: "Voice chat is not available in this browser"
+-};
+-var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" };
+-function useMicLevel(enabled) {
+- const [level, setLevel] = (0, import_react35.useState)(0);
+- (0, import_react35.useEffect)(() => {
+- if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0;
+- let stream = null;
+- let audio = null;
+- let frame = 0;
+- let stopped = false;
+- void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => {
+- if (stopped) {
+- media.getTracks().forEach((track) => track.stop());
+- return;
+- }
+- stream = media;
+- audio = new AudioContext();
+- const analyser = audio.createAnalyser();
+- analyser.fftSize = 256;
+- audio.createMediaStreamSource(media).connect(analyser);
+- const data = new Uint8Array(analyser.frequencyBinCount);
+- let smooth = 0;
+- const tick = () => {
+- analyser.getByteTimeDomainData(data);
+- let sum = 0;
+- for (const value of data) sum += ((value - 128) / 128) ** 2;
+- smooth = smooth * 0.8 + Math.min(1, Math.sqrt(sum / data.length) * 4) * 0.2;
+- setLevel(Math.round(smooth * 100) / 100);
+- frame = requestAnimationFrame(tick);
+- };
+- tick();
+- }).catch(() => {
+- });
+- return () => {
+- stopped = true;
+- cancelAnimationFrame(frame);
+- stream?.getTracks().forEach((track) => track.stop());
+- void audio?.close().catch(() => {
+- });
+- setLevel(0);
+- };
+- }, [enabled]);
+- return level;
++function MemoryCard({ bot, actions }) {
++ const count = useMemoryCount(bot.id, actions);
++ const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count });
++ return (0, import_react30.createElement)(
++ Card,
++ { title: t("Memory") },
++ (0, import_react30.createElement)(
++ "button",
++ { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, label)),
++ (0, import_react30.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react30.createElement)(ChevronRightIcon))
++ )
++ );
+ }
+-function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }) {
+- const bot = roster.byId[sessionId];
+- const room2 = roster.roomsById[sessionId];
+- const name = bot?.name ?? room2?.name ?? t("Bot");
+- const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
+- const lastReply = useSessions((list) => {
+- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
+- const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0;
+- return entry ? `${outline.length}|${entry.response ?? ""}` : "0|";
+- });
+- const [speech] = (0, import_react35.useState)(() => actions.speech());
+- const [phase, setPhase] = (0, import_react35.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
+- const [catalog, setCatalog] = (0, import_react35.useState)(null);
+- const [heard, setHeard] = (0, import_react35.useState)("");
+- const [said, setSaid] = (0, import_react35.useState)("");
+- const [note, setNote] = (0, import_react35.useState)("");
+- const [serviceLevel, setServiceLevel] = (0, import_react35.useState)(0);
+- const [cycle, setCycle] = (0, import_react35.useState)(0);
+- const waiting = (0, import_react35.useRef)(null);
+- const reply = (0, import_react35.useRef)(lastReply);
+- reply.current = lastReply;
+- const turn = (0, import_react35.useRef)(0);
+- const paneLeft = usePaneLeft();
+- const close = () => {
+- turn.current += 1;
+- window.speechSynthesis?.cancel();
+- actions.closeVoice();
+- };
+- useEscape(close);
+- const submit = (text2) => {
+- setHeard(text2);
+- setPhase("thinking");
+- waiting.current = reply.current.split("|")[0];
+- void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening"));
+- };
+- const dictation = useDictation((text2, final) => {
+- setHeard(text2);
+- if (!final) return;
+- dictation.stop();
+- submit(text2);
+- });
+- (0, import_react35.useEffect)(() => {
+- if (!note) return void 0;
+- const timer = setTimeout(() => setNote(""), 2600);
+- return () => clearTimeout(timer);
+- }, [note]);
+- (0, import_react35.useEffect)(() => {
+- if (!speech) return void 0;
+- let live = true;
+- speechCatalog(speech).then(({ catalog: next, usable }) => {
+- if (!live) return;
+- setCatalog(next);
+- setPhase(usable ? "listening" : "setup");
+- }, () => {
+- if (live) setPhase("setup");
+- });
+- return () => {
+- live = false;
+- };
+- }, [speech]);
+- const listen = phase === "listening";
+- (0, import_react35.useEffect)(() => {
+- if (speech || !listen || dictation.listening) return void 0;
+- const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => {
+- } }), 250);
++function BotSettingsForm({ bot, roster, actions, run }) {
++ const [editing, setEditing] = (0, import_react30.useState)(false);
++ const [confirming, setConfirming] = (0, import_react30.useState)(false);
++ const closeEditor = (0, import_react30.useCallback)(() => setEditing(false), []);
++ (0, import_react30.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(false), 4e3);
+ return () => clearTimeout(timer);
+- }, [listen, dictation.listening]);
+- (0, import_react35.useEffect)(() => {
+- if (phase !== "listening") dictation.stop();
+- }, [phase]);
+- (0, import_react35.useEffect)(() => {
+- if (!speech || !catalog || !listen) return void 0;
+- const own = turn.current;
+- let capture = null;
+- let frame = 0;
+- let stopped = false;
+- let smooth = 0;
+- const detect = turnDetector();
+- const limit = Math.max(1, catalog.maxDurationSeconds - 1) * 1e3;
+- const finish = async (current) => {
+- setPhase("transcribing");
+- try {
+- const wave = await current.finish(catalog.maxDurationSeconds);
+- const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : "";
+- if (turn.current !== own) return;
+- if (text2) submit(text2);
+- else setPhase("listening");
+- } catch (error) {
+- if (turn.current !== own) return;
+- setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
+- setPhase("listening");
+- }
+- };
+- openCapture().then((opened) => {
+- if (stopped) {
+- opened.cancel();
+- return;
+- }
+- capture = opened;
+- const began = performance.now();
+- const tick = () => {
+- const now = performance.now();
+- const level2 = capture.level();
+- smooth = smooth * 0.8 + level2 * 0.2;
+- setServiceLevel(Math.round(smooth * 100) / 100);
+- const state = detect(level2, now);
+- if (state === "done" || state === "speech" && now - began >= limit) {
+- const current = capture;
+- capture = null;
+- setServiceLevel(0);
+- void finish(current);
+- return;
+- }
+- if (state === "waiting" && now - began >= limit) {
+- setCycle((value) => value + 1);
+- return;
+- }
+- frame = requestAnimationFrame(tick);
+- };
+- tick();
+- }, (error) => {
+- if (stopped) return;
+- setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
+- setPhase("paused");
+- });
+- return () => {
+- stopped = true;
+- cancelAnimationFrame(frame);
+- capture?.cancel();
+- setServiceLevel(0);
+- };
+- }, [speech, catalog, listen, cycle]);
+- (0, import_react35.useEffect)(() => {
+- if (phase !== "thinking" || running || waiting.current === null) return;
+- const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
+- if (count === waiting.current || !text2.trim()) return;
+- waiting.current = null;
+- const spoken = speechOf(text2, botNames(roster));
+- setSaid(spoken);
+- setHeard("");
+- if (!window.speechSynthesis || !spoken) {
+- setPhase("listening");
+- return;
+- }
+- const utterance = new SpeechSynthesisUtterance(spoken);
+- utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
+- utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
+- utterance.onerror = utterance.onend;
+- setPhase("speaking");
+- window.speechSynthesis.cancel();
+- window.speechSynthesis.speak(utterance);
+- }, [phase, running, lastReply]);
+- (0, import_react35.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
+- const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
+- const level = speech ? serviceLevel : micLevel;
+- const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
+- const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
+- const togglePause = () => {
+- if (phase === "paused") {
+- setPhase("listening");
+- return;
+- }
+- turn.current += 1;
+- window.speechSynthesis?.cancel();
+- setPhase("paused");
++ }, [confirming]);
++ if (bot.remote) return null;
++ const isMainBot = isMainOf(roster, bot.id);
++ const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1;
++ const isTile = roster.mainBotId === bot.id;
++ const save = (patch) => run(actions.updateBot(bot.id, patch));
++ const duplicate = () => run(actions.duplicateBot(bot.id).then((copy2) => actions.openSession(copy2.id)));
++ const remove = () => {
++ if (confirming) void run(actions.deleteBot(bot.id));
++ else setConfirming(true);
+ };
+- const openSetup = phase === "setup" ? actions.voiceSetup() : null;
+- const caption = phase === "speaking" ? said : heard;
+- const shownNote = note || dictation.note;
+- return (0, import_react35.createElement)(
++ (0, import_react30.useEffect)(() => {
++ void actions.refreshModels?.();
++ }, [bot.id]);
++ return (0, import_react30.createElement)(
+ "div",
+- { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
+- (0, import_react35.createElement)(
++ { className: "bt-bs" },
++ (0, import_react30.createElement)(
+ "div",
+- { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
+- (0, import_react35.createElement)(
++ { className: "bt-bs-hero" },
++ (0, import_react30.createElement)(
+ "div",
+- { className: "bt-voice-orb", style: { "--bt-level": level } },
+- (0, import_react35.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
+- (0, import_react35.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
+- bot ? (0, import_react35.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
++ { className: "bt-avatar-anchor" },
++ (0, import_react30.createElement)(
++ "button",
++ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) },
++ (0, import_react30.createElement)(BotMark, { bot, size: 64, live: true }),
++ (0, import_react30.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react30.createElement)(PencilIcon))
++ ),
++ editing ? (0, import_react30.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null
+ ),
+- (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name),
+- (0, import_react35.createElement)(
++ (0, import_react30.createElement)(
+ "div",
+- { className: "bt-voice-status", role: "status" },
+- phase === "thinking" ? (0, import_react35.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react35.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
++ { className: "bt-bs-who" },
++ (0, import_react30.createElement)("span", { className: "bt-bs-name" }, bot.name),
++ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot"))
++ )
++ ),
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("Profile") },
++ (0, import_react30.createElement)(Row, { label: t("Name") }, (0, import_react30.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })),
++ (0, import_react30.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react30.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) }))
++ ),
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("Model") },
++ (0, import_react30.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))),
++ (0, import_react30.createElement)(ModelMenu, { bot, roster, actions, run })
+ ),
+- (0, import_react35.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
+- openSetup ? (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
+- close();
+- openSetup();
+- } }, t("Set up voice input")) : null,
+- shownNote ? (0, import_react35.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
++ bot.model ? (0, import_react30.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null
+ ),
+- (0, import_react35.createElement)(
+- "div",
+- { className: "bt-voice-bar" },
+- (0, import_react35.createElement)("button", {
+- type: "button",
+- className: "bt-voice-btn",
+- "aria-pressed": phase === "paused",
+- disabled: blocked,
+- "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
+- onClick: togglePause
+- }, (0, import_react35.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react35.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
+- (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react35.createElement)(CloseIcon))
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("Working folder") },
++ (0, import_react30.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
++ (0, import_react30.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) })
++ )
++ ),
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("Instructions") },
++ (0, import_react30.createElement)(
++ Row,
++ { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true },
++ (0, import_react30.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) })
++ )
++ ),
++ // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406).
++ (0, import_react30.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react30.createElement)(Card, { title: t("Sharing") }, children) }),
++ (0, import_react30.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }),
++ (0, import_react30.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }),
++ roster.memory === false ? null : (0, import_react30.createElement)(MemoryCard, { bot, actions }),
++ (0, import_react30.createElement)(BotSchedules, { bot, roster, actions }),
++ (0, import_react30.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }),
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("Main Bot") },
++ onlyMain ? (0, import_react30.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react30.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page."))
++ ),
++ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change"))
++ ) : (0, import_react30.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) })
++ ),
++ // The Main Bot tile is always first and always shown, like in the context menu.
++ isTile ? null : (0, import_react30.createElement)(
++ Card,
++ { title: t("Sidebar") },
++ (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }),
++ isMainBot ? null : (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) })
++ ),
++ (0, import_react30.createElement)(
++ Card,
++ { title: t("More") },
++ (0, import_react30.createElement)(
++ "button",
++ { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate },
++ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))),
++ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
++ ),
++ isMainBot ? null : (0, import_react30.createElement)(
++ "button",
++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
++ (0, import_react30.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")),
++ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team."))
++ ),
++ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon))
++ )
+ )
+ );
+ }
++var BOT_SETTINGS_CSS = `
++.bt-bs{display:flex;flex-direction:column;gap:18px}
++.bt-bs-hero{display:flex;align-items:center;gap:14px;padding:2px 4px}
++.bt-bs-who{display:flex;flex-direction:column;min-width:0;gap:2px}
++.bt-bs-name{font-size:17px;line-height:24px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-bs-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
++.bt-bs .bt-avedit{left:0;transform:none}
++.bt-bs-row{min-height:48px}
++label.bt-bs-row{cursor:text}
++.bt-bs-label{font-size:13px;line-height:18px;font-weight:500}
++/* #1347: a row keeps its label and its control side by side. The *label* is one line — "Name" used to
++ break into "Nam" + "e" inside a 36px column — and a control never breaks its own words: "Test
++ connection" came out as "Test connecti" / "on" and Main Bot's "Change" as "Ch/an/ge". The hint under
++ a label is prose, so it wraps and is never cut: the row grows instead. */
++.bt-bs-row>.bt-set-copy{flex:1 1 8ch;min-width:8ch}
++.bt-bs-row>.bt-set-copy>.bt-bs-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
++.bt-bs-row .bt-soft,.bt-bs-row .bt-mini{flex:none;white-space:nowrap}
++.bt-bs-row .bt-model-trigger{flex:none;max-width:58%}
++.bt-bs-row .bt-model-trigger :is(.bt-model-name,.bt-model-provider){white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
++${MODEL_TEST_CSS}
++.bt-bs-input{flex:0 1 220px;width:0;min-width:96px;height:30px;box-sizing:border-box;margin:-4px -6px -4px 0;padding:0 8px;border:1px solid transparent;border-radius:8px;background:none;color:var(--bt-ink);font:inherit;font-size:13px;text-align:right;outline:none;transition:background-color .12s ease,border-color .12s ease}
++.bt-bs-input:hover{background:var(--bt-hover)}
++.bt-bs-input:focus{background:var(--bt-main);border-color:var(--bt-line-2);text-align:left}
++.bt-bs-input::placeholder{color:var(--bt-ink-3)}
++.bt-bs-stack{flex-direction:column;align-items:stretch;gap:8px}
++.bt-bs-area{flex:none;width:auto;min-height:112px;height:auto;margin:0 -6px -4px;padding:8px;line-height:19px;text-align:left;resize:vertical;background:var(--bt-hover)}
++.bt-bs-area:hover{background:var(--bt-active)}
++.bt-bs-danger .bt-bs-label{color:#c21d2e}
++.bt-bs-danger[data-confirm]{background:color-mix(in srgb,#e02135 8%,transparent)}
++.bt-bs-danger[data-confirm]:hover{background:color-mix(in srgb,#e02135 12%,transparent)}
++.bt-switch{position:relative;flex:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--bt-line-2);cursor:pointer;transition:background-color .2s ease}
++.bt-switch[aria-checked=true]{background:var(--bt-accent)}
++.bt-switch:disabled{opacity:.45;cursor:default}
++.bt-switch:focus-visible{outline:2px solid var(--bt-accent);outline-offset:2px}
++.bt-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:999px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:${spring(SPRING_SHAPE, "transform")},${spring(SPRING_TAP, "width")}}
++.bt-switch[aria-checked=true] .bt-switch-knob{transform:translateX(16px)}
++.bt-switch:active:not(:disabled) .bt-switch-knob{width:21px}
++.bt-switch[aria-checked=true]:active:not(:disabled) .bt-switch-knob{transform:translateX(11px)}
++.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:bt-veil-in .18s ease-out}
++@media (prefers-reduced-motion:reduce){.bt-switch,.bt-switch-knob{transition:none}.bt-avedit :is(.bt-shapes,.bt-colors,.bt-dropzone){animation:none}}
++/* #1347: the panel's floor is 240px, where a label and a 96px field cannot share a line honestly. Below
++ 300px the row stacks instead: the copy keeps its whole width on top, the control sits under it and
++ keeps its own words. Nothing is clipped to fit a width that is not there. */
++@container bt-panel (max-width:300px){
++ .bt-bs-row{flex-wrap:wrap;row-gap:6px}
++ .bt-bs-row>.bt-set-copy{flex:1 1 100%;min-width:0}
++ .bt-bs-input{flex:1 1 100%;width:auto;min-width:0;margin:0;text-align:left}
++}
++`;
+
+-// src/client/workflows.js
+-var import_react36 = require("react");
+-var READ_MS = 8e3;
+-var POLL_MS6 = 2e3;
+-var WORKFLOW_PLUGIN_HINT = "The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.";
+-var COLORS = { Planner: "#8b5cf6", Writer: "#f0509a", Coder: "#1fbf6a", Reviewer: "#f5a524", Tester: "#14b8a6", Deployer: "#2f7cf6" };
+-function workflowCallMissing(failure) {
+- const code = failure && typeof failure === "object" ? String(failure.code ?? "") : "";
+- const message = typeof failure === "string" ? failure : String(failure?.message ?? "");
+- return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message);
++// src/client/group-settings.js
++var import_react31 = require("react");
++var ROOM_MODES = [
++ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."],
++ ["everyone", "Everyone", "Every member answers in turn, admin first."]
++];
++var shownMode = (room2, roster) => roster.byId[room2.admin] ? room2.mode : "everyone";
++function faceSeatBot(seat, roster) {
++ if (!seat) return void 0;
++ const record = seat.record && typeof seat.record === "object" ? seat.record : {};
++ const extra = {};
++ if (typeof record.color === "string" && record.color) extra.color = record.color;
++ if (record.avatar && typeof record.avatar === "object") extra.avatar = record.avatar;
++ if (seat.local && !seat.local.remote) return Object.keys(extra).length ? { ...seat.local, ...extra } : seat.local;
++ const handle = typeof record.handle === "string" && record.handle.trim() ? record.handle : typeof seat.key === "string" && seat.key.startsWith("@") ? seat.key : "";
++ return lookBotForRemote({
++ handle,
++ displayName: record.displayName || seat.bot,
++ name: seat.bot,
++ label: record.label,
++ id: record.id || seat.key,
++ ...extra
++ }, roster);
+ }
+-function workflowCallError(failure, fallback = "Could not read workflows") {
+- if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT;
+- if (typeof failure === "string" && failure.trim()) return failure.trim();
+- const message = typeof failure?.message === "string" ? failure.message.trim() : "";
+- return message || fallback;
++function faceMemberLook(bot) {
++ if (!bot) return void 0;
++ return bot.remote ? lookAvoidingLocals(bot) : lookOf(bot);
+ }
+-function settleWorkflowRead(value, fallback = "Could not read workflows") {
+- if (!value || value.ok === false || !Array.isArray(value.workflows)) {
+- const raw = typeof value?.error === "string" && value.error.trim() ? value.error : "";
+- const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : raw || fallback;
+- return { phase: "error", error, pack: null };
++function faceMarks(person, roster, size) {
++ const marks = [];
++ for (const seat of person?.seats ?? []) {
++ const bot = faceSeatBot(seat, roster);
++ if (!bot) continue;
++ marks.push((0, import_react31.createElement)(BotMark, { key: seat.key, bot, size, live: false }));
+ }
+- const templates = Array.isArray(value.templates) ? value.templates : [];
+- const workflows = value.workflows;
+- const pack = { ...value, templates, workflows };
+- if (templates.length === 0 && workflows.length === 0) return { phase: "empty", error: "", pack };
+- return { phase: "ready", error: "", pack };
++ if (marks.length === 0) return [(0, import_react31.createElement)(PersonMark, { key: "person", size, name: person?.name })];
++ return marks;
+ }
+-function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
+- return new Promise((resolve) => {
+- let settled = false;
+- const finish = (value) => {
+- if (settled) return;
+- settled = true;
+- clearTimeout(timer);
+- resolve(value);
+- };
+- const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
+- Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
+- });
+-}
+-function moveNode(nodes, id, x, y) {
+- return nodes.map((node) => node.id === id ? { ...node, x, y } : node);
+-}
+-function connectNodes(edges, from, to) {
+- if (!from || !to || from === to) return edges;
+- if (edges.some((edge) => edge.from === from && edge.to === to)) return edges;
+- return edges.concat([{ from, to }]);
++function faceRoomCount(room2, roster) {
++ const people2 = facePeople(room2, roster).length;
++ const seats = faceSeats(room2, roster).length;
++ const persons = people2 === 1 ? t("1 person") : t("{count} people", { count: people2 });
++ const bots = seats === 1 ? t("1 Bot") : t("{count} Bots", { count: seats });
++ return `${persons} · ${bots}`;
+ }
+-function dropBot(nodes, bot, x, y) {
+- const ids = new Set(nodes.map((node) => node.id));
+- let n = nodes.length + 1;
+- while (ids.has(`n${n}`)) n += 1;
+- return nodes.concat([{ id: `n${n}`, name: bot.name, botId: bot.id, role: bot.role ?? "", x, y }]);
++var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLowerCase();
++var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead));
++function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) {
++ const bots = person.seats.flatMap((seat, index) => {
++ const name = seat.local && onOpenBot ? (0, import_react31.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react31.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot);
++ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react31.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name];
++ return index === 0 ? marked : [(0, import_react31.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked];
++ });
++ return (0, import_react31.createElement)(
++ "div",
++ { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-gs-who bt-face-person" },
++ (0, import_react31.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)),
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-face-person-text" },
++ (0, import_react31.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)),
++ (0, import_react31.createElement)("span", { className: "bt-face-bots" }, bots)
++ )
++ )
++ );
+ }
+-function removeNode(nodes, edges, id) {
+- return {
+- nodes: nodes.filter((node) => node.id !== id),
+- edges: edges.filter((edge) => edge.from !== id && edge.to !== id)
++function FaceLead({ room: room2, roster, pick: pick2 }) {
++ const lead = room2.face?.lead;
++ const [open, setOpen] = (0, import_react31.useState)(false);
++ const [busy, setBusy] = (0, import_react31.useState)(false);
++ const [error, setError] = (0, import_react31.useState)("");
++ const seats = faceSeats(room2, roster);
++ const current = seats.find((seat) => isLeadSeat(seat, lead?.handle));
++ if (!current) return null;
++ const canPick = lead.canPick === true && typeof pick2 === "function";
++ const choose = async (handle) => {
++ setBusy(true);
++ setError("");
++ try {
++ await pick2(handle);
++ setOpen(false);
++ } catch (err) {
++ setError(err?.message || t("Could not change the room lead."));
++ } finally {
++ setBusy(false);
++ }
+ };
++ return (0, import_react31.createElement)(
++ "div",
++ { className: "bt-face-lead", "data-flybot": "face-lead" },
++ (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Room lead")),
++ (0, import_react31.createElement)(
++ "div",
++ { className: "bt-set-row" },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react31.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)),
++ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined."))
++ ),
++ canPick ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null
++ ),
++ open && canPick ? seats.map((seat) => (0, import_react31.createElement)(
++ "div",
++ { key: seat.key, className: "bt-set-row" },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)),
++ seat === current ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead"))
++ )) : null,
++ open && canPick && lead.picked ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null,
++ canPick || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")),
++ error ? (0, import_react31.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null
++ );
+ }
+-function chainOf(item) {
+- if (typeof item?.chain === "string" && item.chain) return item.chain;
+- if (!Array.isArray(item?.layers) || !Array.isArray(item?.nodes)) return "";
+- const name = (id) => item.nodes.find((node) => node.id === id)?.name ?? id;
+- return item.layers.map((layer) => layer.map(name).join(" ‖ ")).join(" → ");
+-}
+-function openedGraph(pack, opened) {
+- if (!pack || !opened) return null;
+- if (opened.source === "own") return (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
+- return (pack.templates ?? []).find((item) => item.id === opened.id) ?? (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
+-}
+-function statusLabel2(status) {
+- if (status === "running") return t("Running");
+- if (status === "done") return t("Done");
+- if (status === "failed") return t("Failed");
+- if (status === "idle") return t("Idle");
+- if (status === "skipped") return t("Skipped");
+- return t("Queued");
+-}
+-function hitNode(event) {
+- const direct = event.target?.dataset?.node ? event.target : null;
+- const nested = event.target?.closest?.("[data-node]");
+- return (nested ?? direct)?.dataset?.node || "";
+-}
+-var WIRE_SPAN = 480;
+-var NODE_WIRE_OUT = 56;
+-var CARD_HALF = 84;
+-var CARD_H = 72;
+-var FRAME_SIDE = 108;
+-var FRAME_LABEL = 18;
+-var FRAME_FOOT = 10;
+-var MIN_FIT = 0.5;
+-function wirePath(from, to) {
+- const x1 = from.x + WIRE_SPAN;
+- const y1 = from.y + NODE_WIRE_OUT;
+- const x2 = to.x + WIRE_SPAN;
+- const y2 = to.y;
+- const mid = (y1 + y2) / 2;
+- return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`;
++function seatedCount(room2) {
++ const live = room2?.presentedParticipants ?? room2?.participants ?? [];
++ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0).length;
++ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0).length;
++ return humans + remotes + (room2?.members?.length ?? 0);
+ }
+-function fitScale(nodes, width) {
+- const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
+- if (!(width > 0) || reach === 0) return 1;
+- return Math.min(1, Math.max(MIN_FIT, width / 2 / reach));
++function roomSubtitle(room2, roster) {
++ if (room2.origin === "face-to-face") return faceRoomCount(room2, roster);
++ const admin = roster.byId[room2.admin];
++ const count = seatedCount(room2);
++ return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · ");
+ }
+-function fitLayout(nodes, width) {
+- const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
+- const scale = fitScale(nodes, width);
+- const spare = width > 0 && reach > 0 ? reach * scale - width / 2 : 0;
+- const pad2 = spare > 1e-6 ? Math.ceil(spare) : 0;
+- const centre = width > 0 ? pad2 + width / 2 : 0;
+- return {
+- reach,
+- scale,
+- pad: pad2,
+- left: centre - reach * scale,
+- right: centre + reach * scale,
+- scrollWidth: Math.max(width + pad2, centre + reach * scale)
++function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
++ const [adding, setAdding] = (0, import_react31.useState)(false);
++ const [confirming, setConfirming] = (0, import_react31.useState)(false);
++ (0, import_react31.useEffect)(() => {
++ if (!confirming) return void 0;
++ const timer = setTimeout(() => setConfirming(false), 4e3);
++ return () => clearTimeout(timer);
++ }, [confirming]);
++ const live = room2.presentedParticipants ?? room2.participants ?? [];
++ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
++ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
++ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
++ const face = room2.origin === "face-to-face";
++ const people2 = face ? facePeople(room2, roster) : [];
++ const shared2 = humans.length > 1;
++ const chiefs = new Set((roster.humans ?? []).map((person) => person.designatedChiefId).filter(Boolean));
++ const outside = roster.bots.filter((bot) => {
++ if (bot.remote) return false;
++ if (room2.members.includes(bot.id)) return false;
++ if (shared2) return chiefs.has(bot.id);
++ return true;
++ });
++ const outsidePeople = (roster.humans ?? []).filter((person) => !humans.some((p) => p.id === person.id));
++ const outsideRemotes = (roster.remoteBots ?? []).filter((bot) => !remotes.some((p) => p.handle === bot.handle));
++ const admin = roster.byId[room2.admin];
++ const mode = shownMode(room2, roster);
++ const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
++ const update = (patch) => run(actions.updateRoom(room2.id, patch));
++ const openBot = (id) => {
++ actions.closeSettings();
++ actions.openSession(id);
+ };
+-}
+-function measureCanvasWidth(el) {
+- const width = el?.clientWidth;
+- return Number.isFinite(width) && width > 0 ? width : 0;
+-}
+-function GraphCanvas({ graph, editing, roster, onCommit, width: knownWidth = 0 }) {
+- const gesture = (0, import_react36.useRef)(null);
+- const canvasRef = (0, import_react36.useRef)(null);
+- const [measuredWidth, setCanvasWidth] = (0, import_react36.useState)(0);
+- const nodes = graph.nodes ?? [];
+- const edges = graph.edges ?? [];
+- const status = graph.run?.nodeStatus ?? null;
+- const locals = (roster?.bots ?? []).filter((bot) => bot && bot.remote !== true && !nodes.some((node) => node.botId === bot.id));
+- const height = Math.max(420, ...nodes.map((node) => (node.y ?? 0) + 150), 0);
+- const canvasWidth = measuredWidth || knownWidth;
+- const { scale, pad: pad2 } = fitLayout(nodes, canvasWidth);
+- const drawnHeight = Math.round(height * scale);
+- (0, import_react36.useEffect)(() => {
+- const el = canvasRef.current;
+- const measure = () => {
+- const width = measureCanvasWidth(el);
+- if (width > 0 && Math.abs(width - canvasWidth) > 0.5) setCanvasWidth(width);
+- };
+- measure();
+- if (typeof ResizeObserver !== "function" || !el) return void 0;
+- const observer = new ResizeObserver(measure);
+- observer.observe(el);
+- return () => observer.disconnect();
+- }, [canvasWidth]);
+- const end = (event) => {
+- const current = gesture.current;
+- gesture.current = null;
+- if (!current || !editing) return;
+- if (current.kind === "move") {
+- const x = Math.round(current.x + (event.clientX - current.ox) / scale);
+- const y = Math.round(current.y + (event.clientY - current.oy) / scale);
+- onCommit?.({ nodes: moveNode(nodes, current.id, x, y), edges });
+- } else if (current.kind === "wire") {
+- const next = connectNodes(edges, current.from, hitNode(event));
+- if (next !== edges) onCommit?.({ nodes, edges: next });
+- } else if (current.kind === "add" && event.target?.dataset?.plane) {
+- const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX / scale) : 24;
+- const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY / scale) : 24;
+- onCommit?.({ nodes: dropBot(nodes, current.bot, x, y), edges });
++ const remove = () => {
++ if (!confirming) {
++ setConfirming(true);
++ return;
+ }
++ void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => {
++ if (done) onDeleted?.();
++ });
+ };
+- return (0, import_react36.createElement)(
++ return (0, import_react31.createElement)(
+ "div",
+- { className: "bt-wf-canvas", ref: canvasRef, onPointerUp: end },
+- editing ? (0, import_react36.createElement)(
++ { className: "bt-bs" },
++ (0, import_react31.createElement)(
+ "div",
+- { className: "bt-wf-palette" },
+- (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")),
+- locals.map((bot) => (0, import_react36.createElement)("button", {
+- key: bot.id,
+- type: "button",
+- "data-bot": bot.id,
+- onPointerDown: (event) => {
+- event.preventDefault?.();
+- gesture.current = { kind: "add", bot };
+- }
+- }, bot.name))
+- ) : null,
+- // A node's system prompt is the run Bot's prompt (plugins/workflow
+- // nodeConfig). The sidebar panel has this field, so the Settings editor
+- // writes the same graph instead of only drawing it (#1393).
+- editing ? (0, import_react36.createElement)("div", { className: "bt-wf-prompts" }, nodes.map((node) => (0, import_react36.createElement)(
+- "label",
+- { key: node.id, className: "bt-wf-prompt" },
+- (0, import_react36.createElement)("span", null, `${node.name} · ${t("System prompt")}`),
+- (0, import_react36.createElement)("textarea", {
+- "data-prompt": node.id,
+- rows: 3,
+- defaultValue: node.prompt || "",
+- onPointerDown: (event) => event.stopPropagation?.(),
+- onBlur: (event) => {
+- const prompt = event.target?.value ?? "";
+- if (prompt === (node.prompt || "")) return;
+- onCommit?.({ nodes: nodes.map((item) => item.id === node.id ? { ...item, prompt } : item), edges });
+- }
+- })
+- ))) : null,
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-stage", style: pad2 > 0 ? { height: drawnHeight, paddingLeft: pad2 } : { height: drawnHeight } },
+- (0, import_react36.createElement)(
++ { className: "bt-bs-hero" },
++ (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
++ (0, import_react31.createElement)(
+ "div",
+- { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } },
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } },
+- (0, import_react36.createElement)(
+- "svg",
+- {
+- className: "bt-wf-wires",
+- "aria-hidden": true,
+- width: WIRE_SPAN * 2,
+- height,
+- viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`
+- },
+- (0, import_react36.createElement)(
+- "defs",
+- null,
+- (0, import_react36.createElement)("marker", {
+- id: "bt-wf-arrow",
+- viewBox: "0 0 10 10",
+- refX: "8",
+- refY: "5",
+- markerWidth: "6",
+- markerHeight: "6",
+- orient: "auto"
+- }, (0, import_react36.createElement)("path", { d: "M0 0 10 5 0 10z" }))
+- ),
+- edges.map((edge) => {
+- const from = nodes.find((node) => node.id === edge.from);
+- const to = nodes.find((node) => node.id === edge.to);
+- if (!from || !to) return null;
+- return (0, import_react36.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" });
+- })
+- ),
+- (graph.layers ?? []).filter((layer) => layer.length > 1).map((layer) => {
+- const members = layer.map((id) => nodes.find((node) => node.id === id)).filter(Boolean);
+- if (members.length < 2) return null;
+- const left = Math.min(...members.map((node) => node.x ?? 0)) - FRAME_SIDE;
+- const right = Math.max(...members.map((node) => node.x ?? 0)) + FRAME_SIDE;
+- const top = Math.min(...members.map((node) => node.y ?? 0)) - FRAME_LABEL;
+- const bottom = Math.max(...members.map((node) => node.y ?? 0)) + CARD_H + FRAME_FOOT;
+- return (0, import_react36.createElement)("div", {
+- key: layer.join("+"),
+- className: "bt-wf-par",
+- style: { left, top, width: right - left, height: bottom - top }
+- }, (0, import_react36.createElement)("div", { className: "bt-wf-plabel" }, t("Parallel branch, then join")));
+- }),
+- nodes.map((node) => {
+- const state = status ? status[node.id] ?? "queued" : "idle";
+- return (0, import_react36.createElement)(
+- "div",
+- {
+- key: node.id,
+- "data-node": node.id,
+- className: `bt-wf-node ${state}${editing ? " editing" : ""}`,
+- style: { left: (node.x ?? 0) - CARD_HALF, top: node.y ?? 0 },
+- onPointerDown: (event) => {
+- if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return;
+- gesture.current = { kind: "move", id: node.id, ox: event.clientX, oy: event.clientY, x: node.x ?? 0, y: node.y ?? 0 };
+- }
+- },
+- editing ? (0, import_react36.createElement)("button", {
+- type: "button",
+- className: "bt-wf-port",
+- "data-port": "out",
+- "aria-label": node.name,
+- onPointerDown: (event) => {
+- event.preventDefault?.();
+- event.stopPropagation?.();
+- gesture.current = { kind: "wire", from: node.id };
+- }
+- }) : null,
+- editing ? (0, import_react36.createElement)("button", {
+- type: "button",
+- className: "bt-wf-remove",
+- "data-remove": node.id,
+- "aria-label": t("Remove"),
+- onClick: (event) => {
+- event.stopPropagation?.();
+- const next = removeNode(nodes, edges, node.id);
+- onCommit?.(next);
+- }
+- }, "×") : null,
+- (0, import_react36.createElement)("span", { className: `bt-wf-chip ${state}` }, statusLabel2(state)),
+- (0, import_react36.createElement)("span", { className: "bt-wf-av", style: { background: COLORS[node.name] ?? "#4d6bfe" } }, (node.name ?? "?").slice(0, 1)),
+- (0, import_react36.createElement)(
+- "span",
+- { className: "bt-wf-copy" },
+- (0, import_react36.createElement)("span", { className: "bt-wf-name" }, node.name),
+- (0, import_react36.createElement)("span", { className: "bt-wf-role" }, node.role ?? "")
+- )
+- );
+- })
+- )
++ { className: "bt-bs-who" },
++ (0, import_react31.createElement)("span", { className: "bt-bs-name" }, room2.name),
++ (0, import_react31.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster))
+ )
+- )
+- );
+-}
+-function Row2({ item, source, onOpen, onRename, onDelete }) {
+- return (0, import_react36.createElement)(
+- "button",
+- {
+- type: "button",
+- className: "bt-set-row",
+- "data-workflow": item.id,
+- "data-source": source,
+- onClick: () => onOpen?.(item)
+- },
+- (0, import_react36.createElement)(
+- "span",
+- { className: "bt-set-copy" },
+- (0, import_react36.createElement)("span", null, item.name),
+- (0, import_react36.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "")
+ ),
+- source === "own" ? (0, import_react36.createElement)(
+- "span",
+- { className: "bt-set-actions" },
+- (0, import_react36.createElement)("button", {
+- type: "button",
+- className: "bt-set-action",
+- "aria-label": t("Rename"),
+- title: t("Rename"),
+- onClick: (event) => {
+- event.stopPropagation();
+- onRename?.(item);
+- }
+- }, "✎"),
+- (0, import_react36.createElement)("button", {
+- type: "button",
+- className: "bt-set-action",
+- "aria-label": t("Delete"),
+- title: t("Delete"),
+- onClick: (event) => {
+- event.stopPropagation();
+- onDelete?.(item);
+- }
+- }, "×")
+- ) : null,
+- (0, import_react36.createElement)("span", { className: "bt-chevron", "aria-hidden": true }, "›")
+- );
+-}
+-function alertCopy(error) {
+- if (error === WORKFLOW_PLUGIN_HINT) return t("The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.");
+- return error || t("Could not read workflows");
+-}
+-function WorkflowsView({ phase = "loading", pack = null, error = "", opened = null, editing = false, roster = null, canvasWidth = 0, onOpen, onBack, onEditing, onRetry, onUse, onCommit, onRename, onDelete }) {
+- const graph = phase === "ready" ? openedGraph(pack, opened) : null;
+- const templates = pack?.templates ?? [];
+- const templateIds = new Set(templates.map((item) => item.id));
+- const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id));
+- const showUse = opened?.source === "factory" || opened?.fromFactory !== void 0;
+- return (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-page" },
+- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.")),
+- error ? (0, import_react36.createElement)(
+- "div",
+- { className: "bt-error", role: "alert" },
+- alertCopy(error),
+- phase === "error" ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onRetry?.() }, t("Retry")) : null
+- ) : null,
+- phase === "loading" ? (0, import_react36.createElement)("p", { className: "bt-wf-status", role: "status" }, t("Reading workflows…")) : null,
+- phase === "empty" ? (0, import_react36.createElement)("p", { className: "bt-wf-status" }, t("No workflows yet.")) : null,
+- phase === "ready" && !graph ? (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-lists" },
+- (0, import_react36.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react36.createElement)("h3", null, t("Factory presets")),
+- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("Ready-made pipelines. Use one to start your own.")),
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-set-card" },
+- templates.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No workflows yet."))) : templates.map((item) => {
+- const live = (pack.workflows ?? []).find((workflow) => workflow.id === item.id);
+- const row = live ? { ...item, name: live.name, preview: live.preview || chainOf(live) || chainOf(item) } : item;
+- return (0, import_react36.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") });
+- })
+- )
++ (0, import_react31.createElement)(
++ Card,
++ { title: t("Profile") },
++ (0, import_react31.createElement)(
++ Row,
++ { label: t("Name"), hint: t("Leave it empty to name the group after its members.") },
++ (0, import_react31.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) })
+ ),
+- (0, import_react36.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react36.createElement)("h3", null, t("Yours")),
+- (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")),
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-set-card" },
+- yours.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react36.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete }))
+- )
++ (0, import_react31.createElement)(
++ Row,
++ { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true },
++ (0, import_react31.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) })
+ )
+- ) : null,
+- graph ? (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-editor" },
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-wf-actions" },
+- (0, import_react36.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")),
+- showUse ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null,
+- opened?.source === "own" ? (0, import_react36.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null
+- ),
+- (0, import_react36.createElement)("h3", { className: "bt-wf-title" }, graph.name),
+- graph.description ? (0, import_react36.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null,
+- (graph.nodes ?? []).length === 0 ? (0, import_react36.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react36.createElement)(GraphCanvas, {
+- graph,
+- roster,
+- onCommit,
+- width: canvasWidth,
+- editing: opened?.source === "own" && editing === true
+- })
+- ) : null
+- );
+-}
+-function RenameModal({ workflow, onClose, onConfirm }) {
+- const [name, setName] = (0, import_react36.useState)(workflow?.name ?? "");
+- return (0, import_react36.createElement)(
+- "div",
+- { className: "bt-modal-mask", onClick: (event) => {
+- if (event.target === event.currentTarget) onClose?.();
+- } },
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") },
+- (0, import_react36.createElement)("h3", null, t("Rename workflow")),
+- (0, import_react36.createElement)(
+- "label",
+- { className: "bt-field" },
+- t("Name"),
+- (0, import_react36.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true })
+- ),
+- (0, import_react36.createElement)(
++ ),
++ (0, import_react31.createElement)(
++ Card,
++ { title: t("Who answers") },
++ (0, import_react31.createElement)(
+ "div",
+- { className: "bt-modal-actions" },
+- (0, import_react36.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
+- (0, import_react36.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename"))
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react31.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react31.createElement)("button", {
++ key: id,
++ type: "button",
++ "aria-pressed": mode === id,
++ disabled: id === "admin" && !admin,
++ title: id === "admin" && !admin ? t("Choose an admin first") : void 0,
++ onClick: () => {
++ if (mode !== id) void update({ mode: id });
++ }
++ }, t(label)))),
++ (0, import_react31.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`)
+ )
+- )
+- );
+-}
+-function DeleteModal({ workflow, onClose, onConfirm }) {
+- return (0, import_react36.createElement)(
+- "div",
+- { className: "bt-modal-mask", onClick: (event) => {
+- if (event.target === event.currentTarget) onClose?.();
+- } },
+- (0, import_react36.createElement)(
+- "div",
+- { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") },
+- (0, import_react36.createElement)("h3", null, t("Delete workflow")),
+- (0, import_react36.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })),
+- (0, import_react36.createElement)(
++ ),
++ (0, import_react31.createElement)(
++ Card,
++ { title: t("Working folder") },
++ (0, import_react31.createElement)(
+ "div",
+- { className: "bt-modal-actions" },
+- (0, import_react36.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
+- (0, import_react36.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete"))
++ { className: "bt-set-row bt-bs-row" },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Folder"))),
++ (0, import_react31.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) })
+ )
+- )
+- );
+-}
+-function WorkflowsPage({ call: call4, roster }) {
+- const [phase, setPhase] = (0, import_react36.useState)("loading");
+- const [pack, setPack] = (0, import_react36.useState)(null);
+- const [error, setError] = (0, import_react36.useState)("");
+- const [opened, setOpened] = (0, import_react36.useState)(null);
+- const [editing, setEditing] = (0, import_react36.useState)(false);
+- const [renaming, setRenaming] = (0, import_react36.useState)(null);
+- const [deleting, setDeleting] = (0, import_react36.useState)(null);
+- const invoke = typeof call4 === "function" ? call4 : () => Promise.reject(new Error("Could not read workflows"));
+- const apply2 = (settled) => {
+- setPhase(settled.phase);
+- setPack(settled.pack);
+- setError(settled.error);
+- };
+- (0, import_react36.useEffect)(() => {
+- let gone = false;
+- readWorkflows(invoke).then((settled) => {
+- if (!gone) apply2(settled);
+- });
+- return () => {
+- gone = true;
+- };
+- }, []);
+- const live = (pack?.workflows ?? []).some((item) => (item.activeCount ?? 0) > 0);
+- (0, import_react36.useEffect)(() => {
+- if (!live || editing) return void 0;
+- let gone = false;
+- const timer = setInterval(() => {
+- readWorkflows(invoke).then((settled) => {
+- if (!gone) apply2(settled);
+- });
+- }, POLL_MS6);
+- return () => {
+- gone = true;
+- clearInterval(timer);
+- };
+- }, [live, editing]);
+- const retry = () => {
+- setPhase("loading");
+- setError("");
+- setOpened(null);
+- readWorkflows(invoke).then(apply2);
+- };
+- const open = (item, source) => {
+- if (source === "factory") {
+- const live2 = (pack?.workflows ?? []).find((workflow) => workflow.id === item.id);
+- setOpened(live2 ? { source: "own", id: live2.id, fromFactory: item.id } : { source: "factory", id: item.id });
+- setEditing(false);
+- return;
+- }
+- setOpened({ source: "own", id: item.id });
+- setEditing(false);
+- };
+- const useTemplate = (id) => {
+- const template = typeof id === "string" ? id : "";
+- if (!template) return;
+- Promise.resolve(invoke("workflow/create", { template })).then((value) => {
+- const settled = settleWorkflowRead(value);
+- if (settled.phase === "error") {
+- setError(settled.error);
+- return;
+- }
+- const before = new Set((pack?.workflows ?? []).map((item) => item.id));
+- const made = settled.pack.workflows.find((item) => !before.has(item.id));
+- setPack(settled.pack);
+- setPhase(settled.phase);
+- setError("");
+- if (made) {
+- setOpened({ source: "own", id: made.id });
+- setEditing(true);
+- }
+- }).catch((failure) => {
+- setError(workflowCallError(failure, t("Could not copy this workflow")));
+- });
+- };
+- const rename = (item) => {
+- setRenaming(item);
+- };
+- const confirmRename = (workflowId, name) => {
+- if (!name.trim()) return;
+- Promise.resolve(invoke("workflow/rename", { workflowId, name: name.trim() })).then((value) => {
+- const settled = settleWorkflowRead(value);
+- if (settled.phase === "error") {
+- setError(settled.error);
+- return;
+- }
+- setPack(settled.pack);
+- setPhase("ready");
+- setError("");
+- setRenaming(null);
+- }).catch((failure) => {
+- setError(workflowCallError(failure, t("Could not rename this workflow")));
+- });
+- };
+- const deleteWorkflow = (item) => {
+- setDeleting(item);
+- };
+- const confirmDelete = (workflowId) => {
+- Promise.resolve(invoke("workflow/delete", { workflowId })).then((value) => {
+- const settled = settleWorkflowRead(value);
+- if (settled.phase === "error") {
+- setError(settled.error);
+- return;
+- }
+- setPack(settled.pack);
+- setPhase(settled.phase);
+- setError("");
+- setDeleting(null);
+- if (opened?.id === workflowId) setOpened(null);
+- }).catch((failure) => {
+- setError(workflowCallError(failure, t("Could not delete this workflow")));
+- });
++ ),
++ face ? (0, import_react31.createElement)(
++ Card,
++ { title: t("Members · {count}", { count: people2.length }) },
++ people2.map((person) => (0, import_react31.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle }))
++ ) : null,
++ face ? null : (0, import_react31.createElement)(
++ Card,
++ { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) },
++ humans.map((person) => (0, import_react31.createElement)(
++ "div",
++ { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-gs-who" },
++ (0, import_react31.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id),
++ (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Human")),
++ person.role === "admin" ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Admin")) : null
++ ),
++ humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null
++ )),
++ members.map((member) => {
++ const leads = room2.admin === member.id;
++ const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share";
++ const taskLead = room2.taskLead === member.id;
++ return (0, import_react31.createElement)(
++ "div",
++ { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" },
++ (0, import_react31.createElement)(
++ "button",
++ { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) },
++ (0, import_react31.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }),
++ (0, import_react31.createElement)("span", { className: "bt-row-title" }, member.name),
++ leads ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Admin")) : null,
++ taskLead ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null,
++ (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Chief")) : null
++ ),
++ leads ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")),
++ boardHere ? taskLead ? (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null,
++ members.length > 2 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null
++ );
++ }),
++ remotes.map((person) => (0, import_react31.createElement)(
++ "div",
++ { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-gs-who" },
++ (0, import_react31.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
++ (0, import_react31.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)),
++ // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is
++ // labelled as such; a Bot with no Bot Social identity can never be written to at all.
++ room2.origin === "face-to-face" ? null : (0, import_react31.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot"))
++ ),
++ // A guest's mirror is not theirs to edit: the host's membership is changed on the host side.
++ room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×")
++ )),
++ room2.origin === "face-to-face" ? null : adding ? [
++ ...outsidePeople.map((person) => (0, import_react31.createElement)(
++ "button",
++ { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
++ setAdding(false);
++ void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] });
++ } },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)),
++ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add person"))
++ )),
++ ...outside.map((bot) => (0, import_react31.createElement)(
++ "button",
++ { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
++ setAdding(false);
++ void update({ add: [bot.id] });
++ } },
++ (0, import_react31.createElement)(BotMark, { bot, size: 22 }),
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, bot.name)),
++ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add"))
++ )),
++ ...outsideRemotes.map((bot) => (0, import_react31.createElement)(
++ "button",
++ { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => {
++ setAdding(false);
++ void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] });
++ } },
++ (0, import_react31.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }),
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))),
++ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot"))
++ )),
++ (0, import_react31.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel"))
++ ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react31.createElement)(PlusIcon), t("Add member")) : null,
++ (0, import_react31.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react31.createElement)(
++ "span",
++ { className: "bt-set-hint" },
++ // The host's two-Bot floor guards membership changes; this hint warns before one.
++ members.length + humans.length + remotes.length < 2 ? t("Add a Bot first: a group chat needs at least two.") : `${admin ? t("{name} leads this group: it can call members with @Name, change the group, and post here.", { name: admin.name }) : t("No admin yet. An admin can call members with @Name, change the group, and post here.")}${room2.origin === "face-to-face" || room2.origin === "inbound-share" ? "" : ` ${t("The task lead divides the board. When unset, the admin does.")}`}`
++ )),
++ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. Last, after
++ // the note on who leads, so that note stays with the members it is about (#892).
++ room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react31.createElement)(FlyBotRoomMembers, { room: room2, roster, actions })
++ ),
++ (0, import_react31.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }),
++ (0, import_react31.createElement)(
++ Card,
++ { title: t("Sidebar") },
++ (0, import_react31.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }),
++ (0, import_react31.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) })
++ ),
++ (0, import_react31.createElement)(
++ Card,
++ { title: t("More") },
++ (0, import_react31.createElement)(
++ "button",
++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove },
++ (0, import_react31.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react31.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")),
++ (0, import_react31.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team."))
++ ),
++ (0, import_react31.createElement)("span", { className: "bt-chevron" }, (0, import_react31.createElement)(ChevronRightIcon))
++ )
++ )
++ );
++}
++function NewGroupForm({ roster, actions, run, onCreated, onCancel }) {
++ const [admin, setAdmin] = (0, import_react31.useState)(null);
++ const [members, setMembers] = (0, import_react31.useState)([]);
++ const [name, setName] = (0, import_react31.useState)("");
++ const [busy, setBusy] = (0, import_react31.useState)(false);
++ const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]);
++ const pickAdmin = (id) => {
++ setAdmin(id);
++ setMembers((list) => list.filter((item) => item !== id));
+ };
+- const commit = (graph) => {
+- if (!opened || opened.source !== "own") return;
+- Promise.resolve(invoke("workflow/graph", { workflowId: opened.id, nodes: graph.nodes, edges: graph.edges })).then((value) => {
+- const settled = settleWorkflowRead(value);
+- if (settled.phase === "error") {
+- setError(settled.error);
+- return;
+- }
+- setPack(settled.pack);
+- setPhase("ready");
+- setError("");
+- }).catch((failure) => {
+- setError(workflowCallError(failure, t("Could not save this workflow")));
+- });
++ const create = async () => {
++ if (busy || admin === null || members.length === 0) return;
++ setBusy(true);
++ let room2;
++ const done = await run(actions.createRoom([admin, ...members], { admin, ...name.trim() ? { name: name.trim() } : {} }).then((value) => {
++ room2 = value;
++ }));
++ if (done && room2) onCreated(room2);
++ else setBusy(false);
+ };
+- return (0, import_react36.createElement)(
++ const chip = (bot, on, onClick, role) => (0, import_react31.createElement)("button", {
++ key: bot.id,
++ type: "button",
++ role,
++ className: "bt-ng-chip",
++ "aria-checked": on,
++ onClick
++ }, (0, import_react31.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react31.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react31.createElement)(AdminStar) : null);
++ return (0, import_react31.createElement)(
+ "div",
+- null,
+- (0, import_react36.createElement)(WorkflowsView, {
+- phase,
+- pack,
+- error,
+- opened,
+- editing,
+- roster,
+- onOpen: open,
+- onBack: () => {
+- setOpened(null);
+- setEditing(false);
+- },
+- onEditing: setEditing,
+- onRetry: retry,
+- onUse: useTemplate,
+- onCommit: commit,
+- onRename: rename,
+- onDelete: deleteWorkflow
+- }),
+- renaming ? (0, import_react36.createElement)(RenameModal, {
+- workflow: renaming,
+- onClose: () => setRenaming(null),
+- onConfirm: (name) => confirmRename(renaming.id, name)
+- }) : null,
+- deleting ? (0, import_react36.createElement)(DeleteModal, {
+- workflow: deleting,
+- onClose: () => setDeleting(null),
+- onConfirm: () => confirmDelete(deleting.id)
+- }) : null
++ { className: "bt-set-card bt-ng" },
++ (0, import_react31.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row bt-bs-stack" },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react31.createElement)(AdminStar), t("Choose the group admin"))),
++ (0, import_react31.createElement)(
++ "div",
++ { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") },
++ roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio"))
++ )
++ ),
++ admin === null ? null : (0, import_react31.createElement)(
++ "div",
++ { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))),
++ (0, import_react31.createElement)(
++ "div",
++ { className: "bt-ng-picks", role: "group", "aria-label": t("Members") },
++ roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox"))
++ )
++ ),
++ admin === null || members.length === 0 ? null : (0, import_react31.createElement)(
++ "label",
++ { className: "bt-set-row bt-bs-row bt-ng-step" },
++ (0, import_react31.createElement)("span", { className: "bt-set-copy" }, (0, import_react31.createElement)("span", { className: "bt-bs-label" }, t("Name"))),
++ (0, import_react31.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) })
++ ),
++ (0, import_react31.createElement)(
++ "div",
++ { className: "bt-set-row bt-task-foot" },
++ (0, import_react31.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")),
++ (0, import_react31.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat"))
++ )
+ );
+ }
+-
+-// src/client/usage-data.js
+-var HOUR = 0;
+-var OWNER = 1;
+-var MODEL = 2;
+-var KIND = 3;
+-var HOUR_MS = 36e5;
+-var DAY_MS = 864e5;
+-var RANGES = [
+- { id: "24h", unit: "hour", count: 24, stride: 6 },
+- { id: "7d", unit: "day", count: 7, stride: 1 },
+- { id: "30d", unit: "day", count: 30, stride: 5 },
+- { id: "12w", unit: "week", count: 12, stride: 2 },
+- { id: "12m", unit: "month", count: 12, stride: 1 }
+-];
+-var hourMs = (key) => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, Number(key.slice(8, 10)), Number(key.slice(11, 13)) || 0);
+-var dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
+-var hourKey = (ms) => new Date(ms).toISOString().slice(0, 13).replace("T", " ");
+-var mondayOf = (ms) => ms - (new Date(ms).getUTCDay() + 6) % 7 * DAY_MS;
+-function spanRange(from, to) {
+- if (to < from) [from, to] = [to, from];
+- const days = Math.round((hourMs(to) - hourMs(from)) / DAY_MS) + 1;
+- const unit = days <= 2 ? "hour" : days <= 62 ? "day" : days <= 182 ? "week" : "month";
+- const range = { id: "custom", unit, from, to, days };
+- const count = spanBuckets(range).length;
+- const stride = unit === "hour" ? 6 : [1, 2, 3, 5, 7, 10, 14].find((step) => count / step <= 12) ?? Math.ceil(count / 12);
+- return { ...range, count, stride };
+-}
+-function spanBuckets(range) {
+- const first = hourMs(range.from);
+- const end = hourMs(range.to) + DAY_MS;
+- const buckets = [];
+- if (range.unit === "hour" || range.unit === "day") {
+- const step = range.unit === "hour" ? HOUR_MS : DAY_MS;
+- for (let start = first; start < end; start += step) buckets.push({ start, end: start + step, key: range.unit === "hour" ? hourKey(start) : dayKey(start) });
+- } else if (range.unit === "week") {
+- for (let monday = mondayOf(first); monday < end; monday += 7 * DAY_MS) {
+- buckets.push({ start: Math.max(monday, first), end: Math.min(monday + 7 * DAY_MS, end), key: dayKey(monday) });
+- }
+- } else {
+- const year = new Date(first).getUTCFullYear();
+- for (let month = new Date(first).getUTCMonth(); Date.UTC(year, month, 1) < end; month += 1) {
+- const start = Date.UTC(year, month, 1);
+- buckets.push({ start: Math.max(start, first), end: Math.min(Date.UTC(year, month + 1, 1), end), key: dayKey(start).slice(0, 7) });
++var GROUP_SETTINGS_CSS = `
++.bt-ng{animation:bt-q-in .4s cubic-bezier(.22,1,.36,1),bt-veil-in .2s ease-out}
++.bt-ng-step{animation:bt-veil-in .22s ease-out}
++.bt-ng-label{display:inline-flex;align-items:center;gap:6px}
++.bt-ng-picks{display:flex;flex-wrap:wrap;gap:6px}
++.bt-ng-chip{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 5px;border-radius:999px;border:.5px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink-2);font:inherit;font-size:13px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease,scale .3s cubic-bezier(.34,1.56,.64,1)}
++.bt-ng-chip:hover{background:var(--bt-hover);color:var(--bt-ink)}
++.bt-ng-chip:active{scale:.96}
++.bt-ng-chip[aria-checked=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)}
++.bt-ng-chip .bt-admin-star{animation:bt-check-pop .4s cubic-bezier(.34,1.56,.64,1)}
++.bt-ng-name{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++@media (prefers-reduced-motion:reduce){.bt-ng,.bt-ng-step,.bt-ng-chip .bt-admin-star{animation:none}}
++.bt-gs-member{gap:8px}
++.bt-gs-who{flex:1;display:flex;align-items:center;gap:8px;min-width:0;padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer}
++.bt-gs-who .bt-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-gs-who:hover .bt-row-title{text-decoration:underline;text-underline-offset:2px}
++.bt-gs-add{gap:8px;color:var(--bt-ink-2)}
++.bt-face-marks{display:inline-flex;flex:none;align-items:center;gap:2px}
++.bt-face-marks>.bt-mark{flex:none}
++.bt-face-person{cursor:default}
++.bt-face-person:hover .bt-row-title{text-decoration:none}
++.bt-face-person-text{display:flex;flex-direction:column;min-width:0}
++.bt-face-bots{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
++.bt-face-bot{padding:0;border:0;background:none;color:inherit;font:inherit}
++button.bt-face-bot{cursor:pointer}
++button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-underline-offset:2px}
++.bt-gs-pick{gap:10px;animation:bt-rise .16s ease-out}
++.bt-bs-stack .bt-seg{align-self:stretch}
++@media (prefers-reduced-motion:reduce){.bt-gs-pick{animation:none}}
++`;
++
++// src/client/palette.js
++var import_react32 = require("react");
++var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives");
++var paletteText = (raw, names) => readableLine(String(raw ?? ""), names);
++var PALETTE_EXIT_MS = 160;
++function CommandPalette({ start, token, roster, actions, useSessions }) {
++ const [query, setQuery] = (0, import_react32.useState)(start ?? "");
++ const [cursor, setCursor] = (0, import_react32.useState)(0);
++ const [leaving, setLeaving] = (0, import_react32.useState)(false);
++ const wanted = query.trim().toLowerCase();
++ const close = () => {
++ if (leaving) return;
++ setLeaving(true);
++ setTimeout(() => actions.closePalette(token), PALETTE_EXIT_MS);
++ };
++ useEscape(close);
++ const ids = [...roster.bots.map(currentPart), ...roster.rooms.map((item) => item.id)];
++ const names = botNames(roster);
++ const found = useSessions((list) => {
++ if (wanted.length < 2) return "[]";
++ const hits = [];
++ for (const id of ids) {
++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline;
++ if (!Array.isArray(outline)) continue;
++ const hidden = roster.exchangeTurns[id] ?? [];
++ for (let index2 = outline.length - 1; index2 >= 0 && hits.length < 30; index2 -= 1) {
++ const entry = outline[index2];
++ if (hidden.includes(entry.turn)) continue;
++ for (const raw of [entry.response, entry.prompt]) {
++ const text2 = paletteText(raw, names);
++ const at = text2.toLowerCase().indexOf(wanted);
++ if (at < 0) continue;
++ const from = Math.max(0, at - 24);
++ hits.push([id, entry.turn, (from > 0 ? "…" : "") + text2.slice(from, at + wanted.length + 60)]);
++ break;
++ }
++ }
+ }
+- }
+- return buckets;
++ return JSON.stringify(hits);
++ });
++ const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted));
++ const sections = [];
++ const people2 = [
++ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({
++ key: bot.id,
++ avatar: (0, import_react32.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }),
++ title: pickerBotLabel(bot),
++ subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "),
++ badge: bot.hidden ? t("Hidden") : void 0,
++ run: async () => {
++ if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
++ actions.openSession(bot.id);
++ }
++ })),
++ ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({
++ key: room2.id,
++ avatar: (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
++ title: room2.name,
++ subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }),
++ badge: room2.hidden ? t("Hidden") : void 0,
++ run: async () => {
++ if (room2.hidden) await actions.setFlags(room2.id, { hidden: false });
++ actions.openSession(room2.id);
++ }
++ }))
++ ];
++ if (people2.length) sections.push([t("Bots"), people2]);
++ const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({
++ key: agent.id,
++ avatar: (0, import_react32.createElement)("span", { className: "bt-palette-agent" }, (0, import_react32.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })),
++ title: "DSH Agent",
++ subtitle: t("Plain dsh session"),
++ run: () => actions.openAgent(agent.id)
++ }));
++ if (agents.length) sections.push(["DSH Agent", agents]);
++ const messages = JSON.parse(found).map(([id, turn, snippet]) => {
++ const bot = roster.byId[id];
++ const room2 = roster.roomsById[id];
++ return {
++ key: `${id}:${turn}`,
++ title: bot?.name ?? room2?.name ?? t("Bot"),
++ subtitle: snippet,
++ avatar: bot ? (0, import_react32.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react32.createElement)(RoomAvatar, { room: room2, roster, size: 24 }),
++ run: () => actions.openSession(id)
++ };
++ });
++ if (messages.length) sections.push([t("Messages"), messages]);
++ const commands = [
++ { key: "cmd:new", icon: (0, import_react32.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") },
++ { key: "cmd:bot", icon: (0, import_react32.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") },
++ { key: "cmd:group", icon: (0, import_react32.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") },
++ { key: "cmd:settings", icon: (0, import_react32.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") }
++ ].filter((command) => matchesName(command.title));
++ if (commands.length) sections.push([t("Commands"), commands]);
++ const rows = sections.flatMap(([, items]) => items);
++ const active = Math.min(cursor, Math.max(rows.length - 1, 0));
++ const shown = rows.map((row) => row.key).join("\n");
++ const glide = useGlide(`${active}
++${shown}`, { axis: "y", ...QUICK_EDGES });
++ const listRef = glide.box;
++ useFlip(listRef, shown, "[data-flip]");
++ (0, import_react32.useEffect)(() => {
++ listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" });
++ }, [active, query]);
++ const choose = (row) => {
++ if (!row || leaving) return;
++ void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu"));
++ close();
++ };
++ let index = -1;
++ return (0, import_react32.createElement)(
++ "div",
++ { className: "bt-palette-layer", "data-leaving": leaving || void 0 },
++ (0, import_react32.createElement)("div", { className: "bt-backdrop", onMouseDown: close }),
++ (0, import_react32.createElement)(
++ "div",
++ { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true },
++ (0, import_react32.createElement)(
++ "div",
++ { className: "bt-palette-head" },
++ (0, import_react32.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react32.createElement)(SearchIcon)),
++ (0, import_react32.createElement)("input", {
++ autoFocus: true,
++ value: query,
++ placeholder: t("Search Bots, chats and messages"),
++ "aria-label": t("Search"),
++ role: "combobox",
++ "aria-expanded": true,
++ "aria-controls": "bt-palette-list",
++ onChange: (event) => {
++ setQuery(event.target.value);
++ setCursor(0);
++ },
++ onKeyDown: (event) => {
++ if (event.nativeEvent.isComposing) return;
++ if (event.key === "ArrowDown") {
++ event.preventDefault();
++ setCursor(Math.min(active + 1, rows.length - 1));
++ } else if (event.key === "ArrowUp") {
++ event.preventDefault();
++ setCursor(Math.max(active - 1, 0));
++ } else if (event.key === "Enter") {
++ event.preventDefault();
++ choose(rows[active]);
++ }
++ }
++ }),
++ query ? (0, import_react32.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => {
++ setQuery("");
++ setCursor(0);
++ } }, (0, import_react32.createElement)(CloseIcon)) : null
++ ),
++ (0, import_react32.createElement)(
++ "div",
++ { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") },
++ (0, import_react32.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ rows.length === 0 ? (0, import_react32.createElement)(
++ "div",
++ { className: "bt-palette-empty" },
++ (0, import_react32.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react32.createElement)(SearchIcon)),
++ (0, import_react32.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")),
++ (0, import_react32.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message"))
++ ) : sections.map(([title, items]) => (0, import_react32.createElement)(
++ import_react32.Fragment,
++ { key: title },
++ (0, import_react32.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title),
++ items.map((row) => {
++ index += 1;
++ const at = index;
++ return (0, import_react32.createElement)(
++ "button",
++ {
++ key: row.key,
++ type: "button",
++ role: "option",
++ className: "bt-palette-row",
++ "aria-selected": at === active,
++ "data-flip": row.key,
++ onMouseMove: () => {
++ if (cursor !== at) setCursor(at);
++ },
++ onClick: () => choose(row)
++ },
++ (0, import_react32.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react32.createElement)("span", { className: "bt-palette-cmd" }, row.icon)),
++ (0, import_react32.createElement)(
++ "span",
++ { className: "bt-palette-text" },
++ (0, import_react32.createElement)("span", { className: "bt-palette-title" }, row.title),
++ row.subtitle ? (0, import_react32.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null
++ ),
++ row.badge ? (0, import_react32.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null
++ );
++ })
++ ))
++ )
++ )
++ );
+ }
+-function spanOf(range, now) {
+- if (range.from) {
+- return { buckets: spanBuckets(range), from: `${range.from} 00`, to: `${range.to} 23`, before: hourKey(hourMs(range.from) - range.days * DAY_MS) };
+- }
+- const buckets = bucketsOf(range, now);
+- return { buckets: buckets.slice(range.count), from: hourKey(buckets[range.count].start), to: now, before: hourKey(buckets[0].start) };
++
++// src/client/bot-draft.js
++function freshBotName(roster, base = "New Bot") {
++ const taken = new Set(roster.bots.map((bot) => bot.name.toLowerCase()));
++ if (!taken.has(base.toLowerCase())) return base;
++ let index = 2;
++ while (taken.has(`${base.toLowerCase()} ${index}`)) index += 1;
++ return `${base} ${index}`;
+ }
+-function bucketsOf(range, now) {
+- const end = hourMs(now);
+- const today = end - end % DAY_MS;
+- const buckets = [];
+- for (let back = range.count * 2 - 1; back >= 0; back -= 1) {
+- let start;
+- if (range.unit === "hour") start = end - back * HOUR_MS;
+- else if (range.unit === "day") start = today - back * DAY_MS;
+- else if (range.unit === "week") start = mondayOf(today) - back * 7 * DAY_MS;
+- else start = Date.UTC(new Date(end).getUTCFullYear(), new Date(end).getUTCMonth() - back, 1);
+- buckets.push({ start, key: range.unit === "hour" ? hourKey(start) : range.unit === "month" ? dayKey(start).slice(0, 7) : dayKey(start) });
+- }
+- return buckets;
+-}
+-var tokensOf = (row) => row[4] + row[5] + row[6] + row[7];
+-var blank2 = () => ({ input: 0, cacheRead: 0, output: 0, calls: 0, total: 0 });
+-function addRow(sum, row) {
+- sum.input += row[4] + row[6];
+- sum.cacheRead += row[5];
+- sum.output += row[7];
+- sum.calls += row[8];
+- sum.total += tokensOf(row);
+- return sum;
+-}
+-function addTo(map, key, row) {
+- map.set(key, addRow(map.get(key) ?? blank2(), row));
+-}
+-function summarize(view, range, keep = () => true, seriesOf = () => "all") {
+- const { buckets, from, to, before } = spanOf(range, view.now);
+- const index = new Map(buckets.map((bucket, at) => [bucket.key, at]));
+- const weeks = /* @__PURE__ */ new Map();
+- const keyOf = (hour) => {
+- if (range.unit === "hour") return hour;
+- if (range.unit === "day") return hour.slice(0, 10);
+- if (range.unit === "month") return hour.slice(0, 7);
+- const day = hour.slice(0, 10);
+- let week = weeks.get(day);
+- if (week === void 0) weeks.set(day, week = dayKey(mondayOf(hourMs(day))));
+- return week;
+- };
+- const current = buckets.map((bucket) => ({ ...bucket, sum: blank2(), stacks: /* @__PURE__ */ new Map() }));
+- const result = { buckets: current, total: blank2(), previous: 0, owners: /* @__PURE__ */ new Map(), models: /* @__PURE__ */ new Map(), kinds: /* @__PURE__ */ new Map() };
+- for (const row of view.rows) {
+- const hour = row[HOUR];
+- if (hour < before || hour > to || !keep(row)) continue;
+- if (hour < from) {
+- result.previous += tokensOf(row);
+- continue;
+- }
+- const at = index.get(keyOf(hour));
+- if (at === void 0) continue;
+- const bucket = current[at];
+- addRow(bucket.sum, row);
+- addRow(result.total, row);
+- const series = seriesOf(row);
+- bucket.stacks.set(series, (bucket.stacks.get(series) ?? 0) + tokensOf(row));
+- addTo(result.owners, row[OWNER], row);
+- addTo(result.models, row[MODEL], row);
+- addTo(result.kinds, row[KIND], row);
+- }
+- return result;
++function startModel(roster) {
++ const models = roster.models ?? [];
++ const served = (ref) => ref ? models.find((model) => model.ref === ref) : void 0;
++ const main = roster.byId?.[roster.mainBotId] ?? (roster.bots ?? []).find((bot) => bot.id === roster.mainBotId);
++ return (served(main?.model) ?? served(main?.appliedModel) ?? served(roster.defaultModel) ?? models[0])?.ref ?? "";
+ }
+-function trimmed2(value) {
+- const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
+- return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2;
++function createPayload({ name, look, image, model, effort }) {
++ const payload = { name, color: look.color, avatar: image ? { shape: look.shape, image } : { shape: look.shape } };
++ if (model) payload.model = model;
++ if (effort) payload.effort = effort;
++ return payload;
+ }
+-function compact(count) {
+- for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) {
+- if (count >= size * 0.9995) return `${trimmed2(count / size)}${unit}`;
++async function afterCreate({ id, text: text2, actions }) {
++ actions.openSession(id);
++ actions.showDetails(id, "settings");
++ const line2 = String(text2 ?? "").trim();
++ if (line2 === "") return;
++ try {
++ await actions.send(id, line2);
++ } catch (failure) {
++ actions.notify?.(failure?.message ?? String(failure));
+ }
+- return String(Math.round(count));
+-}
+-function percent(part, whole) {
+- if (!(whole > 0) || !(part > 0)) return "0";
+- const value = part / whole * 100;
+- return value < 1 ? "<1" : String(Math.round(value));
+ }
+
+-// src/client/usage-page.js
+-var import_react37 = require("react");
+-var HEAT_WEEKS = 53;
+-var TOP_SERIES = 7;
+-var RING_R = 42;
+-var RING2 = 2 * Math.PI * RING_R;
+-var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id];
+-var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit];
+-var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"];
+-var PARTS = [["output", 1], ["input", 0.6], ["cacheRead", 0.32]];
+-var partLabel = (part) => ({ output: t("Output"), input: t("Input"), cacheRead: t("Cache hit") })[part];
+-var soften = (paint, strength = 1) => {
+- const mix = (color) => `color-mix(in srgb, ${color} ${Math.round(88 * strength)}%, var(--bt-card))`;
+- return paint.startsWith("linear-gradient(") ? paint.replace(/#[0-9a-f]{6}/gi, mix) : mix(paint);
++// src/client/create-bot.js
++var import_react33 = require("react");
++var pick = (list) => list[Math.floor(Math.random() * list.length)];
++var randomLook = (avoid = {}) => {
++ const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape);
++ const colors = DEFAULT_COLORS.filter((color) => color !== avoid.color);
++ return { shape: pick(shapes), color: pick(colors) };
+ };
+-var full = (count) => new Intl.NumberFormat(dateLocale()).format(Math.round(count));
+-var hitRate = (sum) => percent(sum.cacheRead, sum.input + sum.cacheRead);
+-var dates = (options) => new Intl.DateTimeFormat(dateLocale(), { timeZone: "UTC", ...options });
+-var hourText = (ms) => `${String(new Date(ms).getUTCHours()).padStart(2, "0")}:00`;
+-var yearOf = (ms) => new Date(ms).getUTCFullYear();
+-function spanText(from, to) {
+- const start = hourMs(from);
+- const end = hourMs(to);
+- const year = (/* @__PURE__ */ new Date()).getFullYear();
+- const format = dates(yearOf(start) !== year || yearOf(end) !== year ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
+- return from === to ? format.format(start) : `${format.format(start)} – ${format.format(end)}`;
+-}
+-var daysText = (count) => count === 1 ? t("1 day") : t("{count} days", { count });
+-var lastText = (range) => range.from ? spanText(range.from, range.to) : t("Last {range}", { range: rangeLabel(range.id) });
+-function compareText(range) {
+- if (!range.from) return t("Compared with the {range} before", { range: rangeLabel(range.id) });
+- return range.days === 1 ? t("Compared with the day before") : t("Compared with the {count} days before", { count: range.days });
+-}
+-function tickOf(range, bucket) {
+- if (range.unit === "hour") {
+- return range.days > 1 && new Date(bucket.start).getUTCHours() === 0 ? dates({ month: "numeric", day: "numeric" }).format(bucket.start) : hourText(bucket.start);
+- }
+- if (range.unit === "month") return dates({ month: "short" }).format(bucket.start);
+- return dates({ month: "numeric", day: "numeric" }).format(bucket.start);
+-}
+-function titleOf(range, bucket) {
+- if (range.unit === "hour") return `${dates({ month: "short", day: "numeric" }).format(bucket.start)} ${hourText(bucket.start)}–${hourText(bucket.start + HOUR_MS)}`;
+- if (range.unit === "day") return dates({ month: "long", day: "numeric", weekday: "short" }).format(bucket.start);
+- if (range.unit === "week") return spanText(dayKey(bucket.start), dayKey((bucket.end ?? bucket.start + 7 * DAY_MS) - DAY_MS));
+- if (bucket.end && (new Date(bucket.start).getUTCDate() !== 1 || new Date(bucket.end).getUTCDate() !== 1)) return spanText(dayKey(bucket.start), dayKey(bucket.end - DAY_MS));
+- return dates({ year: "numeric", month: "long" }).format(bucket.start);
+-}
+-function useTween(value) {
+- const [shown, setShown] = (0, import_react37.useState)(0);
+- const from = (0, import_react37.useRef)(0);
+- (0, import_react37.useEffect)(() => {
+- if (reducedMotion() || typeof requestAnimationFrame !== "function") {
+- from.current = value;
+- setShown(value);
+- return void 0;
++function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) {
++ const [look, setLook] = (0, import_react33.useState)(() => randomLook());
++ const [image, setImage] = (0, import_react33.useState)(null);
++ const [name, setName] = (0, import_react33.useState)(start?.name ?? "");
++ const [model, setModel] = (0, import_react33.useState)(() => startModel(roster));
++ const [effort, setEffort] = (0, import_react33.useState)(void 0);
++ const [busy, setBusy] = (0, import_react33.useState)(false);
++ const [error, setError] = (0, import_react33.useState)("");
++ const fileRef = (0, import_react33.useRef)(null);
++ const frame = (0, import_react33.useRef)(null);
++ useEscape(onBack);
++ (0, import_react33.useEffect)(() => {
++ if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" });
++ }, []);
++ const fallbackName = freshBotName(roster, t("New Bot"));
++ const finalName = name.trim() || fallbackName;
++ const clash = roster.bots.some((bot) => bot.name.toLowerCase() === finalName.toLowerCase());
++ const problem = nameProblem(finalName);
++ const create = async () => {
++ if (busy || clash || problem) return;
++ setError("");
++ setBusy(true);
++ try {
++ const bot = await actions.createBot(createPayload({ name: finalName, look, image, model, effort }));
++ await onCreated(bot);
++ } catch (failure) {
++ setError(failure?.message ?? String(failure));
++ setBusy(false);
+ }
+- const begin = from.current;
+- const started = performance.now();
+- let frame;
+- const step = (now) => {
+- const progress = Math.min(1, (now - started) / 650);
+- from.current = begin + (value - begin) * (1 - (1 - progress) ** 3);
+- setShown(from.current);
+- if (progress < 1) frame = requestAnimationFrame(step);
+- };
+- frame = requestAnimationFrame(step);
+- return () => cancelAnimationFrame(frame);
+- }, [value]);
+- return shown;
+-}
+-var glyphBox = (size, icon3) => (0, import_react37.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react37.createElement)(icon3));
+-function ModelGlyph({ name, paint, size }) {
+- return (0, import_react37.createElement)("span", {
+- className: "bt-us-model",
+- "aria-hidden": true,
+- style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` }
+- }, (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase());
+-}
+-function ownerFace(owner, roster) {
+- if (owner.kind === "bot") {
+- const bot = roster.byId[owner.id] ?? { id: owner.id, name: owner.name };
+- const name = bot.name || owner.name || t("Deleted Bot");
+- const role = bot.role?.trim() ?? "";
+- return {
+- kind: "bot",
+- name,
+- sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role,
+- paint: paintCss(colorOf(bot)),
+- stops: GRADIENTS[colorOf(bot)],
+- glyph: (size) => (0, import_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false })
+- };
+- }
+- if (owner.kind === "room") {
+- const room2 = roster.roomsById[owner.id];
+- return {
+- kind: "room",
+- name: owner.name || t("Deleted group chat"),
+- sub: owner.deleted ? t("Deleted") : t("Group chat"),
+- paint: "var(--bt-us-rest)",
+- glyph: (size) => room2 ? (0, import_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon)
+- };
+- }
+- if (owner.kind === "agent") {
+- return {
+- kind: "agent",
+- name: owner.name || "DSH Agent",
+- sub: owner.deleted ? t("Deleted") : "",
+- paint: "var(--bt-us-rest)",
+- glyph: (size) => glyphBox(size, ChatsIcon)
+- };
+- }
+- return { kind: "other", name: t("Other"), sub: "", paint: "var(--bt-us-rest)", glyph: (size) => glyphBox(size, ChatsIcon) };
+-}
+-function kindLabel(kind, botFocus) {
+- const labels = { chat: botFocus ? t("Own chat") : t("Conversations"), group: t("Group chats"), compaction: t("Compaction and handoff"), image: t("Image reading"), memory: t("Memory"), "session-title": t("Titles"), other: t("Other") };
+- return labels[kind] ?? kind;
+-}
+-function Tabs({ label, value, options, onChange, boxRef }) {
+- const names = options.map(([, text2]) => text2).join("\n");
+- const glide = useGlide(`${value}
+-${names}`, { box: boxRef });
+- return (0, import_react37.createElement)(
++ };
++ const upload = (file) => {
++ if (!file) return;
++ setError("");
++ avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure)));
++ };
++ const previewLook = image ? { ...look, image } : look;
++ const card = (0, import_react33.createElement)(
+ "div",
+- { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label },
+- (0, import_react37.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+- options.map(([id, text2, icon3]) => (0, import_react37.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react37.createElement)(icon3) : null, text2))
+- );
+-}
+-function Stats({ range, total, previous }) {
+- const shown = useTween(total.total);
+- const change = previous > 0 ? Math.round((total.total - previous) / previous * 100) : null;
+- const cell = (label, value) => (0, import_react37.createElement)("div", { className: "bt-us-stat" }, (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react37.createElement)("span", { className: "bt-us-stat-value" }, value));
+- return (0, import_react37.createElement)(
+- "section",
+- { className: "bt-us-stats", style: { "--i": 0 } },
+- (0, import_react37.createElement)(
++ { className: "bt-create-card" },
++ (0, import_react33.createElement)(
+ "div",
+- { className: "bt-us-stat bt-us-stat-main" },
+- (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)),
+- (0, import_react37.createElement)(
++ { className: "bt-create-preview" },
++ // The key remounts the mark, so every new pick plays the pop.
++ (0, import_react33.createElement)(
+ "span",
+- { className: "bt-us-stat-value" },
+- compact(shown),
+- (0, import_react37.createElement)("small", null, t("tokens")),
+- change === null ? null : (0, import_react37.createElement)(
+- "span",
+- { className: "bt-us-delta", title: compareText(range) },
+- `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%`
+- )
+- )
+- ),
+- cell(t("Cache hit rate"), `${hitRate(total)}%`),
+- cell(t("Output"), compact(total.output)),
+- cell(t("Calls"), full(total.calls))
+- );
+-}
+-function niceTop(max) {
+- if (!(max > 0)) return 1;
+- const step = 10 ** Math.floor(Math.log10(max));
+- return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max);
+-}
+-function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) {
+- const [hover, setHover] = (0, import_react37.useState)(null);
+- const top = niceTop(Math.max(0, ...columns.map((column) => column.total)));
+- const count = columns.length;
+- const fillOf = new Map(series.map((entry) => [entry.key, entry.fill]));
+- const tipOf = new Map(tipSeries.map((entry) => [entry.key, entry]));
+- const tip = hover === null ? null : columns[hover];
+- const tipSide = hover !== null && hover >= count / 2 ? { right: `calc(${(count - hover) / count * 100}% + 8px)` } : { left: `calc(${(hover + 1) / count * 100}% + 8px)` };
+- return (0, import_react37.createElement)(
+- "section",
+- { className: "bt-us-card bt-us-chart", style: { "--i": 1 } },
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-card-head" },
+- (0, import_react37.createElement)("h3", null, t("Tokens over time"), (0, import_react37.createElement)("span", null, unitLabel(range.unit))),
+- onBy ? (0, import_react37.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null
++ { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" },
++ (0, import_react33.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true })
++ ),
++ (0, import_react33.createElement)("span", { className: "bt-create-name" }, finalName)
+ ),
+- (0, import_react37.createElement)(
++ (0, import_react33.createElement)("div", { className: "bt-create-step" }, t("Look")),
++ (0, import_react33.createElement)(
+ "div",
+- { className: "bt-us-chart-body" },
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-plot", "data-hover": hover === null ? void 0 : "", onMouseLeave: () => setHover(null) },
+- [1, 0.5].map((level) => (0, import_react37.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react37.createElement)("span", null, compact(top * level)))),
+- (0, import_react37.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react37.createElement)("span", null, "0")),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-cols" },
+- columns.map((column, at) => (0, import_react37.createElement)("div", {
+- key: `${range.from ? `${range.from}~${range.to}` : range.id}:${column.key}`,
+- className: "bt-us-col",
+- "data-on": hover === at ? "" : void 0,
+- onMouseEnter: () => setHover(at)
+- }, column.total > 0 ? (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-stack", style: { height: `${column.total / top * 100}%`, "--i": at } },
+- column.parts.map(([key, value]) => value > 0 ? (0, import_react37.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null)
+- ) : (0, import_react37.createElement)("div", { className: "bt-us-stub" })))
+- ),
+- tip ? (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-tip", style: tipSide },
+- (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, tip.title),
+- (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(tip.total) })),
+- tip.tip.filter(([, value]) => value > 0).sort((a, b) => b[1] - a[1]).map(([key, value]) => (0, import_react37.createElement)(
+- "div",
+- { key, className: "bt-us-tip-row" },
+- (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }),
+- (0, import_react37.createElement)("span", null, tipOf.get(key)?.name),
+- (0, import_react37.createElement)("b", null, compact(value))
+- ))
+- ) : null
+- ),
+- (0, import_react37.createElement)(
++ { className: "bt-create-looks" },
++ (0, import_react33.createElement)(CharacterGrid, {
++ look,
++ hasPhoto: Boolean(image),
++ onShape: (shape) => {
++ setImage(null);
++ setLook((current) => ({ ...current, shape }));
++ },
++ onColor: (color) => {
++ setImage(null);
++ setLook((current) => ({ ...current, color }));
++ }
++ }),
++ (0, import_react33.createElement)(
+ "div",
+- { className: "bt-us-ticks", "aria-hidden": true },
+- // A preset counts its labels back from now; a picked span forward from its first day.
+- columns.map((column, at) => (0, import_react37.createElement)(
+- "span",
+- { key: column.key, "data-now": at === count - 1 && range.ends !== false ? "" : void 0 },
+- (range.from ? at : count - 1 - at) % range.stride === 0 ? column.tick : ""
+- ))
++ { className: "bt-actions bt-create-lookbar" },
++ (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
++ setImage(null);
++ setLook((current) => randomLook(current));
++ } }, t("Shuffle")),
++ (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")),
++ image ? (0, import_react33.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null,
++ (0, import_react33.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => {
++ const file = event.target.files?.[0];
++ event.target.value = "";
++ upload(file);
++ } })
+ )
+ ),
+- series.length > 1 || series[0]?.pick ? (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-legend" },
+- series.map((entry) => (0, import_react37.createElement)(entry.pick ? "button" : "span", {
+- key: entry.key,
+- className: "bt-us-key",
+- ...entry.pick ? { type: "button", onClick: () => onPick(entry.pick) } : {}
+- }, (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name))
+- ) : null
+- );
+-}
+-function Row3({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) {
+- const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0;
+- return (0, import_react37.createElement)(
+- onClick ? "button" : "div",
+- {
+- className: "bt-us-row",
+- "data-kind": kind,
+- style: { "--i": index },
+- title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }),
+- ...onClick ? { type: "button", onClick } : {}
+- },
+- glyph ? (0, import_react37.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null,
+- (0, import_react37.createElement)(
+- "span",
+- { className: "bt-us-row-body" },
+- (0, import_react37.createElement)(
+- "span",
+- { className: "bt-us-row-line" },
+- (0, import_react37.createElement)("span", { className: "bt-us-row-name" }, name),
+- sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null
+- ),
+- (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill3 } }))
++ (0, import_react33.createElement)(
++ "label",
++ { className: "bt-field" },
++ (0, import_react33.createElement)("span", { className: "bt-field-label" }, t("Name")),
++ (0, import_react33.createElement)("input", {
++ className: "bt-input",
++ autoFocus: !inline,
++ value: name,
++ placeholder: fallbackName,
++ maxLength: 40,
++ "aria-label": t("Name"),
++ onChange: (event) => setName(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Enter" && !event.nativeEvent.isComposing) {
++ event.preventDefault();
++ void create();
++ }
++ }
++ }),
++ problem ? (0, import_react33.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react33.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null
+ ),
+- (0, import_react37.createElement)("span", { className: "bt-us-row-num" }, (0, import_react37.createElement)("b", null, compact(sum.total)), (0, import_react37.createElement)("small", null, `${percent(sum.total, whole)}%`))
+- );
+-}
+-function ListCard({ title, count, index, children }) {
+- return (0, import_react37.createElement)(
+- "section",
+- { className: "bt-us-card", style: { "--i": index } },
+- (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, title, count === void 0 ? null : (0, import_react37.createElement)("span", null, count))),
+- (0, import_react37.createElement)("div", { className: "bt-us-rows" }, children)
+- );
+-}
+-function Profile({ face, share, range }) {
+- const arc = Math.max(0, Math.min(1, share)) * RING2;
+- return (0, import_react37.createElement)(
+- "section",
+- { className: "bt-us-profile" },
+- (0, import_react37.createElement)(
+- "span",
+- { className: "bt-us-ring" },
+- (0, import_react37.createElement)(
+- "svg",
+- { viewBox: "0 0 96 96", "aria-hidden": true },
+- face.stops ? (0, import_react37.createElement)("defs", null, (0, import_react37.createElement)(
+- "linearGradient",
+- { id: "bt-us-ring-paint", x1: 0, y1: 1, x2: 1, y2: 0 },
+- face.stops.map(([offset, color]) => (0, import_react37.createElement)("stop", { key: offset, offset, stopColor: color }))
+- )) : null,
+- (0, import_react37.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }),
+- arc > 0 ? (0, import_react37.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null
+- ),
+- (0, import_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64))
++ (0, import_react33.createElement)(
++ "div",
++ { className: "bt-field" },
++ (0, import_react33.createElement)("span", { className: "bt-field-label" }, t("Model")),
++ (0, import_react33.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => {
++ setModel(ref);
++ setEffort(void 0);
++ }, onPickEffort: setEffort }),
++ (0, import_react33.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later."))
+ ),
+- (0, import_react37.createElement)(
++ error ? (0, import_react33.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
++ (0, import_react33.createElement)(
+ "div",
+- { className: "bt-us-profile-copy" },
+- (0, import_react37.createElement)("h3", null, face.name),
+- face.sub ? (0, import_react37.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null,
+- (0, import_react37.createElement)("span", { className: "bt-us-profile-share" }, range.from ? t("{share}% of all usage from {span}", { share: percent(share, 1), span: spanText(range.from, range.to) }) : t("{share}% of all usage in the last {range}", { share: percent(share, 1), range: rangeLabel(range.id) }))
++ { className: "bt-create-foot" },
++ // The dialog's header already has Back and Close (#230); only the inline form needs its own.
++ inline ? (0, import_react33.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null,
++ (0, import_react33.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create"))
+ )
+ );
+-}
+-var monthOf = (ms) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth(), 1);
+-var addMonths = (ms, count) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth() + count, 1);
+-function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) {
+- const box = (0, import_react37.useRef)(null);
+- const todayMs = hourMs(today);
+- const thisMonth = monthOf(todayMs);
+- const [right, setRight] = (0, import_react37.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today))));
+- const [anchor, setAnchor] = (0, import_react37.useState)(null);
+- const [hover, setHover] = (0, import_react37.useState)(null);
+- (0, import_react37.useEffect)(() => {
+- const onDown = (event) => {
+- if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose();
+- };
+- const onKey = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- onClose();
+- }
+- };
+- window.addEventListener("mousedown", onDown);
+- window.addEventListener("keydown", onKey, true);
+- return () => {
+- window.removeEventListener("mousedown", onDown);
+- window.removeEventListener("keydown", onKey, true);
+- };
+- }, []);
+- const [low, high] = anchor ? [anchor, hover ?? anchor].sort() : span ? [span.from, span.to] : [null, null];
+- const press = (key) => {
+- if (!anchor) {
+- setAnchor(key);
+- return;
+- }
+- const [from, to] = [anchor, key].sort();
+- onPick(from, to);
+- };
+- const weekdays = (0, import_react37.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []);
+- const month = (start, side) => {
+- const length = new Date(addMonths(start, 1) - DAY_MS).getUTCDate();
+- const lead = (new Date(start).getUTCDay() + 6) % 7;
+- const cells = Array.from({ length: lead }, (_, at) => (0, import_react37.createElement)("span", { key: `b${at}` }));
+- for (let day = 1; day <= length; day += 1) {
+- const ms = start + (day - 1) * DAY_MS;
+- const key = dayKey(ms);
+- const weekday = (lead + day - 1) % 7;
+- const flag = (on) => on ? "" : void 0;
+- cells.push((0, import_react37.createElement)("button", {
+- key,
+- type: "button",
+- className: "bt-us-day",
+- disabled: ms > todayMs,
+- "aria-label": dates({ year: "numeric", month: "long", day: "numeric", weekday: "short" }).format(ms),
+- "aria-pressed": key === low || key === high,
+- "data-in": flag(low !== null && key >= low && key <= high),
+- "data-start": flag(key === low),
+- "data-end": flag(key === high),
+- "data-l": flag(weekday === 0 || day === 1),
+- "data-r": flag(weekday === 6 || day === length),
+- "data-today": flag(key === today),
+- "data-has": flag(active.has(key)),
+- onMouseEnter: () => setHover(key),
+- onClick: () => press(key)
+- }, (0, import_react37.createElement)("b", null, day)));
+- }
+- const nav = side === "left" ? (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "left", "aria-label": t("Previous month"), disabled: start <= oldest, onClick: () => setRight(addMonths(right, -1)) }, (0, import_react37.createElement)(ChevronLeftIcon)) : (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "right", "aria-label": t("Next month"), disabled: start >= thisMonth, onClick: () => setRight(addMonths(right, 1)) }, (0, import_react37.createElement)(ChevronRightIcon));
+- return (0, import_react37.createElement)(
+- "div",
+- { key: start, className: "bt-us-month" },
+- (0, import_react37.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react37.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))),
+- (0, import_react37.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react37.createElement)("span", { key: at }, name))),
+- (0, import_react37.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells)
+- );
+- };
+- const shortcuts = [
+- ["month", t("This month"), dayKey(thisMonth), today],
+- ["last", t("Last month"), dayKey(addMonths(thisMonth, -1)), dayKey(thisMonth - DAY_MS)],
+- ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today]
+- ];
+- const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1;
+- return (0, import_react37.createElement)(
++ if (inline) return (0, import_react33.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card);
++ return (0, import_react33.createElement)(
+ "div",
+- { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") },
+- (0, import_react37.createElement)(
++ { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") },
++ (0, import_react33.createElement)(
+ "div",
+- { className: "bt-us-pick-quick" },
+- shortcuts.map(([id, label, from, to]) => (0, import_react37.createElement)("button", {
+- key: id,
+- type: "button",
+- "aria-pressed": span?.from === from && span?.to === to,
+- onClick: () => onPick(from, to)
+- }, label))
++ { className: "bt-create-head" },
++ canGoBack ? (0, import_react33.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react33.createElement)(ChevronLeftIcon)) : (0, import_react33.createElement)("span", { className: "bt-create-gap" }),
++ (0, import_react33.createElement)("span", { className: "bt-create-title" }, t("Create Bot")),
++ (0, import_react33.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react33.createElement)(CloseIcon))
+ ),
+- (0, import_react37.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-pick-foot", role: "status" },
+- (0, import_react37.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")),
+- low === null ? null : (0, import_react37.createElement)("span", null, (0, import_react37.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`)
+- )
++ (0, import_react33.createElement)("div", { className: "bt-create-body" }, card)
+ );
+ }
+-function Heat({ view, keep, paint, index }) {
+- const [hover, setHover] = (0, import_react37.useState)(null);
+- const today = hourMs(`${view.now.slice(0, 10)} 00`);
+- const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS;
+- const from = dayKey(first);
+- const daily = /* @__PURE__ */ new Map();
+- for (const row of view.rows) {
+- const day = row[HOUR].slice(0, 10);
+- if (day >= from && keep(row)) daily.set(day, (daily.get(day) ?? 0) + tokensOf(row));
+- }
+- const most = Math.max(0, ...daily.values());
+- const levels = [0.3, 0.52, 0.76, 1];
+- const months = [];
+- const cells = [];
+- for (let week = 0; week < HEAT_WEEKS; week += 1) {
+- const monday = first + week * 7 * DAY_MS;
+- if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) {
+- months.push((0, import_react37.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday)));
+- }
+- for (let day = 0; day < 7; day += 1) {
+- const ms = monday + day * DAY_MS;
+- const value = daily.get(dayKey(ms)) ?? 0;
+- const level = value > 0 ? Math.max(1, Math.ceil(4 * Math.sqrt(value / most))) : 0;
+- cells.push((0, import_react37.createElement)("span", {
+- key: ms,
+- className: "bt-us-cell",
+- style: { "--w": week },
+- "data-on": hover?.ms === ms ? "" : void 0,
+- "data-future": ms > today ? "" : void 0,
+- "data-today": ms === today ? "" : void 0,
+- onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day })
+- }, level > 0 ? (0, import_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null));
++
++// src/client/new-chat.js
++var import_react34 = require("react");
++function NewChat({ mode, roster, actions }) {
++ const [query, setQuery] = (0, import_react34.useState)("");
++ const [picked, setPicked] = (0, import_react34.useState)([]);
++ const [group, setGroup] = (0, import_react34.useState)(mode === "group");
++ const [compose, setCompose] = (0, import_react34.useState)(mode !== "new");
++ const [admin, setAdmin] = (0, import_react34.useState)(null);
++ const [message, setMessage] = (0, import_react34.useState)("");
++ const [busy, setBusy] = (0, import_react34.useState)(false);
++ const [error, setError] = (0, import_react34.useState)("");
++ const [fix, setFix] = (0, import_react34.useState)(null);
++ const [fixNote, setFixNote] = (0, import_react34.useState)("");
++ const [inviteLink, setInviteLink] = (0, import_react34.useState)("");
++ const [cursor, setCursor] = (0, import_react34.useState)(0);
++ const [draft, setDraft] = (0, import_react34.useState)(mode === "create" ? { name: "", fromList: false } : null);
++ const inputRef = (0, import_react34.useRef)(null);
++ const close = () => actions.closeOverlay();
++ useEscape(close);
++ const paneLeft = usePaneLeft();
++ const wanted = query.trim().toLowerCase();
++ const bots = listedChatBots(roster);
++ const choosingAdmin = group && admin === null;
++ const pool = choosingAdmin ? bots.filter((bot) => !bot.remote) : bots;
++ const matches = pool.filter((bot) => !picked.includes(bot.id) && bot.id !== admin && (wanted === "" || bot.name.toLowerCase().includes(wanted) || pickerBotLabel(bot).toLowerCase().includes(wanted) || (bot.role ?? "").toLowerCase().includes(wanted)));
++ const exact = bots.some((bot) => bot.name.toLowerCase() === wanted);
++ const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean);
++ const run = async (task) => {
++ if (busy) return;
++ setError("");
++ setFix(null);
++ setFixNote("");
++ setInviteLink("");
++ setBusy(true);
++ try {
++ await task();
++ } catch (failure) {
++ setError(failure?.message ?? String(failure));
++ setFix(ungrantedLocalFix(failure?.details) ?? null);
++ } finally {
++ setBusy(false);
+ }
+- }
+- const tipSide = hover ? {
+- ...hover.week >= HEAT_WEEKS / 2 ? { right: `calc(${(HEAT_WEEKS - hover.week) / HEAT_WEEKS * 100}% + 8px)` } : { left: `calc(${(hover.week + 1) / HEAT_WEEKS * 100}% + 8px)` },
+- ...hover.day < 4 ? { top: `calc(${hover.day / 7 * 100}% - 2px)` } : { top: "auto", bottom: `calc(${(6 - hover.day) / 7 * 100}% - 2px)` }
+- } : null;
+- const active = [...daily.values()].filter((value) => value > 0).length;
+- const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active });
+- return (0, import_react37.createElement)(
+- "section",
+- { className: "bt-us-card bt-us-heat", style: { "--i": index } },
+- (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, t("Activity")), (0, import_react37.createElement)("span", { className: "bt-us-head-note" }, summary)),
+- (0, import_react37.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-heat-plot", onMouseLeave: () => setHover(null) },
+- (0, import_react37.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells),
+- hover ? (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-tip bt-us-heat-tip", style: tipSide },
+- (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)),
+- (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) }))
+- ) : null
+- ),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-heat-scale", "aria-hidden": true },
+- t("Light"),
+- levels.map((level) => (0, import_react37.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react37.createElement)("i", { style: { background: soften(paint, level) } }))),
+- t("Heavy")
+- )
+- );
+-}
+-function Skeleton2() {
+- return (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us", "aria-busy": true },
+- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-sheet bt-us-skel-sheet" },
+- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }),
+- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }),
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-pair" },
+- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }),
+- (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } })
+- )
+- )
+- );
+-}
+-var focusOf = (id) => typeof id !== "string" ? null : id.startsWith("model:") ? { kind: "model", key: id.slice("model:".length) } : id.startsWith("owner:") ? { kind: "owner", key: id.slice("owner:".length) } : null;
+-function UsagePage({ roster, actions, focusId }) {
+- const [view, setView] = (0, import_react37.useState)(null);
+- const [error, setError] = (0, import_react37.useState)("");
+- const [rangeId, setRangeId] = (0, import_react37.useState)("7d");
+- const [span, setSpan] = (0, import_react37.useState)(null);
+- const [picking, setPicking] = (0, import_react37.useState)(false);
+- const [by, setBy] = (0, import_react37.useState)("bot");
+- const tabs = (0, import_react37.useRef)(null);
+- const focus = focusOf(focusId);
+- (0, import_react37.useEffect)(() => {
+- let stopped = false;
+- let timer;
+- const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => {
+- })));
+- const load = async () => {
+- try {
+- const value = await actions.usage();
+- await resized;
+- if (stopped) return;
+- (0, import_react37.startTransition)(() => setView(value));
+- setError("");
+- timer = setTimeout(load, value.scanning ? 3e3 : 2e4);
+- } catch (failure) {
+- if (stopped) return;
+- setError(failure?.message ?? String(failure));
+- timer = setTimeout(load, 1e4);
+- }
+- };
+- void load();
+- return () => {
+- stopped = true;
+- clearTimeout(timer);
+- };
+- }, []);
+- const today = view?.now.slice(0, 10);
+- const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId);
+- const shaped = (0, import_react37.useMemo)(() => {
+- if (!view) return null;
+- const faces2 = view.owners.map((owner) => ownerFace(owner, roster));
+- const lifetime = /* @__PURE__ */ new Map();
+- for (const row of view.rows) lifetime.set(row[MODEL], (lifetime.get(row[MODEL]) ?? 0) + tokensOf(row));
+- const modelPaint = new Map([...lifetime].sort((a, b) => b[1] - a[1]).map(([model], rank) => [model, `var(--bt-ink-${MODEL_INKS[rank % MODEL_INKS.length]})`]));
+- const models2 = view.models.map((model, at) => {
+- const name = model.name || model.model || t("Unknown model");
+- const paint = modelPaint.get(at) ?? "var(--bt-us-rest)";
+- return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react37.createElement)(ModelGlyph, { name, paint, size }) };
+- });
+- const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1;
+- const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1;
+- const isBot2 = (at) => ["bot", "agent"].includes(view.owners[at]?.kind);
+- const all2 = summarize(view, range, void 0, (row) => by === "bot" ? isBot2(row[OWNER]) ? `o${row[OWNER]}` : "rest" : `m${row[MODEL]}`);
+- const keep2 = ownerAt2 >= 0 ? (row) => row[OWNER] === ownerAt2 : modelAt >= 0 ? (row) => row[MODEL] === modelAt : () => true;
+- const face2 = ownerAt2 >= 0 ? faces2[ownerAt2] : modelAt >= 0 ? models2[modelAt] : null;
+- const part2 = face2 ? summarize(view, range, keep2) : all2;
+- const activeDays2 = /* @__PURE__ */ new Set();
+- let first = view.now;
+- for (const row of view.rows) {
+- if (row[HOUR] < first) first = row[HOUR];
+- if (keep2(row) && tokensOf(row) > 0) activeDays2.add(row[HOUR].slice(0, 10));
++ };
++ const leaveOut = (id) => {
++ if (id === admin) setAdmin(null);
++ setPicked((current) => current.filter((other) => other !== id));
++ setError("");
++ setFix(null);
++ setFixNote("");
++ setInviteLink("");
++ };
++ const copyInvite = async (bot) => {
++ const social = globalThis.__flybot__?.social;
++ let link;
++ try {
++ const invite = await social.createInvite(bot.id);
++ link = invite?.link || invite?.url;
++ if (!link) throw new Error("no link");
++ } catch {
++ setInviteLink("");
++ setFixNote(t("Could not create the invite. Use Bot settings → Sharing → Copy invite link for this Bot."));
++ return;
+ }
+- const oldest2 = Math.min(monthOf(hourMs(first)), addMonths(monthOf(hourMs(view.now)), -12));
+- const ranked2 = (map) => [...map].filter(([, sum]) => sum.total > 0).sort((a, b) => b[1].total - a[1].total);
+- let series2;
+- if (face2) {
+- series2 = [{ key: "all", name: face2.name, fill: soften(face2.paint) }];
++ if (await copiedToClipboard(link)) {
++ setInviteLink("");
++ setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name }));
+ } else {
+- if (by === "bot") {
+- const bots = ranked2(all2.owners).filter(([at]) => isBot2(at));
+- series2 = bots.slice(0, TOP_SERIES).map(([at]) => ({ key: `o${at}`, name: faces2[at].name, paint: faces2[at].paint, pick: { kind: "owner", key: view.owners[at].key } }));
+- if (bots.length > TOP_SERIES) series2.push({ key: "more", name: t("More Bots"), paint: "var(--bt-us-more)" });
+- if (ranked2(all2.owners).some(([at]) => !isBot2(at))) series2.push({ key: "rest", name: t("Other"), paint: "var(--bt-us-rest)" });
+- } else {
+- const used = ranked2(all2.models);
+- series2 = used.slice(0, TOP_SERIES).map(([at]) => ({ key: `m${at}`, name: models2[at].name, paint: models2[at].paint, pick: { kind: "model", key: view.models[at].key } }));
+- if (used.length > TOP_SERIES) series2.push({ key: "more", name: t("More models"), paint: "var(--bt-us-more)" });
+- }
+- const repeats = /* @__PURE__ */ new Map();
+- for (const entry of series2) {
+- const seen = repeats.get(entry.paint) ?? 0;
+- repeats.set(entry.paint, seen + 1);
+- entry.fill = soften(entry.paint, [1, 0.6, 0.36][seen % 3]);
+- }
++ setInviteLink(link);
++ setFixNote(t("Could not copy the invite link for {name}. Copy the link below and send it to your friend; once they accept, create the group again.", { name: bot.name }));
+ }
+- const tipSeries2 = face2 ? PARTS.map(([key, strength]) => ({ key, name: partLabel(key), fill: soften(face2.paint, strength) })) : series2;
+- const fillOf2 = new Map(series2.map((entry) => [entry.key, entry.fill]));
+- const shownKeys = new Set(series2.map((entry) => entry.key));
+- const columns2 = part2.buckets.map((bucket) => {
+- const parts = face2 ? [["all", bucket.sum.total]] : series2.map((entry) => [entry.key, entry.key === "more" ? [...bucket.stacks].filter(([key]) => key !== "rest" && !shownKeys.has(key)).reduce((sum, [, value]) => sum + value, 0) : bucket.stacks.get(entry.key) ?? 0]);
+- const tip = face2 ? PARTS.map(([key]) => [key, bucket.sum[key]]) : parts;
+- return { key: bucket.key, tick: tickOf(range, bucket), title: titleOf(range, bucket), total: bucket.sum.total, parts, tip };
+- });
+- return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 };
+- }, [view, rangeId, span, by, focusId, roster]);
+- const root = (0, import_react37.useRef)(null);
+- (0, import_react37.useEffect)(() => {
+- root.current?.closest(".bt-settings-scroll")?.scrollTo?.({ top: 0 });
+- }, [focusId]);
+- if (!view) {
+- return error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react37.createElement)(Skeleton2);
+- }
+- if (view.rows.length === 0) {
+- return (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us" },
+- (0, import_react37.createElement)(
+- "div",
+- { className: "bt-us-empty" },
+- (0, import_react37.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }),
+- (0, import_react37.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")),
+- (0, import_react37.createElement)("span", null, t("Token usage shows up here after a model is called."))
+- )
+- );
+- }
+- const { faces, models, ownerAt, face, keep, all, part, series, tipSeries, fillOf, columns, ranked, isBot, activeDays, oldest } = shaped;
+- const pick2 = (next) => actions.openSettings("usage", `${next.kind}:${next.key}`);
+- const chooseRange = (id) => {
+- if (id !== "custom") {
+- setRangeId(id);
+- setPicking(false);
++ };
++ const pick2 = (bot) => {
++ setPicked((current) => [...current, bot.id]);
++ setQuery("");
++ setCursor(0);
++ inputRef.current?.focus();
++ };
++ const chooseAdmin = (bot) => {
++ setAdmin(bot.id);
++ setQuery("");
++ setCursor(0);
++ inputRef.current?.focus();
++ };
++ const openBot = (bot) => run(async () => {
++ if (choosingAdmin) {
++ chooseAdmin(bot);
+ return;
+ }
+- if (span) setRangeId("custom");
+- setPicking((open) => !open);
++ if (group || picked.length > 0) {
++ pick2(bot);
++ return;
++ }
++ if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
++ if (message.trim()) await actions.send(bot.id, message.trim());
++ actions.openSession(bot.id);
++ close();
++ });
++ const createBot = () => {
++ if (busy) return;
++ setError("");
++ setDraft({ name: query.trim() !== "" && !exact ? query.trim() : "", fromList: true });
+ };
+- const pickSpan = (from, to) => {
+- setSpan({ from, to });
+- setRangeId("custom");
+- setPicking(false);
++ const created = (bot) => {
++ close();
++ return afterCreate({ id: bot.id, text: message, actions });
+ };
+- const rangeOptions = [
+- ...RANGES.map((item) => [item.id, rangeLabel(item.id)]),
+- ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon]
+- ];
+- const whole = part.total.total;
+- const ownerRow = ([at, sum], index, total = whole) => (0, import_react37.createElement)(Row3, {
+- key: `o${at}`,
+- kind: faces[at].kind,
+- glyph: faces[at].glyph(32),
+- name: faces[at].name,
+- sub: faces[at].sub,
+- sum,
+- whole: total,
+- index,
+- fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint),
+- onClick: () => pick2({ kind: "owner", key: view.owners[at].key })
+- });
+- const modelRow = ([at, sum], index) => (0, import_react37.createElement)(Row3, {
+- key: `m${at}`,
+- kind: "model",
+- glyph: models[at].glyph(32),
+- name: models[at].name,
+- sub: models[at].sub,
+- sum,
+- whole,
+- index,
+- fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint),
+- onClick: () => pick2({ kind: "model", key: view.models[at].key })
+- });
+- const nothing = (0, import_react37.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range."));
+- let body;
+- if (face) {
+- const kinds = ranked(part.kinds);
+- const usedModels = ranked(part.models);
+- const owners = ranked(part.owners);
+- body = [
+- (0, import_react37.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }),
+- (0, import_react37.createElement)(
+- "div",
+- { key: "sheet", className: "bt-us-sheet" },
+- (0, import_react37.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }),
+- (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }),
+- (0, import_react37.createElement)(
+- "div",
+- { key: "pair", className: "bt-us-pair" },
+- ownerAt >= 0 ? (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react37.createElement)(ListCard, { title: t("Who used it"), count: owners.length, index: 2 }, owners.length > 0 ? owners.map((entry, index) => ownerRow(entry, index)) : nothing),
+- (0, import_react37.createElement)(
+- ListCard,
+- { title: t("What for"), index: 3 },
+- kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react37.createElement)(Row3, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing
+- )
+- ),
+- (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 })
+- )
++ const makeGroup = async () => {
++ if (admin === null || picked.length === 0) return;
++ const text2 = message.trim();
++ const { members, remotes } = splitRoomPicks(roster, [admin, ...picked]);
++ const participants = remotes.length === 0 ? void 0 : [
++ ...members.map((id) => ({ kind: "bot", id, role: id === admin ? "admin" : "member" })),
++ ...remotes
+ ];
+- } else {
+- const owners = ranked(all.owners);
+- const bots = owners.filter(([at]) => isBot(at));
+- const rest = owners.filter(([at]) => !isBot(at));
+- const usedModels = ranked(all.models);
+- body = [
+- (0, import_react37.createElement)(
+- "div",
+- { key: "sheet", className: "bt-us-sheet" },
+- (0, import_react37.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }),
+- (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }),
+- (0, import_react37.createElement)(
+- "div",
+- { key: "pair", className: "bt-us-pair" },
+- (0, import_react37.createElement)(
+- ListCard,
+- { title: t("Bots"), count: bots.length, index: 2 },
+- owners.length > 0 ? null : nothing,
+- bots.map((entry, index) => ownerRow(entry, index)),
+- bots.length > 0 && rest.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-us-split", role: "separator" }) : null,
+- rest.map((entry, index) => ownerRow(entry, bots.length + index))
+- ),
+- (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing)
+- ),
+- (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 })
+- )
++ const room2 = await actions.createRoom(members, { admin, ...participants ? { participants } : {} });
++ if (text2) await actions.send(room2.id, text2);
++ actions.openSession(room2.id);
++ close();
++ };
++ const createGroup = () => run(makeGroup);
++ const sendMessage = () => run(async () => {
++ const text2 = message.trim();
++ if (group && admin !== null) {
++ await makeGroup();
++ return;
++ }
++ if (text2 === "" || picked.length === 0) return;
++ if (picked.length === 1) {
++ await actions.send(picked[0], text2);
++ actions.openSession(picked[0]);
++ close();
++ return;
++ }
++ const { members, remotes } = splitRoomPicks(roster, picked);
++ const participants = remotes.length === 0 ? void 0 : [
++ ...members.map((id) => ({ kind: "bot", id })),
++ ...remotes
+ ];
++ const room2 = await actions.createRoom(members, participants ? { participants } : {});
++ await actions.send(room2.id, text2);
++ actions.openSession(room2.id);
++ close();
++ });
++ const menuOnly = mode === "new" && !compose && !group && picked.length === 0 && wanted === "";
++ const options = [];
++ if (!group && picked.length === 0 && mode !== "search" && wanted === "") {
++ if (menuOnly) {
++ options.push({ key: "chat", icon: (0, import_react34.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => {
++ setCompose(true);
++ setCursor(0);
++ setTimeout(() => inputRef.current?.focus());
++ } });
++ }
++ options.push({ key: "new", icon: (0, import_react34.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot });
++ options.push({ key: "group", icon: (0, import_react34.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => {
++ setGroup(true);
++ setCompose(true);
++ setCursor(0);
++ inputRef.current?.focus();
++ } });
++ for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
++ options.push({
++ key: action.key,
++ icon: action.icon ? (0, import_react34.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react34.createElement)(PeopleIcon) }) : (0, import_react34.createElement)(PeopleIcon),
++ label: t(action.label),
++ hint: action.hint ? t(action.hint) : void 0,
++ run: () => {
++ const done = Promise.resolve().then(() => action.run({ close, actions, roster }));
++ done.then((note) => {
++ if (typeof note === "string" && note) actions.notify?.(note);
++ }).catch((failure) => setError(failure?.message ?? String(failure)));
++ }
++ });
++ }
++ if ((roster.agents ?? []).length === 0) {
++ options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
++ await actions.addAgent();
++ close();
++ }) });
++ }
+ }
+- return (0, import_react37.createElement)(
++ if (!menuOnly) {
++ for (const bot of matches) {
++ options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) });
++ }
++ }
++ if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react34.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot });
++ const active = Math.min(cursor, Math.max(options.length - 1, 0));
++ const listed = options.map((option) => option.key).join("\n");
++ const glide = useGlide(`${active}
++${listed}`, { axis: "y", ...QUICK_EDGES });
++ useFlip(glide.box, listed, "[data-flip]");
++ const drop = (0, import_react34.useRef)(null);
++ useMorph(drop, listed);
++ if (draft) {
++ return (0, import_react34.createElement)(
++ "div",
++ { className: "bt-pane", style: { left: paneLeft } },
++ (0, import_react34.createElement)(CreateBot, {
++ roster,
++ actions,
++ start: draft,
++ canGoBack: draft.fromList,
++ onBack: draft.fromList ? () => {
++ setDraft(null);
++ setTimeout(() => inputRef.current?.focus());
++ } : close,
++ onCreated: created
++ })
++ );
++ }
++ const adminBot = admin === null ? void 0 : roster.byId[admin];
++ const recipients = adminBot ? [adminBot, ...pickedBots] : pickedBots;
++ const placeholder = recipients.length > 0 ? t("Message {name}", { name: recipients.map((bot) => bot.name).join(t(" and ")) }) : t("Message Bot");
++ const unpick = () => {
++ if (picked.length > 0) setPicked((current) => current.slice(0, -1));
++ else if (admin !== null) setAdmin(null);
++ };
++ return (0, import_react34.createElement)(
+ "div",
+- { ref: root, className: "bt-us", key: focusId ?? "all" },
+- (0, import_react37.createElement)(
++ {
++ className: "bt-pane",
++ style: { left: paneLeft },
++ "data-menu": menuOnly || void 0,
++ onMouseDown: menuOnly ? (event) => {
++ if (event.target === event.currentTarget) close();
++ } : void 0
++ },
++ (0, import_react34.createElement)(
+ "div",
+- { className: "bt-us-top" },
+- (0, import_react37.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }),
+- view.scanning ? (0, import_react37.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null,
+- picking ? (0, import_react37.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null
+- ),
+- error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
+- body
++ { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 },
++ // The popover needs no title row: Escape or a press outside closes it.
++ menuOnly ? null : (0, import_react34.createElement)(
++ "div",
++ { className: "bt-to" },
++ (0, import_react34.createElement)("span", { className: "bt-to-label" }, t("To:")),
++ adminBot ? (0, import_react34.createElement)(
++ "span",
++ { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") },
++ (0, import_react34.createElement)(BotMark, { bot: adminBot, size: 16 }),
++ adminBot.name,
++ (0, import_react34.createElement)(AdminStar),
++ (0, import_react34.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×")
++ ) : null,
++ choosingAdmin ? (0, import_react34.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react34.createElement)(AdminStar)) : null,
++ pickedBots.map((bot) => (0, import_react34.createElement)(
++ "span",
++ { key: bot.id, className: "bt-token" },
++ (0, import_react34.createElement)(BotMark, { bot, size: 16 }),
++ bot.name,
++ (0, import_react34.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×")
++ )),
++ (0, import_react34.createElement)("input", {
++ ref: inputRef,
++ autoFocus: true,
++ value: query,
++ "aria-label": t("Recipient"),
++ placeholder: mode === "search" ? t("Search Bots…") : choosingAdmin ? t("Choose the group admin") : group ? t("Who to chat with…") : t("Start a chat with…"),
++ onChange: (event) => {
++ setQuery(event.target.value);
++ setCursor(0);
++ },
++ onKeyDown: (event) => {
++ if (event.nativeEvent.isComposing) return;
++ if (event.key === "ArrowDown") {
++ event.preventDefault();
++ setCursor(Math.min(active + 1, options.length - 1));
++ } else if (event.key === "ArrowUp") {
++ event.preventDefault();
++ setCursor(Math.max(active - 1, 0));
++ } else if (event.key === "Enter" && options[active]) {
++ event.preventDefault();
++ options[active].run();
++ } else if (event.key === "Backspace" && query === "") unpick();
++ }
++ }),
++ group && admin !== null ? (0, import_react34.createElement)("button", {
++ type: "button",
++ className: "bt-send bt-to-create",
++ disabled: busy || picked.length === 0,
++ title: picked.length === 0 ? t("Add at least one more Bot") : void 0,
++ onClick: createGroup
++ }, t("Create group chat")) : null,
++ (0, import_react34.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×")
++ ),
++ (0, import_react34.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react34.createElement)(
++ "div",
++ { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" },
++ (0, import_react34.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ options.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react34.createElement)(
++ "button",
++ {
++ key: option.key,
++ type: "button",
++ role: "option",
++ className: "bt-option",
++ "aria-selected": index === active,
++ "data-flip": option.key,
++ onMouseEnter: () => setCursor(index),
++ onClick: option.run
++ },
++ option.bot ? (0, import_react34.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react34.createElement)("span", { className: "bt-option-icon" }, option.icon),
++ // An action row's description sits under its title and is always there (#283): a long line
++ // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows.
++ // A Bot row's short hint stays beside the name and shows on the active row only.
++ option.bot || !option.hint ? (0, import_react34.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react34.createElement)("span", { className: "bt-option-copy" }, (0, import_react34.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react34.createElement)("span", { className: "bt-option-sub" }, option.hint)),
++ option.bot && option.hint && index === active ? (0, import_react34.createElement)("span", { className: "bt-option-hint" }, option.hint) : null
++ ))
++ )),
++ menuOnly ? null : (0, import_react34.createElement)("div", { className: "bt-nc-spacer" }),
++ error && fix ? (0, import_react34.createElement)(
++ "div",
++ { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" },
++ (0, import_react34.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })),
++ (0, import_react34.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [
++ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react34.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => {
++ void copyInvite(bot);
++ } }, t("Copy invite link for {name}", { name: bot.name })) : null,
++ (0, import_react34.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name }))
++ ])),
++ fixNote ? (0, import_react34.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null,
++ inviteLink ? (0, import_react34.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null
++ ) : error ? (0, import_react34.createElement)("div", { className: "bt-error" }, error) : null,
++ menuOnly ? null : (0, import_react34.createElement)(
++ "div",
++ { className: "bt-nc-compose" },
++ (0, import_react34.createElement)(
++ "div",
++ { className: "bt-nc-card" },
++ (0, import_react34.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react34.createElement)(PlusIcon)),
++ (0, import_react34.createElement)("textarea", {
++ rows: 1,
++ value: message,
++ placeholder,
++ "aria-label": placeholder,
++ onChange: (event) => setMessage(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
++ event.preventDefault();
++ sendMessage();
++ }
++ }
++ }),
++ (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
++ )
++ )
++ )
+ );
+ }
+-var USAGE_CSS = `
+-.bt-us{--bt-us-ease:cubic-bezier(.22,1,.36,1);--bt-us-rest:color-mix(in srgb,var(--bt-ink) 30%,var(--bt-card));--bt-us-more:color-mix(in srgb,var(--bt-ink) 16%,var(--bt-card));display:flex;flex-direction:column;gap:14px;padding-bottom:4px}
+-.bt-us-top{position:relative;z-index:4;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+-.bt-us-tabs{position:relative;display:inline-flex;flex:none;padding:3px;border-radius:999px;background:var(--bt-hover)}
+-.bt-us-tabs button{position:relative;z-index:1;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 12px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:26px;white-space:nowrap;cursor:pointer;transition:color .15s ease}
+-.bt-us-tabs button svg{flex:none;width:13px;height:13px;margin-left:-2px}
+-.bt-us-pick{position:absolute;top:calc(100% + 8px);left:0;max-width:100%;box-sizing:border-box;padding:14px 16px 12px;border-radius:18px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 22px 50px -18px rgba(0,0,0,.32),0 2px 8px rgba(0,0,0,.05);transform-origin:24px 0;animation:bt-us-drop .2s var(--bt-us-ease)}
+-.bt-us-pick-quick{display:flex;gap:6px;margin-bottom:12px}
+-.bt-us-pick-quick button{height:26px;padding:0 11px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease}
+-.bt-us-pick-quick button:hover{background:var(--bt-active);color:var(--bt-ink)}
+-.bt-us-pick-quick button[aria-pressed=true]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:var(--bt-accent)}
+-.bt-us-pick-months{display:flex;flex-wrap:wrap;gap:8px 28px}
+-.bt-us-month{width:252px;animation:bt-us-fade .22s ease}
+-.bt-us-month-head{position:relative;display:flex;align-items:center;justify-content:center;height:28px;margin-bottom:4px;font-size:13px;line-height:18px;font-weight:600}
+-.bt-us-month-nav{position:absolute;top:0;width:28px;height:28px;padding:0;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease}
+-.bt-us-month-nav[data-side=left]{left:0}
+-.bt-us-month-nav[data-side=right]{right:0}
+-.bt-us-month-nav:hover:not(:disabled){background:var(--bt-hover);color:var(--bt-ink)}
+-.bt-us-month-nav:disabled{opacity:.3;cursor:default}
+-.bt-us-weekdays,.bt-us-days{display:grid;grid-template-columns:repeat(7,36px)}
+-.bt-us-weekdays span{height:24px;font-size:11px;line-height:24px;text-align:center;color:var(--bt-ink-3)}
+-.bt-us-day{position:relative;height:36px;padding:0;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer}
+-.bt-us-day::before{content:'';position:absolute;top:4px;bottom:4px;left:0;right:0;transition:background-color .12s ease}
+-.bt-us-day>b{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-weight:400;transition:background-color .12s ease,color .12s ease}
+-.bt-us-day:hover:not(:disabled)>b{background:var(--bt-hover)}
+-.bt-us-day[data-today]>b{color:var(--bt-accent);font-weight:600}
+-.bt-us-day[data-has]::after{content:'';position:absolute;z-index:2;left:50%;bottom:6px;width:3px;height:3px;margin-left:-1.5px;border-radius:50%;background:var(--bt-ink-3)}
+-.bt-us-day[data-in]::before{background:color-mix(in srgb,var(--bt-accent) 13%,transparent)}
+-.bt-us-day[data-in][data-l]::before{left:4px;border-radius:14px 0 0 14px}
+-.bt-us-day[data-in][data-r]::before{right:4px;border-radius:0 14px 14px 0}
+-.bt-us-day[data-in][data-l][data-r]::before{border-radius:14px}
+-.bt-us-day[data-in][data-start]::before{left:50%;border-radius:0}
+-.bt-us-day[data-in][data-end]::before{right:50%;border-radius:0}
+-.bt-us-day[data-in][data-start][data-r]::before,.bt-us-day[data-in][data-end][data-l]::before,.bt-us-day[data-in][data-start][data-end]::before{display:none}
+-.bt-us-day[data-start]>b,.bt-us-day[data-end]>b,.bt-us-day[data-start]:hover>b,.bt-us-day[data-end]:hover>b{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
+-.bt-us-day[data-start][data-has]::after,.bt-us-day[data-end][data-has]::after{background:color-mix(in srgb,var(--bt-accent-ink,#fff) 75%,transparent)}
+-.bt-us-day:disabled{color:var(--bt-ink-3);opacity:.45;cursor:default}
+-.bt-us-pick-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:8px;padding-top:10px;border-top:1px solid var(--bt-line);font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap}
+-.bt-us-pick-foot b{color:var(--bt-ink);font-weight:500}
+-.bt-us-tabs button:hover,.bt-us-tabs button[aria-pressed=true]{color:var(--bt-ink)}
+-.bt-us-tabs:not([data-glide]) button[aria-pressed=true]{background:var(--bt-card)}
+-.bt-us-tabs>.bt-thumb{top:3px;bottom:3px;border-radius:999px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08),0 0 0 .5px var(--bt-line-2)}
+-.bt-us-note{display:inline-flex;align-items:center;gap:8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+-.bt-us-note::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--bt-accent);animation:bt-us-pulse 1.4s ease-in-out infinite}
+-.bt-us-sheet{display:flex;flex-direction:column;gap:4px}
+-.bt-us-sheet>.bt-us-stats,.bt-us-sheet>.bt-us-card,.bt-us-sheet>.bt-us-pair>.bt-us-card{border-radius:0}
+-.bt-us-sheet>:first-child{border-radius:16px 16px 0 0}
+-.bt-us-sheet>:last-child{border-radius:0 0 16px 16px}
+-.bt-us-stats{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards}
+-.bt-us-stat{display:flex;flex-direction:column;justify-content:flex-end;gap:6px;min-width:0;padding:16px 18px}
+-.bt-us-stat+.bt-us-stat{border-left:1px solid var(--bt-line)}
+-.bt-us-stat-label{overflow:hidden;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;text-overflow:ellipsis}
+-.bt-us-stat-value{display:flex;align-items:baseline;gap:6px;min-width:0;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums;white-space:nowrap}
+-.bt-us-stat-main .bt-us-stat-value{font-size:34px;line-height:40px;letter-spacing:-.025em}
+-.bt-us-stat-value small{font-size:13px;font-weight:400;letter-spacing:0;color:var(--bt-ink-3)}
+-.bt-us-delta{align-self:center;margin-left:2px;padding:0 8px;border-radius:999px;background:var(--bt-tag-bg);color:var(--bt-ink-2);font-size:11px;line-height:20px;font-weight:500;letter-spacing:0}
+-.bt-us-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 18px 16px;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 45ms)}
+-.bt-us-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px}
+-.bt-us-card-head h3{display:flex;align-items:baseline;gap:8px;min-width:0;margin:0;font-size:13px;line-height:18px;font-weight:600}
+-.bt-us-card-head h3 span,.bt-us-head-note{font-size:12px;font-weight:400;color:var(--bt-ink-3)}
+-.bt-us-chart-body{display:flex;flex-direction:column;gap:8px;padding-top:6px}
+-.bt-us-plot{position:relative;height:200px;margin-left:40px}
+-.bt-us-grid{position:absolute;left:0;right:0;border-top:1px dashed var(--bt-line-2);pointer-events:none}
+-.bt-us-grid.bt-us-base{border-top:1px solid var(--bt-line-2)}
+-.bt-us-grid span{position:absolute;right:calc(100% + 8px);top:-8px;font-size:10px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
+-.bt-us-cols{position:absolute;inset:0;display:flex;align-items:flex-end}
+-.bt-us-col{position:relative;flex:1 1 0;min-width:0;height:100%;display:flex;align-items:flex-end;justify-content:center}
+-.bt-us-stack{display:flex;flex-direction:column-reverse;gap:2px;width:56%;min-width:4px;max-width:28px;transform-origin:50% 100%;animation:bt-us-grow .6s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 14ms);transition:height .45s var(--bt-us-ease),opacity .18s ease}
+-.bt-us-stack>i{flex:1 1 0;min-height:2px;border-radius:5px;transition:flex-grow .45s var(--bt-us-ease)}
+-.bt-us-plot[data-hover] .bt-us-col:not([data-on]) .bt-us-stack{opacity:.3}
+-.bt-us-stub{width:4px;height:4px;margin-bottom:-2px;border-radius:50%;background:var(--bt-line-2)}
+-.bt-us-ticks{display:flex;margin-left:40px;font-size:11px;line-height:14px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
+-.bt-us-ticks span{flex:1 1 0;min-width:0;text-align:center;white-space:nowrap}
+-.bt-us-ticks span[data-now]{color:var(--bt-ink);font-weight:500}
+-.bt-us-tip{position:absolute;top:6px;z-index:3;min-width:160px;max-width:240px;padding:10px 12px;border-radius:12px;border:.5px solid var(--bt-line-2);background:color-mix(in srgb,var(--bt-card) 90%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 14px 34px -12px rgba(0,0,0,.28);font-size:12px;line-height:16px;pointer-events:none;animation:bt-us-pop .16s var(--bt-us-ease)}
+-.bt-us-tip-title{color:var(--bt-ink-3)}
+-.bt-us-tip-total{margin:2px 0 8px;font-size:15px;line-height:20px;font-weight:600;font-variant-numeric:tabular-nums}
+-.bt-us-tip-total:last-child{margin-bottom:0}
+-.bt-us-tip-row{display:flex;align-items:center;gap:8px;color:var(--bt-ink-2)}
+-.bt-us-tip-row+.bt-us-tip-row{margin-top:5px}
+-.bt-us-tip-row span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-us-tip-row b{font-weight:500;color:var(--bt-ink);font-variant-numeric:tabular-nums}
+-.bt-us-legend{display:flex;flex-wrap:wrap;gap:6px}
+-.bt-us-key{display:inline-flex;align-items:center;gap:6px;min-width:0;height:24px;padding:0 10px 0 8px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:24px}
+-button.bt-us-key{cursor:pointer;transition:background-color .12s ease,color .12s ease}
+-button.bt-us-key:hover{background:var(--bt-active);color:var(--bt-ink)}
+-.bt-us-dot{flex:none;width:8px;height:8px;border-radius:50%}
+-.bt-us-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:4px}
+-.bt-us-rows{display:flex;flex-direction:column;margin:0 -8px}
+-.bt-us-row{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;padding:8px;border:0;border-radius:12px;background:none;color:var(--bt-ink);font:inherit;text-align:left;animation:bt-us-in .4s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 90ms)}
+-button.bt-us-row{cursor:pointer;transition:background-color .12s ease}
+-button.bt-us-row:hover{background:var(--bt-hover)}
+-.bt-us-row-glyph{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px}
+-.bt-us-row-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px}
+-.bt-us-row-line{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:13px;line-height:18px}
+-.bt-us-row-name{flex:0 1 auto;min-width:0;overflow:hidden;font-weight:500;text-overflow:ellipsis;white-space:nowrap}
+-.bt-us-row-sub{flex:0 1 auto;min-width:0;overflow:hidden;font-size:12px;color:var(--bt-ink-3);text-overflow:ellipsis;white-space:nowrap}
+-.bt-us-track{display:block;height:4px;border-radius:999px;background:var(--bt-hover);overflow:hidden}
+-.bt-us-track>span{display:block;height:100%;border-radius:inherit;transform-origin:0 50%;animation:bt-us-fill .7s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 140ms);transition:width .45s var(--bt-us-ease)}
+-.bt-us-row-num{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:1px;min-width:52px;font-variant-numeric:tabular-nums}
+-.bt-us-row-num b{font-size:13px;line-height:18px;font-weight:600}
+-.bt-us-row-num small{font-size:11px;line-height:14px;color:var(--bt-ink-3)}
+-.bt-us-split{height:1px;margin:6px 8px;background:var(--bt-line)}
+-.bt-us-none{padding:18px 8px;font-size:12px;color:var(--bt-ink-3);text-align:center}
+-.bt-us-glyph{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2)}
+-.bt-us-model{display:inline-flex;align-items:center;justify-content:center;border-radius:30%;font-weight:600;line-height:1}
+-.bt-us-profile{display:flex;align-items:center;gap:20px;padding:6px 4px 2px;animation:bt-us-in .45s var(--bt-us-ease) backwards}
+-.bt-us-ring{position:relative;flex:none;width:96px;height:96px}
+-.bt-us-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
+-.bt-us-ring>svg circle{fill:none;stroke-width:4}
+-.bt-us-ring-track{stroke:var(--bt-line)}
+-.bt-us-ring-arc{stroke-linecap:round;transition:stroke-dasharray .6s var(--bt-us-ease);animation:bt-us-ring 1s var(--bt-us-ease) .1s backwards}
+-.bt-us-ring-face{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
+-.bt-us-profile-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
+-.bt-us-profile-copy h3{margin:0;overflow:hidden;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;text-overflow:ellipsis;white-space:nowrap}
+-.bt-us-profile-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
+-.bt-us-profile-share{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+-.bt-us-heat{gap:8px}
+-.bt-us-heat-months,.bt-us-heat-grid{display:grid;grid-template-columns:repeat(${HEAT_WEEKS},1fr);gap:3px}
+-.bt-us-heat-months{height:14px;font-size:10px;line-height:14px;color:var(--bt-ink-3)}
+-.bt-us-heat-months span{white-space:nowrap}
+-.bt-us-heat-plot{position:relative}
+-.bt-us-heat-grid{grid-template-rows:repeat(7,auto);grid-auto-flow:column}
+-.bt-us-heat-grid .bt-us-cell[data-on]{outline:1.5px solid var(--bt-ink-2);outline-offset:1px}
+-.bt-us-heat-tip{min-width:0;white-space:nowrap}
+-.bt-us-cell{position:relative;aspect-ratio:1;border-radius:3px;background:var(--bt-hover);overflow:hidden}
+-.bt-us-heat-grid .bt-us-cell{animation:bt-us-cell .5s var(--bt-us-ease) backwards;animation-delay:calc(var(--w,0) * 7ms + 120ms)}
+-.bt-us-cell>i{position:absolute;inset:0}
+-.bt-us-cell[data-today]{outline:1.5px solid var(--bt-ink-3);outline-offset:1px}
+-.bt-us-cell[data-future]{visibility:hidden}
+-.bt-us-heat-scale{display:flex;align-items:center;justify-content:flex-end;gap:4px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
+-.bt-us-heat-scale .bt-us-cell{width:10px}
+-.bt-us-skel{border-radius:16px;background:linear-gradient(90deg,var(--bt-hover) 0%,var(--bt-active) 50%,var(--bt-hover) 100%);background-size:200% 100%;animation:bt-us-shine 1.4s ease-in-out infinite}
+-.bt-us-skel-sheet>.bt-us-skel:first-child{border-radius:16px 16px 0 0}
+-.bt-us-skel-sheet>.bt-us-skel:not(:first-child){border-radius:0}
+-.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:first-child{border-radius:0 0 0 16px}
+-.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:last-child{border-radius:0 0 16px 0}
+-.bt-us-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:72px 16px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
+-.bt-us-empty b{margin-top:6px;font-size:15px;font-weight:600;color:var(--bt-ink)}
+-@keyframes bt-us-in{from{opacity:0;transform:translateY(6px)}}
+-@keyframes bt-us-grow{from{transform:scaleY(0)}}
+-@keyframes bt-us-fill{from{transform:scaleX(0)}}
+-@keyframes bt-us-pop{from{opacity:0;transform:translateY(3px) scale(.97)}}
+-@keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}}
+-@keyframes bt-us-fade{from{opacity:0}}
+-@keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}}
+-@keyframes bt-us-ring{from{stroke-dasharray:0 ${Math.ceil(RING2)}}}
+-@keyframes bt-us-pulse{50%{opacity:.25}}
+-@keyframes bt-us-shine{from{background-position:200% 0}to{background-position:-200% 0}}
+-@media (prefers-reduced-motion:reduce){.bt-us *,.bt-us *::before{animation:none!important;transition:none!important}}
+-`;
+
+-// src/client/connectors.js
+-var import_react38 = require("react");
+-var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new";
+-var COMPOSIO_KEY_URL = "https://platform.composio.dev/";
+-var FEISHU_WEBHOOK_URL = "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot";
+-var APPS = [
+- ["gmail", "Gmail"],
+- ["googlecalendar", "Google Calendar"],
+- ["googledrive", "Google Drive"],
+- ["googledocs", "Google Docs"],
+- ["googlesheets", "Google Sheets"],
+- ["outlook", "Outlook"],
+- ["slack", "Slack"],
+- ["microsoft_teams", "Microsoft Teams"],
+- ["discord", "Discord"],
+- ["telegram", "Telegram"],
+- ["whatsapp", "WhatsApp"],
+- ["notion", "Notion"],
+- ["linear", "Linear"],
+- ["jira", "Jira"],
+- ["asana", "Asana"],
+- ["trello", "Trello"],
+- ["todoist", "Todoist"],
+- ["airtable", "Airtable"],
+- ["dropbox", "Dropbox"],
+- ["hubspot", "HubSpot"],
+- ["salesforce", "Salesforce"],
+- ["stripe", "Stripe"],
+- ["zoom", "Zoom"],
+- ["calendly", "Calendly"],
+- ["figma", "Figma"],
+- ["twitter", "X (Twitter)"],
+- ["youtube", "YouTube"]
+-];
+-var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug;
+-var GitHubMark = () => (0, import_react38.createElement)(
+- "svg",
+- { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true },
+- (0, import_react38.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" })
+-);
+-var FeishuMark = () => (0, import_react38.createElement)(
+- "svg",
+- { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
+- (0, import_react38.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }),
+- (0, import_react38.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }),
+- (0, import_react38.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" })
+-);
+-var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark };
+-var ABOUT = {
+- github: () => t("Repositories, issues, and pull requests"),
+- composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key"),
+- feishu: () => t("Posts into one Feishu group through a custom bot webhook")
+-};
+-function statusText2(connector) {
+- if (connector.state === "connecting") return t("Connecting…");
+- if (connector.state === "error") return t("Could not connect: {error}", { error: connector.error ?? "" });
+- if (connector.state === "ready" && connector.kind === "channel") {
+- return connector.mode === "app" ? t("Connected: app, over the long connection") : t("Connected: custom bot webhook");
++// src/client/exchange.js
++var import_react35 = require("react");
++var SHEET_EXIT_MS = 220;
++function exchangeText(text2) {
++ if (typeof text2 !== "string") return "";
++ const CLOSE = "";
++ let body = text2;
++ for (let previous; previous !== body; ) {
++ previous = body;
++ const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body;
++ body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : ""));
+ }
+- if (connector.state === "ready" && connector.apps?.length > 0) return t("Connected: {apps}", { apps: connector.apps.map(appName).join(", ") });
+- if (connector.state === "ready") return connector.account ? t("Connected as {account}", { account: connector.account }) : t("Connected");
+- return ABOUT[connector.id]?.() ?? "";
++ return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim();
+ }
+-function CredentialField({ credential, value, onChange, onEnter, autoFocus }) {
+- const label = t(credential.label);
+- if (credential.masked) return (0, import_react38.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus });
+- return (0, import_react38.createElement)(
+- "label",
+- { className: "bt-q-field" },
+- (0, import_react38.createElement)("input", {
+- className: "bt-input",
+- type: "text",
+- value,
+- "aria-label": label,
+- placeholder: label,
+- spellCheck: false,
+- autoFocus,
+- onChange: (event) => onChange(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Enter") onEnter?.();
+- }
+- })
++function ExchangeLine({ entry, roster, actions }) {
++ const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" };
++ return (0, import_react35.createElement)(
++ "div",
++ { className: "bt-group-msg" },
++ (0, import_react35.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name),
++ (0, import_react35.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react35.createElement)("span", { className: "bt-lead" }, (0, import_react35.createElement)(BotMark, { bot, size: 22 })) })
+ );
+ }
+-function AllowedSenders({ connector, actions, run, busy }) {
+- const [openId, setOpenId] = (0, import_react38.useState)("");
+- const allowed = connector.allowed ?? [];
+- const refused = connector.inbound?.refusedSenders ?? [];
+- const allow = (id) => void run(actions.connectorAllow(connector.id, id));
+- return (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-allowed" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")),
+- allowed.length === 0 && refused.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null,
+- allowed.map((entry) => (0, import_react38.createElement)(
+- "div",
+- { key: entry.openId, className: "bt-connector-person" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block"))
+- )),
+- refused.map((entry) => (0, import_react38.createElement)(
+- "div",
+- { key: entry.openId, className: "bt-connector-person" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow"))
+- )),
+- (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-form-row" },
+- (0, import_react38.createElement)("input", {
+- className: "bt-input",
+- type: "text",
+- value: openId,
+- spellCheck: false,
+- "aria-label": t("Feishu open_id"),
+- placeholder: t("Paste an open_id to allow"),
+- onChange: (event) => setOpenId(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Enter" && openId.trim() !== "") {
+- allow(openId.trim());
+- setOpenId("");
+- }
+- }
+- }),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => {
+- allow(openId.trim());
+- setOpenId("");
+- } }, t("Allow"))
++function ExchangeDialog({ pair, roster, actions }) {
++ const [entries2, setEntries] = (0, import_react35.useState)(null);
++ const [leaving, setLeaving] = (0, import_react35.useState)(false);
++ const close = () => {
++ if (leaving) return;
++ setLeaving(true);
++ setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS);
++ };
++ useEscape(close);
++ (0, import_react35.useEffect)(() => {
++ let stop = false;
++ let timer;
++ let seen = null;
++ const load = async () => {
++ const result = await actions.exchange(pair[0], pair[1]);
++ if (stop) return;
++ const key = JSON.stringify(result ?? []);
++ if (key !== seen) {
++ seen = key;
++ setEntries(result ?? []);
++ }
++ timer = setTimeout(load, document.hidden ? 8e3 : 2e3);
++ };
++ void load();
++ return () => {
++ stop = true;
++ clearTimeout(timer);
++ };
++ }, [pair[0], pair[1]]);
++ const left = roster.byId[pair[0]];
++ const right = roster.byId[pair[1]];
++ let lastDay = 0;
++ const paneLeft = usePaneLeft();
++ return (0, import_react35.createElement)(
++ "div",
++ { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 },
++ (0, import_react35.createElement)(
++ "div",
++ { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") },
++ (0, import_react35.createElement)("div", { className: "bt-head" }, (0, import_react35.createElement)(
++ "span",
++ { className: "bt-pill", style: { cursor: "default" } },
++ left ? (0, import_react35.createElement)(BotMark, { bot: left, size: 22 }) : null,
++ left?.name ?? "?",
++ (0, import_react35.createElement)("span", { className: "bt-swap" }, "⇄"),
++ right ? (0, import_react35.createElement)(BotMark, { bot: right, size: 22 }) : null,
++ right?.name ?? "?"
++ )),
++ (0, import_react35.createElement)(
++ "div",
++ { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") },
++ entries2 === null ? (0, import_react35.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react35.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => {
++ const showDay = entry.time - lastDay > 30 * 60 * 1e3;
++ lastDay = entry.time;
++ return (0, import_react35.createElement)(
++ import_react35.Fragment,
++ { key: entry.id },
++ showDay ? (0, import_react35.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null,
++ (0, import_react35.createElement)(ExchangeLine, { entry, roster, actions })
++ );
++ })
++ ),
++ (0, import_react35.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react35.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat")))
+ )
+ );
+ }
+-var APP_STATUS = { ready: () => t("Ready"), "sign-in": () => t("Needs sign-in") };
+-function AppChip({ name, status, onRemove }) {
+- const label = APP_STATUS[status]?.();
+- return (0, import_react38.createElement)(
+- "button",
+- { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove },
+- name,
+- label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null
+- );
+-}
+-function AppPicker({ apps, onChange, search, statuses = {} }) {
+- const [query, setQuery] = (0, import_react38.useState)("");
+- const [found, setFound] = (0, import_react38.useState)(null);
+- const [failed, setFailed] = (0, import_react38.useState)(null);
+- const names = (0, import_react38.useRef)(new Map(APPS));
+- const asked = (0, import_react38.useRef)(0);
+- (0, import_react38.useEffect)(() => {
+- const text2 = query.trim();
+- if (text2 === "" || search === void 0) {
+- setFound(null);
+- setFailed(null);
+- return void 0;
+- }
+- const ask = ++asked.current;
+- const timer = setTimeout(() => {
+- search(text2).then((list) => {
+- if (ask !== asked.current) return;
+- for (const app of list) names.current.set(app.slug, app.name);
+- setFound(list);
+- setFailed(null);
+- }, (failure) => {
+- if (ask !== asked.current) return;
+- setFound(null);
+- setFailed(failure?.message ?? String(failure));
++
++// src/client/voice.js
++var import_react36 = require("react");
++var VOICE_LABELS = {
++ listening: "Listening",
++ thinking: "Thinking",
++ speaking: "Speaking",
++ paused: "Paused",
++ setup: "Voice input needs its speech model",
++ unsupported: "Voice chat is not available in this browser"
++};
++var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" };
++function useMicLevel(enabled) {
++ const [level, setLevel] = (0, import_react36.useState)(0);
++ (0, import_react36.useEffect)(() => {
++ if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0;
++ let stream = null;
++ let audio = null;
++ let frame = 0;
++ let stopped = false;
++ void navigator.mediaDevices.getUserMedia({ audio: true }).then((media) => {
++ if (stopped) {
++ media.getTracks().forEach((track) => track.stop());
++ return;
++ }
++ stream = media;
++ audio = new AudioContext();
++ const analyser = audio.createAnalyser();
++ analyser.fftSize = 256;
++ audio.createMediaStreamSource(media).connect(analyser);
++ const data = new Uint8Array(analyser.frequencyBinCount);
++ let smooth = 0;
++ const tick = () => {
++ analyser.getByteTimeDomainData(data);
++ let sum = 0;
++ for (const value of data) sum += ((value - 128) / 128) ** 2;
++ smooth = smooth * 0.8 + Math.min(1, Math.sqrt(sum / data.length) * 4) * 0.2;
++ setLevel(Math.round(smooth * 100) / 100);
++ frame = requestAnimationFrame(tick);
++ };
++ tick();
++ }).catch(() => {
++ });
++ return () => {
++ stopped = true;
++ cancelAnimationFrame(frame);
++ stream?.getTracks().forEach((track) => track.stop());
++ void audio?.close().catch(() => {
+ });
+- }, 250);
+- return () => clearTimeout(timer);
+- }, [query, search]);
+- const nameOf = (slug) => names.current.get(slug) ?? appName(slug);
+- const picked = new Set(apps);
+- const needle = query.trim().toLowerCase();
+- const local = APPS.filter(([slug, name]) => needle === "" || slug.includes(needle) || name.toLowerCase().includes(needle)).map(([slug, name]) => ({ slug, name }));
+- const results = (found ?? local).filter((app) => !picked.has(app.slug));
+- return (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-apps" },
+- apps.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react38.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null,
+- (0, import_react38.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }),
+- failed ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null,
+- (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react38.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => {
+- names.current.set(app.slug, app.name);
+- onChange([...apps, app.slug]);
+- } }, app.name)))
+- );
+-}
+-function AppStatusList({ apps, statuses }) {
+- if (apps.length === 0 || Object.keys(statuses).length === 0) return null;
+- return (0, import_react38.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react38.createElement)(
+- "li",
+- { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)),
+- (0, import_react38.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "")
+- )));
++ setLevel(0);
++ };
++ }, [enabled]);
++ return level;
+ }
+-function ConnectorRow({ connector, roster, actions }) {
+- const [form, setForm] = (0, import_react38.useState)(false);
+- const [bots, setBots] = (0, import_react38.useState)(false);
+- const [token, setToken] = (0, import_react38.useState)("");
+- const [apps, setApps] = (0, import_react38.useState)(() => connector.apps ?? []);
+- const [picking, setPicking] = (0, import_react38.useState)(false);
+- const [busy, setBusy] = (0, import_react38.useState)(false);
+- const [error, setError] = (0, import_react38.useState)(null);
+- const [message, setMessage] = (0, import_react38.useState)("");
+- const run = (task, after) => {
+- setBusy(true);
+- setError(null);
+- return task.then(() => {
+- after?.();
+- }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false));
++function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus }) {
++ const bot = roster.byId[sessionId];
++ const room2 = roster.roomsById[sessionId];
++ const name = bot?.name ?? room2?.name ?? t("Bot");
++ const running = useSessionStatus((map) => map.get(sessionId)?.running === true);
++ const lastReply = useSessions((list) => {
++ const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline;
++ const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0;
++ return entry ? `${outline.length}|${entry.response ?? ""}` : "0|";
++ });
++ const [speech] = (0, import_react36.useState)(() => actions.speech());
++ const [phase, setPhase] = (0, import_react36.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported");
++ const [catalog, setCatalog] = (0, import_react36.useState)(null);
++ const [heard, setHeard] = (0, import_react36.useState)("");
++ const [said, setSaid] = (0, import_react36.useState)("");
++ const [note, setNote] = (0, import_react36.useState)("");
++ const [serviceLevel, setServiceLevel] = (0, import_react36.useState)(0);
++ const [cycle, setCycle] = (0, import_react36.useState)(0);
++ const waiting = (0, import_react36.useRef)(null);
++ const reply = (0, import_react36.useRef)(lastReply);
++ reply.current = lastReply;
++ const turn = (0, import_react36.useRef)(0);
++ const paneLeft = usePaneLeft();
++ const close = () => {
++ turn.current += 1;
++ window.speechSynthesis?.cancel();
++ actions.closeVoice();
+ };
+- const gateway = connector.id === "composio";
+- const sender = connector.kind === "sender";
+- const channel = connector.kind === "channel";
+- const modes = connector.modes ?? [];
+- const [mode, setMode] = (0, import_react38.useState)(() => connector.mode ?? modes[0]?.id);
+- const [values, setValues] = (0, import_react38.useState)({});
+- const chosen = modes.find((entry) => entry.id === mode) ?? modes[0];
+- const field = (name) => values[name] ?? "";
+- const setField = (name, value) => setValues((current) => ({ ...current, [name]: value }));
+- const filled = chosen !== void 0 && chosen.credentials.every((credential) => field(credential.name).trim() !== "");
+- const [statuses, setStatuses] = (0, import_react38.useState)({});
+- const appsKey = (connector.apps ?? []).join(",");
+- (0, import_react38.useEffect)(() => {
+- if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) {
+- setStatuses({});
+- return void 0;
+- }
++ useEscape(close);
++ const submit = (text2) => {
++ setHeard(text2);
++ setPhase("thinking");
++ waiting.current = reply.current.split("|")[0];
++ void actions.send(sessionId, text2.trim()).catch(() => setPhase("listening"));
++ };
++ const dictation = useDictation((text2, final) => {
++ setHeard(text2);
++ if (!final) return;
++ dictation.stop();
++ submit(text2);
++ });
++ (0, import_react36.useEffect)(() => {
++ if (!note) return void 0;
++ const timer = setTimeout(() => setNote(""), 2600);
++ return () => clearTimeout(timer);
++ }, [note]);
++ (0, import_react36.useEffect)(() => {
++ if (!speech) return void 0;
+ let live = true;
+- actions.connectorAppStatus(connector.id).then((value) => {
+- if (live) setStatuses(value ?? {});
++ speechCatalog(speech).then(({ catalog: next, usable }) => {
++ if (!live) return;
++ setCatalog(next);
++ setPhase(usable ? "listening" : "setup");
+ }, () => {
+- if (live) setStatuses({});
++ if (live) setPhase("setup");
+ });
+ return () => {
+ live = false;
+ };
+- }, [gateway, connector.state, appsKey, picking]);
+- const search = (0, import_react38.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]);
+- const connect = () => {
+- if (busy) return;
+- if (channel) {
+- if (chosen === void 0 || !filled) return;
+- const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)]));
+- void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => {
+- setValues({});
+- setForm(false);
+- });
+- return;
+- }
+- if (token.trim() === "") return;
+- if (gateway && apps.length === 0) {
+- setError(t("Pick at least one app"));
+- return;
+- }
+- void run(actions.connectorConnect(connector.id, token, gateway ? apps : void 0), () => {
+- setToken("");
+- setForm(false);
+- });
+- };
+- const saveApps = () => {
+- if (busy) return;
+- if (apps.length === 0) {
+- setError(t("Pick at least one app"));
++ }, [speech]);
++ const listen = phase === "listening";
++ (0, import_react36.useEffect)(() => {
++ if (speech || !listen || dictation.listening) return void 0;
++ const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => {
++ } }), 250);
++ return () => clearTimeout(timer);
++ }, [listen, dictation.listening]);
++ (0, import_react36.useEffect)(() => {
++ if (phase !== "listening") dictation.stop();
++ }, [phase]);
++ (0, import_react36.useEffect)(() => {
++ if (!speech || !catalog || !listen) return void 0;
++ const own = turn.current;
++ let capture = null;
++ let frame = 0;
++ let stopped = false;
++ let smooth = 0;
++ const detect = turnDetector();
++ const limit = Math.max(1, catalog.maxDurationSeconds - 1) * 1e3;
++ const finish = async (current) => {
++ setPhase("transcribing");
++ try {
++ const wave = await current.finish(catalog.maxDurationSeconds);
++ const text2 = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : "";
++ if (turn.current !== own) return;
++ if (text2) submit(text2);
++ else setPhase("listening");
++ } catch (error) {
++ if (turn.current !== own) return;
++ setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
++ setPhase("listening");
++ }
++ };
++ openCapture().then((opened) => {
++ if (stopped) {
++ opened.cancel();
++ return;
++ }
++ capture = opened;
++ const began = performance.now();
++ const tick = () => {
++ const now = performance.now();
++ const level2 = capture.level();
++ smooth = smooth * 0.8 + level2 * 0.2;
++ setServiceLevel(Math.round(smooth * 100) / 100);
++ const state = detect(level2, now);
++ if (state === "done" || state === "speech" && now - began >= limit) {
++ const current = capture;
++ capture = null;
++ setServiceLevel(0);
++ void finish(current);
++ return;
++ }
++ if (state === "waiting" && now - began >= limit) {
++ setCycle((value) => value + 1);
++ return;
++ }
++ frame = requestAnimationFrame(tick);
++ };
++ tick();
++ }, (error) => {
++ if (stopped) return;
++ setNote(t(CAPTURE_NOTES[error?.kind] ?? "Couldn't hear that"));
++ setPhase("paused");
++ });
++ return () => {
++ stopped = true;
++ cancelAnimationFrame(frame);
++ capture?.cancel();
++ setServiceLevel(0);
++ };
++ }, [speech, catalog, listen, cycle]);
++ (0, import_react36.useEffect)(() => {
++ if (phase !== "thinking" || running || waiting.current === null) return;
++ const [count, text2] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)];
++ if (count === waiting.current || !text2.trim()) return;
++ waiting.current = null;
++ const spoken = speechOf(text2, botNames(roster));
++ setSaid(spoken);
++ setHeard("");
++ if (!window.speechSynthesis || !spoken) {
++ setPhase("listening");
+ return;
+ }
+- void run(actions.connectorApps(connector.id, apps), () => setPicking(false));
++ const utterance = new SpeechSynthesisUtterance(spoken);
++ utterance.lang = /[\u3400-\u9fff]/.test(spoken) ? "zh-CN" : "en-US";
++ utterance.onend = () => setPhase((current) => current === "speaking" ? "listening" : current);
++ utterance.onerror = utterance.onend;
++ setPhase("speaking");
++ window.speechSynthesis.cancel();
++ window.speechSynthesis.speak(utterance);
++ }, [phase, running, lastReply]);
++ (0, import_react36.useEffect)(() => () => window.speechSynthesis?.cancel(), []);
++ const micLevel = useMicLevel(!speech && phase === "listening" && dictation.listening);
++ const level = speech ? serviceLevel : micLevel;
++ const markState = phase === "thinking" || phase === "transcribing" ? "thinking" : phase === "speaking" ? "sending" : "idle";
++ const blocked = phase === "unsupported" || phase === "setup" || phase === "starting";
++ const togglePause = () => {
++ if (phase === "paused") {
++ setPhase("listening");
++ return;
++ }
++ turn.current += 1;
++ window.speechSynthesis?.cancel();
++ setPhase("paused");
+ };
+- const on = connector.state !== "off";
+- const canPost = sender || channel && connector.mode === "webhook";
+- const perBot = !sender && (!channel || connector.mode === "app");
+- const Mark = MARKS[connector.id];
+- const row = (0, import_react38.useRef)(null);
+- useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`);
+- return (0, import_react38.createElement)(
++ const openSetup = phase === "setup" ? actions.voiceSetup() : null;
++ const caption = phase === "speaking" ? said : heard;
++ const shownNote = note || dictation.note;
++ return (0, import_react36.createElement)(
+ "div",
+- { ref: row, className: "bt-connector", "data-state": connector.state },
+- (0, import_react38.createElement)(
++ { className: "bt-pane bt-voice-pane", style: { left: paneLeft } },
++ (0, import_react36.createElement)(
+ "div",
+- { className: "bt-connector-head" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react38.createElement)(Mark) : null),
+- (0, import_react38.createElement)(
+- "span",
+- { className: "bt-connector-text" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-name" }, connector.name),
+- (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector))
++ { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase },
++ (0, import_react36.createElement)(
++ "div",
++ { className: "bt-voice-orb", style: { "--bt-level": level } },
++ (0, import_react36.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }),
++ (0, import_react36.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }),
++ bot ? (0, import_react36.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react36.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null
+ ),
+- (0, import_react38.createElement)(
+- "span",
+- { className: "bt-connector-actions" },
+- connector.state === "error" ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null,
+- on && gateway && !picking ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+- setApps(connector.apps ?? []);
+- setPicking(true);
+- } }, t("Choose apps")) : null,
+- on && perBot ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null,
+- on ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect"))
+- )
+- ),
+- on && gateway && !picking ? (0, import_react38.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null,
+- on && perBot ? (0, import_react38.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null,
+- on && bots ? (0, import_react38.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null,
+- on && channel && connector.mode === "app" ? (0, import_react38.createElement)(AllowedSenders, { connector, actions, run, busy }) : null,
+- on && canPost ? (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-form" },
+- (0, import_react38.createElement)("label", { className: "bt-q-field" }, (0, import_react38.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })),
+- (0, import_react38.createElement)(
++ (0, import_react36.createElement)("div", { className: "bt-voice-name" }, name),
++ (0, import_react36.createElement)(
+ "div",
+- { className: "bt-connector-form-row" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-link" }),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send"))
+- )
+- ) : null,
+- !on && form ? (0, import_react38.createElement)(
++ { className: "bt-voice-status", role: "status" },
++ phase === "thinking" ? (0, import_react36.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react36.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : ""
++ ),
++ (0, import_react36.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption),
++ openSetup ? (0, import_react36.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => {
++ close();
++ openSetup();
++ } }, t("Set up voice input")) : null,
++ shownNote ? (0, import_react36.createElement)("div", { className: "bt-voice-note" }, shownNote) : null
++ ),
++ (0, import_react36.createElement)(
+ "div",
+- { className: "bt-connector-form" },
+- channel ? (0, import_react38.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react38.createElement)("button", {
+- key: entry.id,
++ { className: "bt-voice-bar" },
++ (0, import_react36.createElement)("button", {
+ type: "button",
+- className: "bt-state",
+- "aria-pressed": entry.id === chosen?.id,
+- onClick: () => {
+- setMode(entry.id);
+- setValues({});
+- setError(null);
+- }
+- }, t(entry.label)))) : null,
+- channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, {
+- key: credential.name,
+- credential,
+- value: field(credential.name),
+- onChange: (value) => setField(credential.name, value),
+- onEnter: connect,
+- autoFocus: index === 0
+- })) : (0, import_react38.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react38.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })),
+- gateway ? (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null,
+- (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-form-row" },
+- (0, import_react38.createElement)(
+- "a",
+- { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" },
+- channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub")
+- ),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+- setForm(false);
+- setToken("");
+- setValues({});
+- setError(null);
+- } }, t("Cancel")),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect"))
+- )
+- ) : null,
+- on && gateway && picking ? (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-form" },
+- (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }),
+- (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")),
+- (0, import_react38.createElement)(
+- "div",
+- { className: "bt-connector-form-row" },
+- (0, import_react38.createElement)("span", { className: "bt-connector-link" }),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
+- setPicking(false);
+- setError(null);
+- } }, t("Cancel")),
+- (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save"))
+- )
+- ) : null,
+- error ? (0, import_react38.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null
+- );
+-}
+-function ConnectorsPage({ roster, actions }) {
+- const list = roster?.connectors ?? [];
+- return (0, import_react38.createElement)(
+- "section",
+- { className: "bt-set-section" },
+- (0, import_react38.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react38.createElement)("div", { className: "bt-set-row" }, (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react38.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions })))
++ className: "bt-voice-btn",
++ "aria-pressed": phase === "paused",
++ disabled: blocked,
++ "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"),
++ onClick: togglePause
++ }, (0, import_react36.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react36.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null),
++ (0, import_react36.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react36.createElement)(CloseIcon))
++ )
+ );
+ }
+
+-// src/client/feedback.js
+-var import_react39 = require("react");
+-var FeedbackIcon = () => (0, import_react39.createElement)(
+- "svg",
+- { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+- (0, import_react39.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }),
+- (0, import_react39.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" })
+-);
+-function download(name, text2) {
+- const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" }));
+- const anchor = document.createElement("a");
+- anchor.href = url;
+- anchor.download = name;
+- anchor.click();
+- setTimeout(() => URL.revokeObjectURL(url), 1e3);
++// src/client/workflows.js
++var import_react37 = require("react");
++var READ_MS = 8e3;
++var POLL_MS6 = 2e3;
++var WORKFLOW_PLUGIN_HINT = "The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.";
++var COLORS = { Planner: "#8b5cf6", Writer: "#f0509a", Coder: "#1fbf6a", Reviewer: "#f5a524", Tester: "#14b8a6", Deployer: "#2f7cf6" };
++function workflowCallMissing(failure) {
++ const code = failure && typeof failure === "object" ? String(failure.code ?? "") : "";
++ const message = typeof failure === "string" ? failure : String(failure?.message ?? "");
++ return code === "bot/not-found" || /Unknown FlyBot endpoint workflow\//i.test(message);
+ }
+-var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false);
+-function whenText2(time) {
+- const date = new Date(time);
+- const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString();
+- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" });
+- return today ? clock4 : `${date.toLocaleDateString(dateLocale(), { month: "short", day: "numeric" })} ${clock4}`;
++function workflowCallError(failure, fallback = "Could not read workflows") {
++ if (workflowCallMissing(failure)) return WORKFLOW_PLUGIN_HINT;
++ if (typeof failure === "string" && failure.trim()) return failure.trim();
++ const message = typeof failure?.message === "string" ? failure.message.trim() : "";
++ return message || fallback;
+ }
+-function entrySummary(entry) {
+- if (entry.source === "turn") return failureLabel(entry.code, entry.text);
+- return entry.text;
++function settleWorkflowRead(value, fallback = "Could not read workflows") {
++ if (!value || value.ok === false || !Array.isArray(value.workflows)) {
++ const raw = typeof value?.error === "string" && value.error.trim() ? value.error : "";
++ const error = workflowCallMissing(value) || workflowCallMissing(raw) ? WORKFLOW_PLUGIN_HINT : raw || fallback;
++ return { phase: "error", error, pack: null };
++ }
++ const templates = Array.isArray(value.templates) ? value.templates : [];
++ const workflows = value.workflows;
++ const pack = { ...value, templates, workflows };
++ if (templates.length === 0 && workflows.length === 0) return { phase: "empty", error: "", pack };
++ return { phase: "ready", error: "", pack };
+ }
+-var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "This window" };
+-function FeedbackPage({ actions, run, Section, Row: Row4 }) {
+- const [what, setWhat] = (0, import_react39.useState)("");
+- const [report, setReport] = (0, import_react39.useState)(null);
+- const [preview, setPreview] = (0, import_react39.useState)(false);
+- const [note, setNote] = (0, import_react39.useState)("");
+- const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => {
+- setReport(next);
+- return next;
+- });
+- const load = () => fetchReport().then(() => void 0);
+- (0, import_react39.useEffect)(() => {
+- void run(load());
+- }, []);
+- const copyReport = () => run(fetchReport().then(async (next) => {
+- const copied = await copy(next.markdown);
+- setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it."));
+- if (!copied) setPreview(true);
+- }));
+- const [issueLink, setIssueLink] = (0, import_react39.useState)(null);
+- const openIssue = () => {
+- const win = window.open("", "_blank");
+- if (win !== null) win.opener = null;
+- run(fetchReport().then(async (next) => {
+- const copied = await copy(next.markdown);
+- const { url, inline } = issueUrl(next.issuesUrl, { what, diagnostics: next.markdown });
+- if (win !== null) {
+- win.location.href = url;
+- setIssueLink(null);
+- setNote(inline ? t("Opened a new GitHub issue with the diagnostics filled in. Check it before you submit.") : copied ? t("Opened a new GitHub issue. The diagnostics are long, so they are on the clipboard: paste them into the issue.") : t("Opened a new GitHub issue. Copy the diagnostics below into it."));
+- } else {
+- setIssueLink(url);
+- setNote(t("The browser blocked the new window."));
+- }
+- if (!inline && !copied) setPreview(true);
+- }));
+- };
+- const downloadLog = () => run(actions.diagnosticsLog().then((text2) => {
+- download("ds-bot-diagnostics.log", text2 || "No entries.\n");
+- setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it."));
+- }));
+- const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log."))));
+- const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : [];
+- const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time));
+- const total = (report?.total ?? 0) + browserErrors.length;
+- const shown = entries2.slice(0, 8);
+- return [
+- (0, import_react39.createElement)(
+- Section,
+- { key: "report", title: t("Report a problem") },
+- (0, import_react39.createElement)(
+- "div",
+- { className: "bt-set-row bt-set-paste bt-fb-what" },
+- (0, import_react39.createElement)(
+- "label",
+- { className: "bt-set-copy", htmlFor: "bt-fb-what" },
+- (0, import_react39.createElement)("span", null, t("What happened?")),
+- (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue."))
+- ),
+- (0, import_react39.createElement)("textarea", { id: "bt-fb-what", className: "bt-input bt-input-area", value: what, maxLength: WHAT_MAX, placeholder: t("For example: I asked Writer in the group chat and it showed an error."), onChange: (event) => setWhat(event.target.value) })
+- ),
+- (0, import_react39.createElement)(
+- Row4,
+- { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") },
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")),
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy"))
+- ),
+- preview ? (0, import_react39.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react39.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null,
+- (0, import_react39.createElement)(
+- Row4,
+- { label: t("GitHub issue"), hint: t("Opens a new issue in the FlyBot repository with all of this filled in. Nothing is sent until you submit it there.") },
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub"))
+- )
+- ),
+- note ? (0, import_react39.createElement)(
+- "div",
+- { key: "note", className: "bt-note bt-fb-note", role: "status" },
+- note,
+- issueLink ? [" ", (0, import_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null
+- ) : null,
+- (0, import_react39.createElement)(
+- Section,
+- { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") },
+- shown.length === 0 ? (0, import_react39.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react39.createElement)(
+- "div",
+- { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text },
+- (0, import_react39.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }),
+- (0, import_react39.createElement)(
+- "div",
+- { className: "bt-set-copy" },
+- (0, import_react39.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)),
+- (0, import_react39.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · "))
+- ),
+- entry.code ? (0, import_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null
+- )),
+- (0, import_react39.createElement)(
+- Row4,
+- { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") },
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")),
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear"))
+- )
+- )
+- ];
+-}
+-var TOAST_MS = 6e3;
+-function ErrorToast({ useUi, actions }) {
+- const toast = useUi((state) => state.toast);
+- const [leaving, setLeaving] = (0, import_react39.useState)(false);
+- (0, import_react39.useEffect)(() => {
+- if (!toast) return void 0;
+- setLeaving(false);
+- const fade = setTimeout(() => setLeaving(true), TOAST_MS);
+- const gone = setTimeout(() => actions.dismissToast(toast.token), TOAST_MS + 200);
+- return () => {
+- clearTimeout(fade);
+- clearTimeout(gone);
++function readWorkflows(call4, { timeoutMs = READ_MS } = {}) {
++ return new Promise((resolve) => {
++ let settled = false;
++ const finish = (value) => {
++ if (settled) return;
++ settled = true;
++ clearTimeout(timer);
++ resolve(value);
+ };
+- }, [toast?.token]);
+- if (!toast) return null;
+- const info = toast.tone === "info";
+- return (0, import_react39.createElement)(
+- "div",
+- { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 },
+- (0, import_react39.createElement)("span", { className: "bt-toast-text" }, toast.text),
+- info ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => {
+- actions.dismissToast(toast.token);
+- actions.openSettings("feedback");
+- } }, t("Details")),
+- (0, import_react39.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react39.createElement)(CloseIcon))
+- );
++ const timer = setTimeout(() => finish(settleWorkflowRead(null)), timeoutMs);
++ Promise.resolve().then(() => call4("workflow/state")).then((value) => finish(settleWorkflowRead(value))).catch((failure) => finish({ phase: "error", error: workflowCallError(failure), pack: null }));
++ });
+ }
+-var FEEDBACK_CSS = `
+-.bt-fb-what{cursor:default}
+-.bt-fb-what:hover,.bt-fb-entry:hover,.bt-fb-preview:hover{background:none}
+-.bt-fb-what .bt-input-area{min-height:80px}
+-.bt-fb-preview{cursor:default;padding:0}
+-.bt-fb-preview pre{margin:0;width:100%;max-height:240px;overflow:auto;padding:10px 12px;box-sizing:border-box;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink-2);white-space:pre-wrap;word-break:break-word;outline:none}
+-.bt-fb-primary{height:28px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);padding:0 14px;font-size:12px;cursor:pointer;transition:filter .12s ease}
+-.bt-fb-primary:hover{filter:brightness(1.08)}
+-.bt-fb-primary:active{filter:brightness(.94)}
+-.bt-fb-note{margin:-8px 4px 0}
+-.bt-fb-entry{cursor:default;align-items:flex-start}
+-.bt-fb-dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:#e02135}
+-.bt-fb-dot[data-level=warn]{background:#e3a008}
+-.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
+-.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
+-.bt-event-error{color:#e02135}
+-.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
+-.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
+-.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
+-.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
+-.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
+-.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
+-.bt-toast-close{flex:none}
+-@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
+-@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
+-`;
+-
+-// src/client/about.js
+-var import_react40 = require("react");
+-var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count);
+-function botHint(update) {
+- if (update.phase === "installing") return t("Updating FlyBot core…");
+- if (update.phase === "installed") return t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest });
+- if (update.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(update.error ?? "") });
+- if (update.available) return t("FlyBot core {version} is available", { version: update.latest });
+- if (update.enabled === false) return t("Update checks are off.");
+- if (update.source === "path") return t("Installed from a local folder; update it there.");
+- if (update.source === "git" && update.build?.branch !== void 0) return branchHint(update);
+- if (update.source === "git" && update.build?.pinned) return t("Pinned to commit {commit}. Updates come from the maintainer: reinstall FlyBot to get them.", { commit: update.build.commit });
+- if (!update.checkedAt) return null;
+- if (!update.latest && update.source !== "registry") return t("Updates are not offered for this install.");
+- const checked = t("Checked {time}", { time: whenText2(update.checkedAt) });
+- return `${update.latest ? t("Up to date") : t("Could not check for updates.")} · ${checked}`;
++function moveNode(nodes, id, x, y) {
++ return nodes.map((node) => node.id === id ? { ...node, x, y } : node);
+ }
+-function branchHint({ build, behind, checkedAt }) {
+- const where = [build.branch, build.commit].filter(Boolean).join(" ");
+- if (build.commit === void 0 || !checkedAt) return t("Installed from GitHub ({where}). Updates come from the maintainer: reinstall FlyBot to get them.", { where });
+- const checked = t("Checked {time}", { time: whenText2(checkedAt) });
+- const state = behind === null ? t("Could not compare with {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : behind === 0 ? t("Up to date with {branch}.", { branch: build.branch }) : behind === 1 ? t("1 commit behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : t("{count} commits behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { count: behind, branch: build.branch });
+- return `${state} · ${checked}`;
++function connectNodes(edges, from, to) {
++ if (!from || !to || from === to) return edges;
++ if (edges.some((edge) => edge.from === from && edge.to === to)) return edges;
++ return edges.concat([{ from, to }]);
+ }
+-var versionLine = (update) => [t("Version {version}", { version: update.current }), [update.build?.branch, update.build?.commit].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
+-function dshHint(dsh, shown) {
+- if (dsh.phase === "installing") return t("Updating DSH…");
+- if (dsh.phase === "installed") return t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified });
+- if (dsh.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(dsh.error ?? "") });
+- if (dsh.available === false && dsh.blockedBy) return t("Update FlyBot core {version} first.", { version: dsh.blockedBy });
+- if (shown) return t("DSH {version} is available", { version: dsh.verified });
+- return t("Version {version}", { version: dsh.current });
++function dropBot(nodes, bot, x, y) {
++ const ids = new Set(nodes.map((node) => node.id));
++ let n = nodes.length + 1;
++ while (ids.has(`n${n}`)) n += 1;
++ return nodes.concat([{ id: `n${n}`, name: bot.name, botId: bot.id, role: bot.role ?? "", x, y }]);
+ }
+-var linkProps = (href) => ({ className: "bt-soft bt-soft-sm bt-about-link", href, target: "_blank", rel: "noopener noreferrer" });
+-function AboutPage({ useUpdate, actions, run, Section, Row: Row4 }) {
+- const update = useUpdate((value) => value) ?? {};
+- const [checking, setChecking] = (0, import_react40.useState)(false);
+- const [stars, setStars] = (0, import_react40.useState)(null);
+- (0, import_react40.useEffect)(() => {
+- let live = true;
+- actions.repoStars().then((count) => {
+- if (live && Number.isInteger(count)) setStars(count);
+- }, () => {
+- });
+- return () => {
+- live = false;
+- };
+- }, []);
+- const check = () => {
+- setChecking(true);
+- void run(actions.checkUpdate()).then(() => {
+- setChecking(false);
+- });
++function removeNode(nodes, edges, id) {
++ return {
++ nodes: nodes.filter((node) => node.id !== id),
++ edges: edges.filter((edge) => edge.from !== id && edge.to !== id)
+ };
+- const phase = update.phase ?? "idle";
+- const dsh = update.dsh;
+- const dshPhase = dsh?.phase ?? "idle";
+- const dshNew = showsDshRow(dsh, update.app);
+- const dshShown = dshNew && dsh.available;
+- const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react40.createElement)(
+- "button",
+- { type: "button", className: "bt-fb-primary", disabled: phase === "installing", onClick: () => run(actions.installUpdate()) },
+- phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")
+- ) : (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: checking || update.enabled === false, onClick: check }, checking ? t("Checking…") : t("Check for updates"));
+- const dshControl = !dshNew || dshPhase === "installed" ? null : dsh.kind === "app" ? dsh.available ? (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.openAppUpdates(dsh.verified)) }, t("Open updates")) : null : dsh.available || dshPhase !== "idle" ? (0, import_react40.createElement)(
+- "button",
+- { type: "button", className: "bt-fb-primary", disabled: dshPhase === "installing", onClick: () => run(actions.installDshUpdate()) },
+- dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")
+- ) : null;
+- const repo = update.repoUrl;
+- return [
+- (0, import_react40.createElement)(
+- "div",
+- { key: "hero", className: "bt-about-hero" },
+- (0, import_react40.createElement)("span", { className: "bt-about-mark" }, (0, import_react40.createElement)(FlyBotMark, { size: 52 })),
+- (0, import_react40.createElement)(
+- "div",
+- { className: "bt-about-copy" },
+- (0, import_react40.createElement)("span", { className: "bt-about-name" }, t("FlyBot")),
+- update.current ? (0, import_react40.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null
+- ),
+- repo ? (0, import_react40.createElement)(
+- "a",
+- {
+- className: "bt-star",
+- href: repo,
+- target: "_blank",
+- rel: "noopener noreferrer",
+- title: t("Star FlyBot on GitHub"),
+- "aria-label": stars === null ? t("Star FlyBot on GitHub") : `${t("Star FlyBot on GitHub")} · ${t("{count} stars", { count: stars })}`
+- },
+- (0, import_react40.createElement)("span", { className: "bt-star-main" }, (0, import_react40.createElement)("span", { className: "bt-star-icon" }, (0, import_react40.createElement)(StarLineIcon)), "Star"),
+- stars === null ? null : (0, import_react40.createElement)("span", { className: "bt-star-count" }, starCount(stars))
+- ) : null
+- ),
+- repo ? (0, import_react40.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If FlyBot helps you, a star on GitHub helps more people find it.")) : null,
+- (0, import_react40.createElement)(
+- Section,
+- { key: "updates", title: t("Updates") },
+- (0, import_react40.createElement)(Row4, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null),
+- dsh?.current ? (0, import_react40.createElement)(Row4, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null
+- ),
+- (0, import_react40.createElement)(
+- Section,
+- { key: "links", title: t("Links") },
+- repo ? (0, import_react40.createElement)(Row4, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react40.createElement)("a", linkProps(repo), (0, import_react40.createElement)(GitHubMark), t("Open"))) : null,
+- update.notesUrl ? (0, import_react40.createElement)(Row4, { label: t("Release notes") }, (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null,
+- (0, import_react40.createElement)(Row4, { label: t("Report a problem") }, (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open")))
+- )
+- ];
+ }
+-var ABOUT_CSS = `
+-.bt-about-hero{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
+-.bt-about-mark{flex:none;display:inline-flex}
+-.bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
+-.bt-about-name{font-size:16px;line-height:22px;font-weight:600}
+-.bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums}
+-.bt-about-ask{margin:-10px 4px 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
+-.bt-star{flex:none;display:inline-flex;align-items:stretch;height:30px;border-radius:999px;border:1px solid var(--bt-line-2,var(--bt-line));overflow:hidden;color:var(--bt-ink);text-decoration:none;font-size:12px;line-height:28px;transition:transform .12s ease,border-color .15s ease}
+-.bt-star:hover{border-color:color-mix(in srgb,#e3a008 55%,var(--bt-line))}
+-.bt-star:active{transform:scale(.96)}
+-.bt-star-main{display:inline-flex;align-items:center;gap:6px;padding:0 12px 0 10px;background:var(--bt-hover);font-weight:500}
+-.bt-star-icon{display:inline-flex;color:var(--bt-ink-2);transition:color .15s ease}
+-.bt-star-icon path{transition:fill .15s ease}
+-.bt-star:hover .bt-star-icon{color:#e3a008;animation:bt-star-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
+-.bt-star:hover .bt-star-icon path{fill:currentColor}
+-.bt-star-count{display:inline-flex;align-items:center;padding:0 11px;border-left:1px solid var(--bt-line-2,var(--bt-line));color:var(--bt-ink-2);font-variant-numeric:tabular-nums;animation:bt-star-count-in ${SPRING_POP.duration}ms ${SPRING_POP.easing},bt-veil-in .2s ease-out}
+-@keyframes bt-star-pop{from{scale:.6;rotate:-30deg}}
+-@keyframes bt-star-count-in{from{translate:-6px 0}}
+-.bt-about-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none}
+-.bt-about-link svg{width:14px;height:14px}
+-.bt-fb-primary:disabled{opacity:.6;cursor:default;filter:none}
+-@media (prefers-reduced-motion:reduce){.bt-star:hover .bt-star-icon,.bt-star-count{animation:none}}
+-`;
+-
+-// src/client/settings.js
+-var import_react41 = require("react");
+-var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
+-var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
+-var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
+-function SetSection({ title, children }) {
+- return (0, import_react41.createElement)("section", { className: "bt-set-section" }, (0, import_react41.createElement)("h3", null, title), (0, import_react41.createElement)("div", { className: "bt-set-card" }, children));
++function chainOf(item) {
++ if (typeof item?.chain === "string" && item.chain) return item.chain;
++ if (!Array.isArray(item?.layers) || !Array.isArray(item?.nodes)) return "";
++ const name = (id) => item.nodes.find((node) => node.id === id)?.name ?? id;
++ return item.layers.map((layer) => layer.map(name).join(" ‖ ")).join(" → ");
+ }
+-function SetRow({ label, hint, children }) {
+- return (0, import_react41.createElement)(
+- "div",
+- { className: "bt-set-row" },
+- (0, import_react41.createElement)("div", { className: "bt-set-copy" }, (0, import_react41.createElement)("span", null, label), hint ? (0, import_react41.createElement)("span", { className: "bt-set-hint" }, hint) : null),
+- children ? (0, import_react41.createElement)("div", { className: "bt-set-control" }, children) : null
+- );
++function openedGraph(pack, opened) {
++ if (!pack || !opened) return null;
++ if (opened.source === "own") return (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
++ return (pack.templates ?? []).find((item) => item.id === opened.id) ?? (pack.workflows ?? []).find((item) => item.id === opened.id) ?? null;
+ }
+-var NOTIFY_KINDS = [
+- ["approval", "Waiting for approval", "Once more if it still waits after 15 min."],
+- ["finished", "Finished"],
+- ["failed", "Failed"]
+-];
+-function GeneralPage({ roster, actions, run }) {
+- const prefs = roster.prefs ?? {};
+- const themes = allThemes();
+- const current = themes[prefs.theme] ? prefs.theme : "deepseek";
+- const [custom, setCustom] = (0, import_react41.useState)(prefs.accent ?? "#4d6bfe");
+- const timer = (0, import_react41.useRef)(0);
+- (0, import_react41.useEffect)(() => () => clearTimeout(timer.current), []);
+- const pickCustom = (value) => {
+- setCustom(value);
+- clearTimeout(timer.current);
+- timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350);
+- };
+- return [
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "look", title: t("Appearance") },
+- (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Theme"), hint: t("Colors for the whole team, on every device.") },
+- (0, import_react41.createElement)(
+- "select",
+- { className: "bt-select", "aria-label": t("Theme"), value: current, onChange: (event) => run(actions.setPrefs({ theme: event.target.value })) },
+- Object.entries(themes).map(([id, theme]) => (0, import_react41.createElement)("option", { key: id, value: id }, t(theme.label)))
+- )
+- ),
+- (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") },
+- (0, import_react41.createElement)(
+- "div",
+- { className: "bt-swatches" },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-swatch bt-swatch-default", title: t("Theme accent"), "aria-label": t("Theme accent"), "aria-pressed": !prefs.accent, onClick: () => run(actions.setPrefs({ accent: null })) }),
+- ACCENT_INKS.map((ink) => (0, import_react41.createElement)("button", {
+- key: ink,
+- type: "button",
+- className: "bt-swatch",
+- title: t(capitalize2(ink)),
+- "aria-label": t(capitalize2(ink)),
+- "aria-pressed": prefs.accent === INKS[ink][0],
+- style: { background: INKS[ink][0] },
+- onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] }))
+- })),
+- (0, import_react41.createElement)(
+- "label",
+- { className: "bt-swatch bt-swatch-custom", title: t("Custom accent"), "data-on": Boolean(prefs.accent) && !ACCENT_INKS.some((ink) => INKS[ink][0] === prefs.accent) },
+- (0, import_react41.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) })
+- )
+- )
+- )
+- ),
+- // One switch for every system notification (#353), then one per kind (#469).
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "notify", title: t("Notifications") },
+- (0, import_react41.createElement)(SwitchRow, {
+- label: t("In other chats"),
+- hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."),
+- on: prefs.notifications !== false,
+- onChange: (on) => run(actions.setPrefs({ notifications: on }))
+- }),
+- NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react41.createElement)(SwitchRow, {
+- key: kind,
+- label: t(label),
+- hint: hint ? t(hint) : void 0,
+- disabled: prefs.notifications === false,
+- on: prefs.notify?.[kind] !== false,
+- onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } }))
+- }))
+- )
+- ];
++function statusLabel2(status) {
++ if (status === "running") return t("Running");
++ if (status === "done") return t("Done");
++ if (status === "failed") return t("Failed");
++ if (status === "idle") return t("Idle");
++ if (status === "skipped") return t("Skipped");
++ return t("Queued");
+ }
+-function MainBotsSection({ roster, actions, run }) {
+- const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean);
+- const [first, ...others] = mains;
+- const own = roster.bots.filter((bot) => !bot.remote);
+- const candidates = own.filter((bot) => !isMainOf(roster, bot.id));
+- return (0, import_react41.createElement)(
+- SetSection,
+- { title: t("Main Bot") },
+- (0, import_react41.createElement)(
+- SetRow,
+- {
+- label: t("Main Bot"),
+- hint: first ? t("First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.", { name: first.name }) : void 0
+- },
+- (0, import_react41.createElement)("select", {
+- className: "bt-select",
+- "aria-label": t("Main Bot"),
+- value: first?.id ?? "",
+- onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {}))
+- }, first ? null : (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
+- ),
+- others.map((bot) => (0, import_react41.createElement)(
+- SetRow,
+- { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t("Move to first")),
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role"))
+- )),
+- candidates.length > 0 ? (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Add a Main Bot"), hint: t("Main Bots can create and change Bots, and run every group chat.") },
+- (0, import_react41.createElement)("select", {
+- className: "bt-select",
+- "aria-label": t("Add a Main Bot"),
+- value: "",
+- onChange: (event) => {
+- if (event.target.value) void run(actions.setMain(event.target.value, true));
+- }
+- }, (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
+- ) : null
+- );
++function hitNode(event) {
++ const direct = event.target?.dataset?.node ? event.target : null;
++ const nested = event.target?.closest?.("[data-node]");
++ return (nested ?? direct)?.dataset?.node || "";
+ }
+-var MOTIONS = [["quiet", "Quiet"], ["normal", "Normal"], ["lively", "Lively"]];
+-function MotionSection({ roster, actions, run }) {
+- const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal";
+- return (0, import_react41.createElement)(
+- SetSection,
+- { title: t("Animation") },
+- (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Bot animation") },
+- (0, import_react41.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react41.createElement)("button", {
+- key: id,
+- type: "button",
+- "aria-pressed": motion === id,
+- onClick: () => {
+- if (motion !== id) run(actions.setPrefs({ motion: id }));
+- }
+- }, t(label))))
+- )
+- );
++var WIRE_SPAN = 480;
++var NODE_WIRE_OUT = 56;
++var CARD_HALF = 84;
++var CARD_H = 72;
++var FRAME_SIDE = 108;
++var FRAME_LABEL = 18;
++var FRAME_FOOT = 10;
++var MIN_FIT = 0.5;
++function wirePath(from, to) {
++ const x1 = from.x + WIRE_SPAN;
++ const y1 = from.y + NODE_WIRE_OUT;
++ const x2 = to.x + WIRE_SPAN;
++ const y2 = to.y;
++ const mid = (y1 + y2) / 2;
++ return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`;
+ }
+-function BotsPage({ roster, actions, run }) {
+- const [creating, setCreating] = (0, import_react41.useState)(false);
+- return [
+- (0, import_react41.createElement)(MainBotsSection, { key: "main", roster, actions, run }),
+- (0, import_react41.createElement)(MotionSection, { key: "motion", roster, actions, run }),
+- creating ? (0, import_react41.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react41.createElement)(
+- "div",
+- { key: "new", className: "bt-actions" },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("New Bot"))
+- ),
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) },
+- roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react41.createElement)(
+- "button",
+- { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) },
+- (0, import_react41.createElement)(
+- "span",
+- { className: "bt-set-bot" },
+- (0, import_react41.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }),
+- (0, import_react41.createElement)("span", { className: "bt-row-title" }, bot.name),
+- bot.role ? (0, import_react41.createElement)("span", { className: "bt-tag" }, bot.role) : null,
+- isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null,
+- bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
+- ),
+- (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon))
+- ))
+- )
+- ];
++function fitScale(nodes, width) {
++ const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
++ if (!(width > 0) || reach === 0) return 1;
++ return Math.min(1, Math.max(MIN_FIT, width / 2 / reach));
+ }
+-function GroupsPage({ roster, actions, run }) {
+- const [creating, setCreating] = (0, import_react41.useState)(false);
+- return [
+- creating ? (0, import_react41.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react41.createElement)(
+- "div",
+- { key: "new", className: "bt-actions" },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", disabled: roster.bots.length < 2, title: roster.bots.length < 2 ? t("A group chat needs at least two Bots") : void 0, onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("Create group chat"))
+- ),
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) },
+- roster.rooms.length === 0 ? (0, import_react41.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => {
+- const admin = roster.byId[room2.admin];
+- return (0, import_react41.createElement)(
+- "button",
+- { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) },
+- (0, import_react41.createElement)(
+- "span",
+- { className: "bt-set-bot" },
+- (0, import_react41.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
+- (0, import_react41.createElement)("span", { className: "bt-row-title" }, room2.name),
+- (0, import_react41.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })),
+- admin ? (0, import_react41.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react41.createElement)(AdminStar), admin.name) : null,
+- room2.hidden ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
+- ),
+- (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon))
+- );
+- })
+- )
+- ];
++function fitLayout(nodes, width) {
++ const reach = Math.max(0, ...(nodes ?? []).map((node) => Math.abs(node.x ?? 0) + FRAME_SIDE));
++ const scale = fitScale(nodes, width);
++ const spare = width > 0 && reach > 0 ? reach * scale - width / 2 : 0;
++ const pad2 = spare > 1e-6 ? Math.ceil(spare) : 0;
++ const centre = width > 0 ? pad2 + width / 2 : 0;
++ return {
++ reach,
++ scale,
++ pad: pad2,
++ left: centre - reach * scale,
++ right: centre + reach * scale,
++ scrollWidth: Math.max(width + pad2, centre + reach * scale)
++ };
+ }
+-function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) {
+- const [error, setError] = (0, import_react41.useState)("");
+- const close = (0, import_react41.useCallback)(() => actions.closeSettings(), []);
+- useEscape(close);
+- const run = (promise) => Promise.resolve(promise).then(() => {
+- setError("");
+- return true;
+- }).catch((failure) => {
+- setError(failure?.message ?? String(failure));
+- return false;
+- });
+- const bot = page === "bots" && id ? roster.byId[id] : void 0;
+- const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
+- const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` }));
+- const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]);
+- const shown = pages.some(([key]) => key === page) ? page : "general";
+- const pluginPage = extra.find(({ key }) => key === shown)?.entry;
+- const usageFocus = shown === "usage" && id ? id : null;
+- const label = t(pages.find(([key]) => key === shown)[1]);
+- const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null;
+- const nav = useGlide(shown, { axis: "y" });
+- const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`;
+- const depth = bot || room2 ? 1 : 0;
+- const last = (0, import_react41.useRef)({ key: pageKey, depth, move: "up" });
+- if (last.current.key !== pageKey) {
+- last.current = { key: pageKey, depth, move: depth > last.current.depth ? "in" : depth < last.current.depth ? "out" : "up" };
+- }
+- const [usageOpened, setUsageOpened] = (0, import_react41.useState)(shown === "usage");
+- if (shown === "usage" && !usageOpened) setUsageOpened(true);
+- const errorLine = error ? (0, import_react41.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
+- return (0, import_react41.createElement)(
+- "div",
+- { className: "bt-settings-layer", "data-leaving": leaving || void 0 },
+- (0, import_react41.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
+- (0, import_react41.createElement)(
+- "div",
+- { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 },
+- (0, import_react41.createElement)(
+- "nav",
+- { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") },
+- (0, import_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }),
+- pages.map(([key, text2]) => (0, import_react41.createElement)("button", {
+- key,
+- type: "button",
+- className: "bt-settings-item",
+- "aria-current": shown === key ? "page" : void 0,
+- onClick: () => actions.openSettings(key)
+- }, (0, import_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))),
+- // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot
+- // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421).
+- (0, import_react41.createElement)("button", {
+- type: "button",
+- className: "bt-settings-item bt-settings-shell",
+- title: t("Opens DeepSeek Harness's own settings (models, providers, permissions) in a separate window"),
+- "aria-label": t("Harness settings, opens DeepSeek Harness's own settings window"),
+- onClick: () => {
+- close();
+- openHarnessSettings();
+- }
+- }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗"))
+- ),
+- (0, import_react41.createElement)(
+- "div",
+- { className: "bt-settings-main" },
+- (0, import_react41.createElement)(
+- "div",
+- { className: "bt-settings-head" },
+- back ? (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react41.createElement)(ChevronLeftIcon)) : null,
+- (0, import_react41.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label)
+- ),
+- shown === "usage" ? null : (0, import_react41.createElement)(
+- "div",
+- { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move },
+- errorLine,
+- shown === "general" ? (0, import_react41.createElement)(GeneralPage, { roster, actions, run }) : null,
+- bot ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null,
+- shown === "bots" && !bot ? (0, import_react41.createElement)(BotsPage, { roster, actions, run }) : null,
+- room2 ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null,
+- shown === "groups" && !room2 ? (0, import_react41.createElement)(GroupsPage, { roster, actions, run }) : null,
+- shown === "projects" ? (0, import_react41.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null,
+- shown === "connectors" ? (0, import_react41.createElement)(ConnectorsPage, { roster, actions }) : null,
+- shown === "workflows" ? (0, import_react41.createElement)(WorkflowsPage, { call: call2, roster }) : null,
+- pluginPage ? (0, import_react41.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
+- shown === "capabilities" ? (0, import_react41.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
+- shown === "schedules" ? (0, import_react41.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
+- shown === "secrets" ? (0, import_react41.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
+- shown === "feedback" ? (0, import_react41.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null,
+- shown === "about" ? (0, import_react41.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null
+- ),
+- usageOpened ? (0, import_react41.createElement)(
+- "div",
+- { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true },
+- shown === "usage" ? errorLine : null,
+- (0, import_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus })
+- ) : null
+- ),
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon))
+- )
+- );
++function measureCanvasWidth(el) {
++ const width = el?.clientWidth;
++ return Number.isFinite(width) && width > 0 ? width : 0;
+ }
+-var openProjectBoard = (id) => {
+- if (typeof window !== "undefined") window.location.hash = `#project=${id}`;
+-};
+-var projectMembers = (roster) => [
+- ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })),
+- ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
+-];
+-function ProjectsList({ projects, roster, call: call4, reload }) {
+- const members = projectMembers(roster);
+- const bind = async (choice, project) => {
+- const [kind, id] = String(choice ?? "").split(":");
+- if (!kind || !id) return;
+- await call4("project-bind", { kind, id, projectId: project.id });
+- await reload?.();
+- };
+- const unbind = async (member) => {
+- await call4("project-bind", { kind: member.kind, id: member.id, projectId: null });
+- await reload?.();
++function GraphCanvas({ graph, editing, roster, onCommit, width: knownWidth = 0 }) {
++ const gesture = (0, import_react37.useRef)(null);
++ const canvasRef = (0, import_react37.useRef)(null);
++ const [measuredWidth, setCanvasWidth] = (0, import_react37.useState)(0);
++ const nodes = graph.nodes ?? [];
++ const edges = graph.edges ?? [];
++ const status = graph.run?.nodeStatus ?? null;
++ const locals = (roster?.bots ?? []).filter((bot) => bot && bot.remote !== true && !nodes.some((node) => node.botId === bot.id));
++ const height = Math.max(420, ...nodes.map((node) => (node.y ?? 0) + 150), 0);
++ const canvasWidth = measuredWidth || knownWidth;
++ const { scale, pad: pad2 } = fitLayout(nodes, canvasWidth);
++ const drawnHeight = Math.round(height * scale);
++ (0, import_react37.useEffect)(() => {
++ const el = canvasRef.current;
++ const measure = () => {
++ const width = measureCanvasWidth(el);
++ if (width > 0 && Math.abs(width - canvasWidth) > 0.5) setCanvasWidth(width);
++ };
++ measure();
++ if (typeof ResizeObserver !== "function" || !el) return void 0;
++ const observer = new ResizeObserver(measure);
++ observer.observe(el);
++ return () => observer.disconnect();
++ }, [canvasWidth]);
++ const end = (event) => {
++ const current = gesture.current;
++ gesture.current = null;
++ if (!current || !editing) return;
++ if (current.kind === "move") {
++ const x = Math.round(current.x + (event.clientX - current.ox) / scale);
++ const y = Math.round(current.y + (event.clientY - current.oy) / scale);
++ onCommit?.({ nodes: moveNode(nodes, current.id, x, y), edges });
++ } else if (current.kind === "wire") {
++ const next = connectNodes(edges, current.from, hitNode(event));
++ if (next !== edges) onCommit?.({ nodes, edges: next });
++ } else if (current.kind === "add" && event.target?.dataset?.plane) {
++ const x = Number.isFinite(event.offsetX) ? Math.round(event.offsetX / scale) : 24;
++ const y = Number.isFinite(event.offsetY) ? Math.round(event.offsetY / scale) : 24;
++ onCommit?.({ nodes: dropBot(nodes, current.bot, x, y), edges });
++ }
+ };
+- const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0);
+- return [
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "projects", title: t("Projects · {count}", { count: projects.length }) },
+- projects.length === 0 ? (0, import_react41.createElement)("div", { className: "bt-set-row" }, (0, import_react41.createElement)("div", { className: "bt-set-copy" }, (0, import_react41.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react41.createElement)(
+- SetRow,
+- {
+- key: project.id,
+- label: project.name,
+- hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir
+- },
+- (0, import_react41.createElement)(
++ return (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-canvas", ref: canvasRef, onPointerUp: end },
++ editing ? (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-palette" },
++ (0, import_react37.createElement)("span", { className: "bt-wf-hint" }, t("Drag a Bot here")),
++ locals.map((bot) => (0, import_react37.createElement)("button", {
++ key: bot.id,
++ type: "button",
++ "data-bot": bot.id,
++ onPointerDown: (event) => {
++ event.preventDefault?.();
++ gesture.current = { kind: "add", bot };
++ }
++ }, bot.name))
++ ) : null,
++ // A node's system prompt is the run Bot's prompt (plugins/workflow
++ // nodeConfig). The sidebar panel has this field, so the Settings editor
++ // writes the same graph instead of only drawing it (#1393).
++ editing ? (0, import_react37.createElement)("div", { className: "bt-wf-prompts" }, nodes.map((node) => (0, import_react37.createElement)(
++ "label",
++ { key: node.id, className: "bt-wf-prompt" },
++ (0, import_react37.createElement)("span", null, `${node.name} · ${t("System prompt")}`),
++ (0, import_react37.createElement)("textarea", {
++ "data-prompt": node.id,
++ rows: 3,
++ defaultValue: node.prompt || "",
++ onPointerDown: (event) => event.stopPropagation?.(),
++ onBlur: (event) => {
++ const prompt = event.target?.value ?? "";
++ if (prompt === (node.prompt || "")) return;
++ onCommit?.({ nodes: nodes.map((item) => item.id === node.id ? { ...item, prompt } : item), edges });
++ }
++ })
++ ))) : null,
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-stage", style: pad2 > 0 ? { height: drawnHeight, paddingLeft: pad2 } : { height: drawnHeight } },
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-fit", style: { height, transform: `scale(${scale})` } },
++ (0, import_react37.createElement)(
+ "div",
+- { className: "bt-project-row-controls" },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
+- (0, import_react41.createElement)(
+- "select",
++ { className: "bt-wf-plane", "data-plane": "1", style: { height, "--bt-wf-wire-span": `${WIRE_SPAN}px` } },
++ (0, import_react37.createElement)(
++ "svg",
+ {
+- className: "bt-select",
+- "aria-label": t("Bind to {name}", { name: project.name }),
+- value: "",
+- onChange: (event) => {
+- void bind(event.target.value, project);
+- }
++ className: "bt-wf-wires",
++ "aria-hidden": true,
++ width: WIRE_SPAN * 2,
++ height,
++ viewBox: `0 0 ${WIRE_SPAN * 2} ${height}`
+ },
+- (0, import_react41.createElement)("option", { value: "" }, t("Bind a Bot or group")),
+- members.map((member) => (0, import_react41.createElement)(
+- "option",
+- { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` },
+- `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}`
+- ))
+- )
++ (0, import_react37.createElement)(
++ "defs",
++ null,
++ (0, import_react37.createElement)("marker", {
++ id: "bt-wf-arrow",
++ viewBox: "0 0 10 10",
++ refX: "8",
++ refY: "5",
++ markerWidth: "6",
++ markerHeight: "6",
++ orient: "auto"
++ }, (0, import_react37.createElement)("path", { d: "M0 0 10 5 0 10z" }))
++ ),
++ edges.map((edge) => {
++ const from = nodes.find((node) => node.id === edge.from);
++ const to = nodes.find((node) => node.id === edge.to);
++ if (!from || !to) return null;
++ return (0, import_react37.createElement)("path", { key: `${edge.from}>${edge.to}`, d: wirePath(from, to), markerEnd: "url(#bt-wf-arrow)" });
++ })
++ ),
++ (graph.layers ?? []).filter((layer) => layer.length > 1).map((layer) => {
++ const members = layer.map((id) => nodes.find((node) => node.id === id)).filter(Boolean);
++ if (members.length < 2) return null;
++ const left = Math.min(...members.map((node) => node.x ?? 0)) - FRAME_SIDE;
++ const right = Math.max(...members.map((node) => node.x ?? 0)) + FRAME_SIDE;
++ const top = Math.min(...members.map((node) => node.y ?? 0)) - FRAME_LABEL;
++ const bottom = Math.max(...members.map((node) => node.y ?? 0)) + CARD_H + FRAME_FOOT;
++ return (0, import_react37.createElement)("div", {
++ key: layer.join("+"),
++ className: "bt-wf-par",
++ style: { left, top, width: right - left, height: bottom - top }
++ }, (0, import_react37.createElement)("div", { className: "bt-wf-plabel" }, t("Parallel branch, then join")));
++ }),
++ nodes.map((node) => {
++ const state = status ? status[node.id] ?? "queued" : "idle";
++ return (0, import_react37.createElement)(
++ "div",
++ {
++ key: node.id,
++ "data-node": node.id,
++ className: `bt-wf-node ${state}${editing ? " editing" : ""}`,
++ style: { left: (node.x ?? 0) - CARD_HALF, top: node.y ?? 0 },
++ onPointerDown: (event) => {
++ if (!editing || event.target?.dataset?.port || event.target?.dataset?.remove) return;
++ gesture.current = { kind: "move", id: node.id, ox: event.clientX, oy: event.clientY, x: node.x ?? 0, y: node.y ?? 0 };
++ }
++ },
++ editing ? (0, import_react37.createElement)("button", {
++ type: "button",
++ className: "bt-wf-port",
++ "data-port": "out",
++ "aria-label": node.name,
++ onPointerDown: (event) => {
++ event.preventDefault?.();
++ event.stopPropagation?.();
++ gesture.current = { kind: "wire", from: node.id };
++ }
++ }) : null,
++ editing ? (0, import_react37.createElement)("button", {
++ type: "button",
++ className: "bt-wf-remove",
++ "data-remove": node.id,
++ "aria-label": t("Remove"),
++ onClick: (event) => {
++ event.stopPropagation?.();
++ const next = removeNode(nodes, edges, node.id);
++ onCommit?.(next);
++ }
++ }, "×") : null,
++ (0, import_react37.createElement)("span", { className: `bt-wf-chip ${state}` }, statusLabel2(state)),
++ (0, import_react37.createElement)("span", { className: "bt-wf-av", style: { background: COLORS[node.name] ?? "#4d6bfe" } }, (node.name ?? "?").slice(0, 1)),
++ (0, import_react37.createElement)(
++ "span",
++ { className: "bt-wf-copy" },
++ (0, import_react37.createElement)("span", { className: "bt-wf-name" }, node.name),
++ (0, import_react37.createElement)("span", { className: "bt-wf-role" }, node.role ?? "")
++ )
++ );
++ })
+ )
+- ))
+- ),
+- bound2.length === 0 ? null : (0, import_react41.createElement)(
+- SetSection,
+- { key: "bindings", title: t("Bindings") },
+- bound2.map((member) => (0, import_react41.createElement)(
+- SetRow,
+- {
+- key: `${member.kind}:${member.id}`,
+- label: member.name,
+- hint: member.kind === "group" ? t("Group") : t("Bot")
+- },
+- (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+- void unbind(member);
+- } }, t("Unbind"))
+- ))
++ )
+ )
+- ];
++ );
+ }
+-function ProjectsPage({ roster, actions, run, call: call4 }) {
+- const [state, setState] = (0, import_react41.useState)({ projects: [], error: null });
+- const [name, setName] = (0, import_react41.useState)("");
+- const [workDir, setWorkDir] = (0, import_react41.useState)(null);
+- const reload = (0, import_react41.useCallback)(async () => {
+- const listing = await call4("projects");
+- setState({ projects: listing?.projects ?? [], error: null });
+- }, [call4]);
+- (0, import_react41.useEffect)(() => {
+- void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) }));
+- }, [reload]);
+- const create = async () => {
+- const trimmed3 = name.trim();
+- if (trimmed3 === "") return;
+- const body = { name: trimmed3 };
+- if (workDir !== null && workDir !== "") body.workDir = workDir;
+- try {
+- await call4("project-create", body);
+- setName("");
+- setWorkDir(null);
+- await reload();
+- } catch (error) {
+- setState({ projects: state.projects, error: error?.message ?? String(error) });
+- }
+- };
+- return [
+- state.error === null ? null : (0, import_react41.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
+- (0, import_react41.createElement)(
+- SetSection,
+- { key: "new", title: t("New Project") },
+- (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Name") },
+- (0, import_react41.createElement)("input", {
+- className: "bt-input",
+- "aria-label": t("Project name"),
+- value: name,
+- placeholder: t("e.g. Website"),
+- onChange: (event) => setName(event.target.value)
+- })
+- ),
+- (0, import_react41.createElement)(
+- SetRow,
+- { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") },
+- (0, import_react41.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })
+- ),
+- (0, import_react41.createElement)(
+- "div",
+- { className: "bt-actions" },
+- (0, import_react41.createElement)("button", {
+- type: "button",
+- className: "bt-primary bt-with-icon",
+- disabled: name.trim() === "",
+- onClick: () => {
+- void create();
+- }
+- }, (0, import_react41.createElement)(PlusIcon), t("New Project"))
+- )
++function Row2({ item, source, onOpen, onRename, onDelete }) {
++ return (0, import_react37.createElement)(
++ "button",
++ {
++ type: "button",
++ className: "bt-set-row",
++ "data-workflow": item.id,
++ "data-source": source,
++ onClick: () => onOpen?.(item)
++ },
++ (0, import_react37.createElement)(
++ "span",
++ { className: "bt-set-copy" },
++ (0, import_react37.createElement)("span", null, item.name),
++ (0, import_react37.createElement)("span", { className: "bt-set-hint" }, item.preview || chainOf(item) || item.description || "")
+ ),
+- // The list is rendered here rather than as a child element: it holds no state of its own,
+- // and the page keeps one reload path for both halves.
+- ...ProjectsList({ projects: state.projects, roster, call: call4, reload })
+- ];
+-}
+-
+-// src/client/context-menu.js
+-var import_react42 = require("react");
+-function ContextMenu({ menu, roster, actions, leaving = false }) {
+- useEscape(() => actions.closeMenu());
+- (0, import_react42.useEffect)(() => {
+- const onDown = (event) => {
+- if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu();
+- };
+- window.addEventListener("mousedown", onDown);
+- return () => window.removeEventListener("mousedown", onDown);
+- }, []);
+- const structure = useSidebarStructure();
+- const ref = (0, import_react42.useRef)(null);
+- const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y });
+- (0, import_react42.useLayoutEffect)(() => {
+- const box = ref.current?.getBoundingClientRect();
+- if (!box) return;
+- setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
+- }, [menu.x, menu.y]);
+- if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react42.createElement)(AgentMenu, { menu, roster, actions, leaving });
+- const bot = roster.byId[menu.id];
+- const room2 = roster.roomsById[menu.id];
+- const entry = bot ?? room2;
+- if (entry === void 0) return null;
+- const isMainBot = isMainOf(roster, menu.id);
+- const isTile = roster.mainBotId === menu.id;
+- const mainCount = (roster.mainBotIds ?? []).length;
+- const groups = bot?.remote ? [
+- [
+- [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
+- ],
+- [
+- [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)]
+- ],
+- pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]),
+- [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]]
+- ] : [
+- [
+- isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
+- [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
+- ],
+- [
+- // One way to edit a Bot (#385): its name is edited in Edit Bot too.
+- bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null,
+- room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null,
+- room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null,
+- bot ? [t("Duplicate"), DuplicateIcon, async () => {
+- const copy2 = await actions.duplicateBot(bot.id);
+- actions.openSession(copy2.id);
+- }] : null,
+- bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null,
+- bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null
+- ],
+- bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [],
+- [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]],
+- [
+- isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })],
+- isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true]
+- ]
+- ];
+- const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0);
+- return (0, import_react42.createElement)(
+- "div",
+- { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 },
+- shown.map((group, index) => (0, import_react42.createElement)(
+- import_react42.Fragment,
+- { key: index },
+- index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
+- group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", {
+- key: label,
++ source === "own" ? (0, import_react37.createElement)(
++ "span",
++ { className: "bt-set-actions" },
++ (0, import_react37.createElement)("button", {
+ type: "button",
+- role: "menuitem",
+- className: danger ? "bt-danger" : void 0,
+- onClick: () => {
+- actions.closeMenu();
+- void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
++ className: "bt-set-action",
++ "aria-label": t("Rename"),
++ title: t("Rename"),
++ onClick: (event) => {
++ event.stopPropagation();
++ onRename?.(item);
+ }
+- }, (0, import_react42.createElement)(Icon), label))
+- ))
+- );
+-}
+-function AgentMenu({ menu, roster, actions, leaving }) {
+- const ref = (0, import_react42.useRef)(null);
+- const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y });
+- (0, import_react42.useLayoutEffect)(() => {
+- const box = ref.current?.getBoundingClientRect();
+- if (!box) return;
+- setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
+- }, [menu.x, menu.y]);
+- const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId];
+- if (agent === void 0) return null;
+- const groups = menu.kind === "agent" ? [
+- [
+- [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })]
+- ],
+- [
+- [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true]
+- ]
+- ] : [
+- [
+- [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)]
+- ],
+- [
+- [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true]
+- ]
+- ];
+- return (0, import_react42.createElement)(
+- "div",
+- { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 },
+- groups.map((group, index) => (0, import_react42.createElement)(
+- import_react42.Fragment,
+- { key: index },
+- index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
+- group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", {
+- key: label,
++ }, "✎"),
++ (0, import_react37.createElement)("button", {
+ type: "button",
+- role: "menuitem",
+- className: danger ? "bt-danger" : void 0,
+- onClick: () => {
+- actions.closeMenu();
+- void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error));
++ className: "bt-set-action",
++ "aria-label": t("Delete"),
++ title: t("Delete"),
++ onClick: (event) => {
++ event.stopPropagation();
++ onDelete?.(item);
+ }
+- }, (0, import_react42.createElement)(Icon), label))
+- ))
++ }, "×")
++ ) : null,
++ (0, import_react37.createElement)("span", { className: "bt-chevron", "aria-hidden": true }, "›")
+ );
+ }
+-
+-// src/client/pane-width.js
+-var DEFAULT_WIDTH = 520;
+-var DETAILS_WIDTH = 380;
+-var DETAILS_MAX = 520;
+-var MIN_WIDTH = 360;
+-var MIN_CHAT = 420;
+-var MIN_DETAILS = 240;
+-function panelWidthRange(view, viewport) {
+- const room2 = Math.max(0, viewport - MIN_CHAT);
+- if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)];
+- const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2));
+- return [low, Math.max(low, Math.min(DETAILS_MAX, room2))];
+-}
+-
+-// src/client/storage.js
+-var STORAGE = {
+- unread: "ds-bot:unread",
+- reactions: "ds-bot:reactions",
+- seen: "ds-bot:seen",
+- friendLines: "ds-bot:friend-lines",
+- prefs: "ds-bot:prefs",
+- surface: "ds-bot:surface",
+- agentSessions: "ds-bot:agent-sessions",
+- browserWidth: "ds-bot:browser-width",
+- browsers: "ds-bot.browser.v2"
+-};
+-var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"];
+-var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"];
+-var LEGACY_THEMES = { grok: "mono" };
+-function legacyKeys(key) {
+- if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS;
+- const name = key.slice(key.indexOf(":") + 1);
+- return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`);
+-}
+-var migrateBrowsers = (storage) => {
+- if (storage.getItem(STORAGE.browsers) !== null) {
+- for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key);
+- return;
+- }
+- for (const key of LEGACY_BROWSER_KEYS) {
+- try {
+- const value = storage.getItem(key);
+- if (value === null) continue;
+- const previous = JSON.parse(value) ?? {};
+- const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => {
+- const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" };
+- return [botId, { tabs: [tab], active: tab.id }];
+- }));
+- storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
+- for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
+- return;
+- } catch {
+- }
+- }
+-};
+-function migrateStorage(storage = globalThis.localStorage) {
+- for (const key of Object.values(STORAGE)) {
+- if (key === STORAGE.browsers) continue;
+- for (const legacy of legacyKeys(key)) {
+- try {
+- const value = storage.getItem(legacy);
+- if (value !== null && storage.getItem(key) === null) storage.setItem(key, value);
+- storage.removeItem(legacy);
+- } catch {
+- }
+- }
+- }
+- try {
+- migrateBrowsers(storage);
+- } catch {
+- }
+- try {
+- const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null");
+- if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) {
+- storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] }));
+- }
+- } catch {
+- }
++function alertCopy(error) {
++ if (error === WORKFLOW_PLUGIN_HINT) return t("The Workflow plugin is not installed or not enabled. A default install includes @flybot/workflow.");
++ return error || t("Could not read workflows");
+ }
+-
+-// src/client/details-drawer.js
+-var import_react43 = require("react");
+-var TAB_LABELS = { details: "Details", browser: "Browser" };
+-var hostOf = (url) => {
+- try {
+- return new URL(url).hostname.replace(/^www\./, "");
+- } catch {
+- return "";
+- }
+-};
+-var shortUrl = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
+-var EMPTY_TABS = { ids: [], active: null };
+-var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" };
+-var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]);
+-function PermissionRow({ bot, roster, actions, run }) {
+- const [pending, setPending] = (0, import_react43.useState)(null);
+- const catalog = roster.permissions;
+- const saved = bot.permission ?? catalog?.defaultPreset;
+- (0, import_react43.useEffect)(() => {
+- if (pending === saved) setPending(null);
+- }, [pending, saved]);
+- if (!catalog?.options?.length) return null;
+- const current = pending ?? saved;
+- const pick2 = (value) => {
+- if (value === current && bot.permission !== void 0) return;
+- setPending(value);
+- void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null));
+- };
+- return (0, import_react43.createElement)(
++function WorkflowsView({ phase = "loading", pack = null, error = "", opened = null, editing = false, roster = null, canvasWidth = 0, onOpen, onBack, onEditing, onRetry, onUse, onCommit, onRename, onDelete }) {
++ const graph = phase === "ready" ? openedGraph(pack, opened) : null;
++ const templates = pack?.templates ?? [];
++ const templateIds = new Set(templates.map((item) => item.id));
++ const yours = (pack?.workflows ?? []).filter((item) => !templateIds.has(item.id));
++ const showUse = opened?.source === "factory" || opened?.fromFactory !== void 0;
++ return (0, import_react37.createElement)(
+ "div",
+- { className: "bt-drawer-permission" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Permissions")),
+- (0, import_react43.createElement)(
+- Segmented,
+- { label: t("Permissions"), value: current },
+- catalog.options.map((option) => (0, import_react43.createElement)("button", {
+- key: option.value,
+- type: "button",
+- "aria-pressed": option.value === current,
+- title: option.description,
+- onClick: () => pick2(option.value),
+- className: DANGER.has(option.value) ? "bt-perm-danger" : void 0
+- }, PERMISSION_LABELS[option.value] ? t(PERMISSION_LABELS[option.value]) : option.name))
+- )
++ { className: "bt-wf-page" },
++ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("A workflow is a group of your Bots. Drag the flowchart to change who hands work to whom.")),
++ error ? (0, import_react37.createElement)(
++ "div",
++ { className: "bt-error", role: "alert" },
++ alertCopy(error),
++ phase === "error" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onRetry?.() }, t("Retry")) : null
++ ) : null,
++ phase === "loading" ? (0, import_react37.createElement)("p", { className: "bt-wf-status", role: "status" }, t("Reading workflows…")) : null,
++ phase === "empty" ? (0, import_react37.createElement)("p", { className: "bt-wf-status" }, t("No workflows yet.")) : null,
++ phase === "ready" && !graph ? (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-lists" },
++ (0, import_react37.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react37.createElement)("h3", null, t("Factory presets")),
++ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("Ready-made pipelines. Use one to start your own.")),
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-set-card" },
++ templates.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No workflows yet."))) : templates.map((item) => {
++ const live = (pack.workflows ?? []).find((workflow) => workflow.id === item.id);
++ const row = live ? { ...item, name: live.name, preview: live.preview || chainOf(live) || chainOf(item) } : item;
++ return (0, import_react37.createElement)(Row2, { key: item.id, item: row, source: "factory", onOpen: () => onOpen?.(item, "factory") });
++ })
++ )
++ ),
++ (0, import_react37.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react37.createElement)("h3", null, t("Yours")),
++ (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, t("Workflows saved on this machine.")),
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-set-card" },
++ yours.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No workflows of your own yet."))) : yours.map((item) => (0, import_react37.createElement)(Row2, { key: item.id, item, source: "own", onOpen: () => onOpen?.(item, "own"), onRename, onDelete }))
++ )
++ )
++ ) : null,
++ graph ? (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-editor" },
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-wf-actions" },
++ (0, import_react37.createElement)("button", { type: "button", onClick: () => onBack?.() }, t("Back")),
++ showUse ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onUse?.(opened.source === "factory" ? opened.id : opened.fromFactory) }, t("Use this")) : null,
++ opened?.source === "own" ? (0, import_react37.createElement)("button", { type: "button", onClick: () => onEditing?.(!editing) }, editing ? t("Finish editing") : t("Edit")) : null
++ ),
++ (0, import_react37.createElement)("h3", { className: "bt-wf-title" }, graph.name),
++ graph.description ? (0, import_react37.createElement)("p", { className: "bt-wf-hint" }, graph.description) : null,
++ (graph.nodes ?? []).length === 0 ? (0, import_react37.createElement)("p", { className: "bt-wf-status" }, t("This workflow has no flowchart yet.")) : (0, import_react37.createElement)(GraphCanvas, {
++ graph,
++ roster,
++ onCommit,
++ width: canvasWidth,
++ editing: opened?.source === "own" && editing === true
++ })
++ ) : null
+ );
+ }
+-function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) {
+- const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS);
+- const pages = useBrowserPages((map) => map);
+- (0, import_react43.useEffect)(() => {
+- actions.browserEnsure?.(bot.id);
+- }, [bot.id]);
+- if (roster.browser !== true || !actions.browserShow) {
+- return (0, import_react43.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app."));
+- }
+- if (state.ids.length === 0) {
+- return (0, import_react43.createElement)(
+- "div",
+- { className: "bt-browser-empty-mark" },
+- (0, import_react43.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }),
+- (0, import_react43.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
+- (0, import_react43.createElement)("div", { className: "bt-muted" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })),
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser"))
+- );
+- }
+- return (0, import_react43.createElement)(
++function RenameModal({ workflow, onClose, onConfirm }) {
++ const [name, setName] = (0, import_react37.useState)(workflow?.name ?? "");
++ return (0, import_react37.createElement)(
+ "div",
+- { className: "bt-bcards" },
+- state.ids.map((tabId, index) => {
+- const page = pages[tabId] ?? {};
+- const label = page.title || shortUrl(page.url) || t("New tab");
+- return (0, import_react43.createElement)(
++ { className: "bt-modal-mask", onClick: (event) => {
++ if (event.target === event.currentTarget) onClose?.();
++ } },
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Rename workflow") },
++ (0, import_react37.createElement)("h3", null, t("Rename workflow")),
++ (0, import_react37.createElement)(
++ "label",
++ { className: "bt-field" },
++ t("Name"),
++ (0, import_react37.createElement)("input", { type: "text", value: name, onChange: (event) => setName(event.target.value), autoFocus: true })
++ ),
++ (0, import_react37.createElement)(
+ "div",
+- { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } },
+- (0, import_react43.createElement)(
+- "button",
+- { type: "button", className: "bt-bcard-shot", "aria-label": t("Open {title}", { title: label }), title: page.url, onClick: () => actions.browserPick(sessionId, tabId) },
+- page.excerpt ? (0, import_react43.createElement)(
+- "span",
+- { className: "bt-bcard-preview" },
+- (0, import_react43.createElement)("span", { className: "bt-bcard-ptitle" }, label),
+- (0, import_react43.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt)
+- ) : (0, import_react43.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react43.createElement)(GlobeIcon), (0, import_react43.createElement)("span", null, label))
+- ),
+- (0, import_react43.createElement)("button", {
+- type: "button",
+- className: "bt-bcard-x",
+- "aria-label": t("Close tab"),
+- title: t("Close tab"),
+- onClick: () => {
+- void actions.browserCloseTab(bot.id, tabId);
+- }
+- }, "×"),
+- (0, import_react43.createElement)(
+- "div",
+- { className: "bt-bcard-row" },
+- (0, import_react43.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }),
+- (0, import_react43.createElement)("span", { className: "bt-bcard-title" }, label),
+- page.url ? (0, import_react43.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null
+- )
+- );
+- }),
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react43.createElement)(PlusIcon), t("New tab"))
++ { className: "bt-modal-actions" },
++ (0, import_react37.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
++ (0, import_react37.createElement)("button", { type: "button", onClick: () => onConfirm?.(name) }, t("Rename"))
++ )
++ )
+ );
+ }
+-function About({ text: text2, title = "About" }) {
+- const [open, setOpen] = (0, import_react43.useState)(false);
+- const long = text2.split("\n").length > 5 || text2.length > 240;
+- const body = (0, import_react43.useRef)(null);
+- useMorph(body, open);
+- return (0, import_react43.createElement)(
++function DeleteModal({ workflow, onClose, onConfirm }) {
++ return (0, import_react37.createElement)(
+ "div",
+- null,
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t(title)),
+- (0, import_react43.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2),
+- long ? (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null
++ { className: "bt-modal-mask", onClick: (event) => {
++ if (event.target === event.currentTarget) onClose?.();
++ } },
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-modal", role: "dialog", "aria-modal": true, "aria-label": t("Delete workflow") },
++ (0, import_react37.createElement)("h3", null, t("Delete workflow")),
++ (0, import_react37.createElement)("p", null, t('Are you sure you want to delete "{name}"? This cannot be undone.', { name: workflow?.name ?? "" })),
++ (0, import_react37.createElement)(
++ "div",
++ { className: "bt-modal-actions" },
++ (0, import_react37.createElement)("button", { type: "button", onClick: onClose }, t("Cancel")),
++ (0, import_react37.createElement)("button", { type: "button", className: "bt-danger", onClick: onConfirm }, t("Delete"))
++ )
++ )
+ );
+ }
+-function SharedRoomApprovals({ room: room2 }) {
+- const [items, setItems] = (0, import_react43.useState)(room2.pendingApprovals ?? []);
+- const [busy, setBusy] = (0, import_react43.useState)("");
+- const face = room2.origin === "face-to-face";
+- (0, import_react43.useEffect)(() => {
+- const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length;
+- if (haveItems) setItems(room2.pendingApprovals);
+- if (room2.viewerRole !== "invited-owner" && !face) return void 0;
+- let alive = true;
+- call("social/status", {}).then((value) => {
+- if (!alive) return;
+- if (room2.viewerRole === "invited-owner" && !haveItems) {
+- setItems(Array.isArray(value?.approvals) ? value.approvals : []);
+- }
+- }, () => {
++function WorkflowsPage({ call: call4, roster }) {
++ const [phase, setPhase] = (0, import_react37.useState)("loading");
++ const [pack, setPack] = (0, import_react37.useState)(null);
++ const [error, setError] = (0, import_react37.useState)("");
++ const [opened, setOpened] = (0, import_react37.useState)(null);
++ const [editing, setEditing] = (0, import_react37.useState)(false);
++ const [renaming, setRenaming] = (0, import_react37.useState)(null);
++ const [deleting, setDeleting] = (0, import_react37.useState)(null);
++ const invoke = typeof call4 === "function" ? call4 : () => Promise.reject(new Error("Could not read workflows"));
++ const apply2 = (settled) => {
++ setPhase(settled.phase);
++ setPack(settled.pack);
++ setError(settled.error);
++ };
++ (0, import_react37.useEffect)(() => {
++ let gone = false;
++ readWorkflows(invoke).then((settled) => {
++ if (!gone) apply2(settled);
+ });
+ return () => {
+- alive = false;
++ gone = true;
+ };
+- }, [room2.id, room2.viewerRole, room2.pendingCount, face]);
+- if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null;
+- const decide = async (item, decision) => {
+- setBusy(item.id);
+- try {
+- await call("social/decide-approval", { approvalId: item.id, decision });
+- setItems((list) => list.filter((entry) => entry.id !== item.id));
+- } finally {
+- setBusy("");
++ }, []);
++ const live = (pack?.workflows ?? []).some((item) => (item.activeCount ?? 0) > 0);
++ (0, import_react37.useEffect)(() => {
++ if (!live || editing) return void 0;
++ let gone = false;
++ const timer = setInterval(() => {
++ readWorkflows(invoke).then((settled) => {
++ if (!gone) apply2(settled);
++ });
++ }, POLL_MS6);
++ return () => {
++ gone = true;
++ clearInterval(timer);
++ };
++ }, [live, editing]);
++ const retry = () => {
++ setPhase("loading");
++ setError("");
++ setOpened(null);
++ readWorkflows(invoke).then(apply2);
++ };
++ const open = (item, source) => {
++ if (source === "factory") {
++ const live2 = (pack?.workflows ?? []).find((workflow) => workflow.id === item.id);
++ setOpened(live2 ? { source: "own", id: live2.id, fromFactory: item.id } : { source: "factory", id: item.id });
++ setEditing(false);
++ return;
+ }
++ setOpened({ source: "own", id: item.id });
++ setEditing(false);
+ };
+- return (0, import_react43.createElement)(
+- "div",
+- { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react43.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))),
+- items.map((item) => (0, import_react43.createElement)(
+- "div",
+- { key: item.id, className: "bt-set-row" },
+- (0, import_react43.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")),
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")),
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject"))
+- ))
+- );
+-}
+-function FaceMembers({ room: room2, roster, actions }) {
+- const people2 = facePeople(room2, roster);
+- const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot });
+- return (0, import_react43.createElement)(
+- "div",
+- null,
+- (0, import_react43.createElement)(FaceLead, { room: room2, roster, pick: pick2 }),
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })),
+- (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react43.createElement)(FacePersonRow, {
+- key: person.key,
+- person,
+- roster,
+- className: "bt-member",
+- onOpenBot: (id) => actions.openSession(id),
+- lead: room2.face?.lead?.handle
+- })))
+- );
+-}
+-function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) {
+- const live = room2.presentedParticipants ?? room2.participants ?? [];
+- const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
+- const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
+- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
+- const { entries: entries2, now } = useMemberWork(room2, useWork);
+- const invited = room2.viewerRole === "invited-owner";
+- const title = roomTitle(room2);
+- const leaveMine = () => {
+- const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId);
+- if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] });
++ const useTemplate = (id) => {
++ const template = typeof id === "string" ? id : "";
++ if (!template) return;
++ Promise.resolve(invoke("workflow/create", { template })).then((value) => {
++ const settled = settleWorkflowRead(value);
++ if (settled.phase === "error") {
++ setError(settled.error);
++ return;
++ }
++ const before = new Set((pack?.workflows ?? []).map((item) => item.id));
++ const made = settled.pack.workflows.find((item) => !before.has(item.id));
++ setPack(settled.pack);
++ setPhase(settled.phase);
++ setError("");
++ if (made) {
++ setOpened({ source: "own", id: made.id });
++ setEditing(true);
++ }
++ }).catch((failure) => {
++ setError(workflowCallError(failure, t("Could not copy this workflow")));
++ });
+ };
+- const face = room2.origin === "face-to-face";
+- return [
+- (0, import_react43.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }),
+- invited && room2.sharedFrom && !face ? (0, import_react43.createElement)(
+- "div",
+- { key: "from", className: "bt-shared-from" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, title),
+- (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })),
+- room2.sharedFrom.invitedAt ? (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null,
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
+- ) : face ? null : room2.notice ? (0, import_react43.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react43.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")),
+- face ? null : (0, import_react43.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }),
+- face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react43.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react43.createElement)(
+- "div",
+- { key: "members" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })),
+- (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, [
+- ...humans.map((person) => (0, import_react43.createElement)(
+- "div",
+- { key: person.id, role: "listitem" },
+- (0, import_react43.createElement)(
+- "div",
+- { className: "bt-member" },
+- (0, import_react43.createElement)("span", { className: "bt-member-name" }, (0, import_react43.createElement)("span", null, person.displayName || person.id)),
+- (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Human")),
+- person.role === "admin" ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
+- )
+- )),
+- ...remotes.map((person) => (0, import_react43.createElement)(
+- "div",
+- { key: person.handle || person.id, role: "listitem" },
+- (0, import_react43.createElement)(
+- "div",
+- { className: "bt-member" },
+- (0, import_react43.createElement)(
+- "span",
+- { className: "bt-member-name" },
+- (0, import_react43.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
+- (0, import_react43.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person))
+- ),
+- face ? null : (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Friend's Bot"))
+- )
+- )),
+- ...members.map((member) => {
+- const entry = entries2.find((item) => item.id === member.id);
+- return (0, import_react43.createElement)(
+- "div",
+- { key: member.id, role: "listitem" },
+- (0, import_react43.createElement)(
+- "div",
+- { className: "bt-member" },
+- (0, import_react43.createElement)(
+- "button",
+- { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) },
+- (0, import_react43.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }),
+- (0, import_react43.createElement)("span", null, member.name)
+- ),
+- room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
+- ),
+- entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null
+- );
+- })
+- ])
+- ),
+- face ? null : (0, import_react43.createElement)(
+- "div",
+- { key: "mode" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Who answers")),
+- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone")))
+- ),
+- face || !usage ? null : (0, import_react43.createElement)(
+- "div",
+- { key: "usage" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")),
+- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, room2.name), (0, import_react43.createElement)("span", null, usage))
+- )
+- ];
+-}
+-function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
+- const owner = roster.byId[sessionId];
+- const part = owner ? currentPart(owner) : sessionId;
+- const isMainBot = isMainOf(roster, owner?.id);
+- const [tab, setTab] = (0, import_react43.useState)("details");
+- const room2 = roster.roomsById[sessionId];
+- const [view, setView] = (0, import_react43.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview");
+- const [editing, setEditing] = (0, import_react43.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId])));
+- const [draftName, setDraftName] = (0, import_react43.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : ""));
+- const [copied, setCopied] = (0, import_react43.useState)(false);
+- const [error, setError] = (0, import_react43.useState)("");
+- const glide = useGlide(tab);
+- const last = (0, import_react43.useRef)({ view, tab, move: void 0 });
+- if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" };
+- else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" };
+- const move = last.current.move;
+- const side = move === "tab" ? void 0 : move;
+- const close = () => actions.closeOverlay();
+- useEscape(close);
+- const bot = owner;
+- const running = useSessionStatus((map) => map.get(part)?.running === true);
+- const doing = useActivity((map) => map[bot?.id ?? sessionId]);
+- const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster)));
+- const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []]));
+- const run = (promise) => Promise.resolve(promise).then(() => {
+- setError("");
+- return true;
+- }).catch((failure) => {
+- setError(failure?.message ?? String(failure));
+- return false;
+- });
+- const share = () => {
+- const text2 = transcript;
+- if (!text2) {
+- actions.notify?.(t("Nothing to copy yet"));
+- return;
+- }
+- void copiedToClipboard(text2).then((written) => {
+- if (!written) {
+- actions.reportError?.(new Error(t("Could not copy the conversation")), "share");
++ const rename = (item) => {
++ setRenaming(item);
++ };
++ const confirmRename = (workflowId, name) => {
++ if (!name.trim()) return;
++ Promise.resolve(invoke("workflow/rename", { workflowId, name: name.trim() })).then((value) => {
++ const settled = settleWorkflowRead(value);
++ if (settled.phase === "error") {
++ setError(settled.error);
+ return;
+ }
+- setCopied(true);
+- setTimeout(() => setCopied(false), 1600);
+- actions.notify?.(t("Conversation copied as Markdown"));
++ setPack(settled.pack);
++ setPhase("ready");
++ setError("");
++ setRenaming(null);
++ }).catch((failure) => {
++ setError(workflowCallError(failure, t("Could not rename this workflow")));
+ });
+ };
+- const errorLine = error ? (0, import_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
+- if ((bot || room2) && view === "settings" && !bot?.remote) {
+- return [
+- (0, import_react43.createElement)(
+- "div",
+- { key: "head", className: "bt-subhead", "data-move": move },
+- (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react43.createElement)(ChevronLeftIcon)),
+- (0, import_react43.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")),
+- hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon))
+- ),
+- (0, import_react43.createElement)(
+- "div",
+- { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move },
+- errorLine,
+- bot ? (0, import_react43.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react43.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close })
+- )
+- ];
+- }
+- const top = (0, import_react43.createElement)(
++ const deleteWorkflow = (item) => {
++ setDeleting(item);
++ };
++ const confirmDelete = (workflowId) => {
++ Promise.resolve(invoke("workflow/delete", { workflowId })).then((value) => {
++ const settled = settleWorkflowRead(value);
++ if (settled.phase === "error") {
++ setError(settled.error);
++ return;
++ }
++ setPack(settled.pack);
++ setPhase(settled.phase);
++ setError("");
++ setDeleting(null);
++ if (opened?.id === workflowId) setOpened(null);
++ }).catch((failure) => {
++ setError(workflowCallError(failure, t("Could not delete this workflow")));
++ });
++ };
++ const commit = (graph) => {
++ if (!opened || opened.source !== "own") return;
++ Promise.resolve(invoke("workflow/graph", { workflowId: opened.id, nodes: graph.nodes, edges: graph.edges })).then((value) => {
++ const settled = settleWorkflowRead(value);
++ if (settled.phase === "error") {
++ setError(settled.error);
++ return;
++ }
++ setPack(settled.pack);
++ setPhase("ready");
++ setError("");
++ }).catch((failure) => {
++ setError(workflowCallError(failure, t("Could not save this workflow")));
++ });
++ };
++ return (0, import_react37.createElement)(
+ "div",
+- { className: "bt-drawer-top", "data-move": side },
+- bot || room2 ? (0, import_react43.createElement)("button", {
+- type: "button",
+- className: "bt-round",
+- "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
+- title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
+- "data-info-row": "settings",
+- onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings")
+- }, (0, import_react43.createElement)(GearIcon)) : null,
+- bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null,
+- bot || room2 ? (0, import_react43.createElement)("button", {
+- type: "button",
+- className: "bt-round",
+- // #1347: the accessible name says what the click does, which is copy — "Share conversation"
+- // promised a sharing step that never came. It still says "Copied" after a copy, so a screen
+- // reader hears the same confirmation the toast gives.
+- "aria-label": copied ? t("Copied conversation") : t("Copy conversation"),
+- title: copied ? t("Copied") : t("Copy conversation as Markdown"),
+- onClick: share
+- }, copied ? (0, import_react43.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react43.createElement)(ShareIcon)) : null,
+- hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon))
++ null,
++ (0, import_react37.createElement)(WorkflowsView, {
++ phase,
++ pack,
++ error,
++ opened,
++ editing,
++ roster,
++ onOpen: open,
++ onBack: () => {
++ setOpened(null);
++ setEditing(false);
++ },
++ onEditing: setEditing,
++ onRetry: retry,
++ onUse: useTemplate,
++ onCommit: commit,
++ onRename: rename,
++ onDelete: deleteWorkflow
++ }),
++ renaming ? (0, import_react37.createElement)(RenameModal, {
++ workflow: renaming,
++ onClose: () => setRenaming(null),
++ onConfirm: (name) => confirmRename(renaming.id, name)
++ }) : null,
++ deleting ? (0, import_react37.createElement)(DeleteModal, {
++ workflow: deleting,
++ onClose: () => setDeleting(null),
++ onConfirm: () => confirmDelete(deleting.id)
++ }) : null
+ );
+- if (!bot && !room2) {
+- return [
+- (0, import_react43.createElement)(import_react43.Fragment, { key: "top" }, top),
+- (0, import_react43.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react43.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team.")))
+- ];
++}
++
++// src/client/usage-data.js
++var HOUR = 0;
++var OWNER = 1;
++var MODEL = 2;
++var KIND = 3;
++var HOUR_MS = 36e5;
++var DAY_MS = 864e5;
++var RANGES = [
++ { id: "24h", unit: "hour", count: 24, stride: 6 },
++ { id: "7d", unit: "day", count: 7, stride: 1 },
++ { id: "30d", unit: "day", count: 30, stride: 5 },
++ { id: "12w", unit: "week", count: 12, stride: 2 },
++ { id: "12m", unit: "month", count: 12, stride: 1 }
++];
++var hourMs = (key) => Date.UTC(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, Number(key.slice(8, 10)), Number(key.slice(11, 13)) || 0);
++var dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
++var hourKey = (ms) => new Date(ms).toISOString().slice(0, 13).replace("T", " ");
++var mondayOf = (ms) => ms - (new Date(ms).getUTCDay() + 6) % 7 * DAY_MS;
++function spanRange(from, to) {
++ if (to < from) [from, to] = [to, from];
++ const days = Math.round((hourMs(to) - hourMs(from)) / DAY_MS) + 1;
++ const unit = days <= 2 ? "hour" : days <= 62 ? "day" : days <= 182 ? "week" : "month";
++ const range = { id: "custom", unit, from, to, days };
++ const count = spanBuckets(range).length;
++ const stride = unit === "hour" ? 6 : [1, 2, 3, 5, 7, 10, 14].find((step) => count / step <= 12) ?? Math.ceil(count / 12);
++ return { ...range, count, stride };
++}
++function spanBuckets(range) {
++ const first = hourMs(range.from);
++ const end = hourMs(range.to) + DAY_MS;
++ const buckets = [];
++ if (range.unit === "hour" || range.unit === "day") {
++ const step = range.unit === "hour" ? HOUR_MS : DAY_MS;
++ for (let start = first; start < end; start += step) buckets.push({ start, end: start + step, key: range.unit === "hour" ? hourKey(start) : dayKey(start) });
++ } else if (range.unit === "week") {
++ for (let monday = mondayOf(first); monday < end; monday += 7 * DAY_MS) {
++ buckets.push({ start: Math.max(monday, first), end: Math.min(monday + 7 * DAY_MS, end), key: dayKey(monday) });
++ }
++ } else {
++ const year = new Date(first).getUTCFullYear();
++ for (let month = new Date(first).getUTCMonth(); Date.UTC(year, month, 1) < end; month += 1) {
++ const start = Date.UTC(year, month, 1);
++ buckets.push({ start: Math.max(start, first), end: Math.min(Date.UTC(year, month + 1, 1), end), key: dayKey(start).slice(0, 7) });
++ }
+ }
+- const current = bot?.name ?? room2.name;
+- const saveName = async () => {
+- setEditing(false);
+- const next = draftName.trim();
+- if (next === current || room2 && !room2.named && next === "") return;
+- if (bot && next) await run(actions.updateBot(bot.id, { name: next }));
+- if (room2) await run(actions.updateRoom(room2.id, { name: next }));
++ return buckets;
++}
++function spanOf(range, now) {
++ if (range.from) {
++ return { buckets: spanBuckets(range), from: `${range.from} 00`, to: `${range.to} 23`, before: hourKey(hourMs(range.from) - range.days * DAY_MS) };
++ }
++ const buckets = bucketsOf(range, now);
++ return { buckets: buckets.slice(range.count), from: hourKey(buckets[range.count].start), to: now, before: hourKey(buckets[0].start) };
++}
++function bucketsOf(range, now) {
++ const end = hourMs(now);
++ const today = end - end % DAY_MS;
++ const buckets = [];
++ for (let back = range.count * 2 - 1; back >= 0; back -= 1) {
++ let start;
++ if (range.unit === "hour") start = end - back * HOUR_MS;
++ else if (range.unit === "day") start = today - back * DAY_MS;
++ else if (range.unit === "week") start = mondayOf(today) - back * 7 * DAY_MS;
++ else start = Date.UTC(new Date(end).getUTCFullYear(), new Date(end).getUTCMonth() - back, 1);
++ buckets.push({ start, key: range.unit === "hour" ? hourKey(start) : range.unit === "month" ? dayKey(start).slice(0, 7) : dayKey(start) });
++ }
++ return buckets;
++}
++var tokensOf = (row) => row[4] + row[5] + row[6] + row[7];
++var blank2 = () => ({ input: 0, cacheRead: 0, output: 0, calls: 0, total: 0 });
++function addRow(sum, row) {
++ sum.input += row[4] + row[6];
++ sum.cacheRead += row[5];
++ sum.output += row[7];
++ sum.calls += row[8];
++ sum.total += tokensOf(row);
++ return sum;
++}
++function addTo(map, key, row) {
++ map.set(key, addRow(map.get(key) ?? blank2(), row));
++}
++function summarize(view, range, keep = () => true, seriesOf = () => "all") {
++ const { buckets, from, to, before } = spanOf(range, view.now);
++ const index = new Map(buckets.map((bucket, at) => [bucket.key, at]));
++ const weeks = /* @__PURE__ */ new Map();
++ const keyOf = (hour) => {
++ if (range.unit === "hour") return hour;
++ if (range.unit === "day") return hour.slice(0, 10);
++ if (range.unit === "month") return hour.slice(0, 7);
++ const day = hour.slice(0, 10);
++ let week = weeks.get(day);
++ if (week === void 0) weeks.set(day, week = dayKey(mondayOf(hourMs(day))));
++ return week;
+ };
+- const subtitle = bot ? bot.role : roomSubtitle(room2, roster);
+- const tabs = bot ? ["details", "browser"] : ["details"];
+- return [
+- (0, import_react43.createElement)(import_react43.Fragment, { key: "top" }, top),
+- (0, import_react43.createElement)(
+- "div",
+- { key: "id", className: "bt-drawer-id", "data-move": side },
+- bot ? (0, import_react43.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react43.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
+- bot?.remote ? (0, import_react43.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react43.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react43.createElement)("input", {
+- className: "bt-drawer-name-input",
+- autoFocus: true,
+- value: draftName,
+- "aria-label": bot ? t("Bot name") : t("Group name"),
+- placeholder: room2 ? t("Name after members") : void 0,
+- onChange: (event) => setDraftName(event.target.value),
+- onBlur: saveName,
+- onKeyDown: (event) => {
+- if (event.key === "Enter") void saveName();
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- setEditing(false);
+- }
+- }
+- }) : (0, import_react43.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => {
+- setDraftName(room2 && !room2.named ? "" : current);
+- setEditing(true);
+- } }, current),
+- subtitle ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null
+- ),
+- bot?.remote ? null : (0, import_react43.createElement)(
+- "div",
+- { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side },
+- (0, import_react43.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
+- tabs.map((key) => (0, import_react43.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))
+- ),
+- (0, import_react43.createElement)(
+- "div",
+- { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move },
+- errorLine,
+- room2 && tab === "details" ? (0, import_react43.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null,
+- bot && bot.remote && tab === "details" ? [
+- bot.remote.handle ? (0, import_react43.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null,
+- (0, import_react43.createElement)(FlyBotDetails, { key: "social", bot, roster, actions })
+- ] : null,
+- bot && !bot.remote && tab === "details" ? [
+- (0, import_react43.createElement)(
+- "div",
+- { key: "model", className: "bt-drawer-model" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Model")),
+- (0, import_react43.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }),
+- modelOf(bot, roster) ? null : (0, import_react43.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name }))
+- ),
+- (0, import_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }),
+- (0, import_react43.createElement)(
+- "div",
+- { key: "folder", className: "bt-drawer-model" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Working folder")),
+- (0, import_react43.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) })
+- ),
+- bot.instructions ? (0, import_react43.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react43.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")),
+- roster.memory === false ? null : (0, import_react43.createElement)(MemoryRow, { key: "memory", bot, actions }),
+- usage ? (0, import_react43.createElement)(
+- "div",
+- { key: "usage" },
+- (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")),
+- (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, bot.name), (0, import_react43.createElement)("span", null, usage))
+- ) : null,
+- (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions })
+- ] : null,
+- bot && !bot.remote && tab === "browser" ? (0, import_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null
+- )
+- ];
++ const current = buckets.map((bucket) => ({ ...bucket, sum: blank2(), stacks: /* @__PURE__ */ new Map() }));
++ const result = { buckets: current, total: blank2(), previous: 0, owners: /* @__PURE__ */ new Map(), models: /* @__PURE__ */ new Map(), kinds: /* @__PURE__ */ new Map() };
++ for (const row of view.rows) {
++ const hour = row[HOUR];
++ if (hour < before || hour > to || !keep(row)) continue;
++ if (hour < from) {
++ result.previous += tokensOf(row);
++ continue;
++ }
++ const at = index.get(keyOf(hour));
++ if (at === void 0) continue;
++ const bucket = current[at];
++ addRow(bucket.sum, row);
++ addRow(result.total, row);
++ const series = seriesOf(row);
++ bucket.stacks.set(series, (bucket.stacks.get(series) ?? 0) + tokensOf(row));
++ addTo(result.owners, row[OWNER], row);
++ addTo(result.models, row[MODEL], row);
++ addTo(result.kinds, row[KIND], row);
++ }
++ return result;
++}
++function trimmed2(value) {
++ const text2 = value >= 100 ? value.toFixed(0) : value >= 10 ? value.toFixed(1) : value.toFixed(2);
++ return text2.includes(".") ? text2.replace(/\.?0+$/, "") : text2;
++}
++function compact(count) {
++ for (const [size, unit] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]]) {
++ if (count >= size * 0.9995) return `${trimmed2(count / size)}${unit}`;
++ }
++ return String(Math.round(count));
++}
++function percent(part, whole) {
++ if (!(whole > 0) || !(part > 0)) return "0";
++ const value = part / whole * 100;
++ return value < 1 ? "<1" : String(Math.round(value));
+ }
+
+-// src/client/browser-pane.js
+-var import_react44 = require("react");
+-var import_react_dom10 = require("react-dom");
+-var PANEL_LINGER_MS = 300;
+-var WIDTH_KEY = STORAGE.browserWidth;
+-var READ_FLASH_MS = 2400;
+-var EMPTY_PAGE = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, excerpt: "" };
+-var EMPTY_TABS2 = { ids: [], active: null };
+-var SPRING_LEAD2 = springEasing(576, 39.4);
+-var SPRING_TRAIL2 = springEasing(144, 19.7);
+-var icon2 = (paths) => function BrowserIcon() {
+- return (0, import_react44.createElement)(
+- "svg",
+- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
+- paths.map((d, index) => (0, import_react44.createElement)("path", { key: index, d }))
+- );
++// src/client/usage-page.js
++var import_react38 = require("react");
++var HEAT_WEEKS = 53;
++var TOP_SERIES = 7;
++var RING_R = 42;
++var RING2 = 2 * Math.PI * RING_R;
++var rangeLabel = (id) => ({ "24h": t("24 hours"), "7d": t("7 days"), "30d": t("30 days"), "12w": t("12 weeks"), "12m": t("12 months") })[id];
++var unitLabel = (unit) => ({ hour: t("Per hour"), day: t("Per day"), week: t("Per week"), month: t("Per month") })[unit];
++var MODEL_INKS = ["deepseek", "tangerine", "green", "violet", "sky", "rose", "yellow", "cyan", "magenta", "brown"];
++var PARTS = [["output", 1], ["input", 0.6], ["cacheRead", 0.32]];
++var partLabel = (part) => ({ output: t("Output"), input: t("Input"), cacheRead: t("Cache hit") })[part];
++var soften = (paint, strength = 1) => {
++ const mix = (color) => `color-mix(in srgb, ${color} ${Math.round(88 * strength)}%, var(--bt-card))`;
++ return paint.startsWith("linear-gradient(") ? paint.replace(/#[0-9a-f]{6}/gi, mix) : mix(paint);
+ };
+-var GlobeIcon = icon2(["M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M3 10h14", "M10 3c2 2.2 2.9 4.5 2.9 7S12 14.8 10 17c-2-2.2-2.9-4.5-2.9-7S8 5.2 10 3z"]);
+-var BackIcon = icon2(["M12.5 4.5L7 10l5.5 5.5"]);
+-var ForwardIcon = icon2(["M7.5 4.5L13 10l-5.5 5.5"]);
+-var ReloadIcon = icon2(["M15.5 10a5.5 5.5 0 1 1-1.6-3.9", "M15.5 3.5v3.5H12"]);
+-var StopIcon = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
+-var CloseIcon2 = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
+-var PlusTabIcon = icon2(["M10 5v10M5 10h10"]);
+-var LockIcon2 = icon2(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]);
+-function Favicon({ src, className }) {
+- const [failed, setFailed] = (0, import_react44.useState)(null);
+- if (!src || failed === src) return (0, import_react44.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react44.createElement)(GlobeIcon));
+- return (0, import_react44.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) });
++var full = (count) => new Intl.NumberFormat(dateLocale()).format(Math.round(count));
++var hitRate = (sum) => percent(sum.cacheRead, sum.input + sum.cacheRead);
++var dates = (options) => new Intl.DateTimeFormat(dateLocale(), { timeZone: "UTC", ...options });
++var hourText = (ms) => `${String(new Date(ms).getUTCHours()).padStart(2, "0")}:00`;
++var yearOf = (ms) => new Date(ms).getUTCFullYear();
++function spanText(from, to) {
++ const start = hourMs(from);
++ const end = hourMs(to);
++ const year = (/* @__PURE__ */ new Date()).getFullYear();
++ const format = dates(yearOf(start) !== year || yearOf(end) !== year ? { year: "numeric", month: "short", day: "numeric" } : { month: "short", day: "numeric" });
++ return from === to ? format.format(start) : `${format.format(start)} – ${format.format(end)}`;
+ }
+-var savedWidth = () => {
+- try {
+- const value = Number(localStorage.getItem(WIDTH_KEY));
+- return Number.isFinite(value) && value >= MIN_DETAILS ? value : null;
+- } catch {
+- return null;
++var daysText = (count) => count === 1 ? t("1 day") : t("{count} days", { count });
++var lastText = (range) => range.from ? spanText(range.from, range.to) : t("Last {range}", { range: rangeLabel(range.id) });
++function compareText(range) {
++ if (!range.from) return t("Compared with the {range} before", { range: rangeLabel(range.id) });
++ return range.days === 1 ? t("Compared with the day before") : t("Compared with the {count} days before", { count: range.days });
++}
++function tickOf(range, bucket) {
++ if (range.unit === "hour") {
++ return range.days > 1 && new Date(bucket.start).getUTCHours() === 0 ? dates({ month: "numeric", day: "numeric" }).format(bucket.start) : hourText(bucket.start);
+ }
+-};
+-var fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high)));
+-var widthRange = (view, viewport) => panelWidthRange(view, viewport);
+-var room = { rule: null, selector: "" };
+-var roomRule = () => {
+- const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected;
+- if (live && room.selector) return room.rule;
+- const column = document.querySelector('[class*="_centerCol"]');
+- const selector = column ? [...column.classList].filter((name) => name.includes("_centerCol")).map((name) => `.${CSS.escape(name)}`).join(",") : "";
+- if (live && !selector) return room.rule;
+- const declarations2 = room.rule?.style.cssText ?? "";
+- room.rule?.parentStyleSheet?.ownerNode?.remove();
+- const style = document.createElement("style");
+- style.dataset.dshBot = "chat-room";
+- style.textContent = `${selector || '[class*="_centerCol"]'}{${declarations2}}`;
+- document.head.append(style);
+- room.rule = style.sheet.cssRules[0];
+- room.selector = selector;
+- return room.rule;
+-};
+-var roomHold = 0;
+-var chatRoom = {
+- /** The column's right margin in px, or null for the shell's own. */
+- set(width) {
+- const { style } = roomRule();
+- if (width === null) style.removeProperty("margin-right");
+- else style.setProperty("margin-right", `${width}px`, "important");
+- },
+- /** While on, a margin change lands at once instead of gliding. */
+- hold(on) {
+- cancelAnimationFrame(roomHold);
+- if (on) {
+- roomRule().style.setProperty("transition", "none", "important");
+- return;
++ if (range.unit === "month") return dates({ month: "short" }).format(bucket.start);
++ return dates({ month: "numeric", day: "numeric" }).format(bucket.start);
++}
++function titleOf(range, bucket) {
++ if (range.unit === "hour") return `${dates({ month: "short", day: "numeric" }).format(bucket.start)} ${hourText(bucket.start)}–${hourText(bucket.start + HOUR_MS)}`;
++ if (range.unit === "day") return dates({ month: "long", day: "numeric", weekday: "short" }).format(bucket.start);
++ if (range.unit === "week") return spanText(dayKey(bucket.start), dayKey((bucket.end ?? bucket.start + 7 * DAY_MS) - DAY_MS));
++ if (bucket.end && (new Date(bucket.start).getUTCDate() !== 1 || new Date(bucket.end).getUTCDate() !== 1)) return spanText(dayKey(bucket.start), dayKey(bucket.end - DAY_MS));
++ return dates({ year: "numeric", month: "long" }).format(bucket.start);
++}
++function useTween(value) {
++ const [shown, setShown] = (0, import_react38.useState)(0);
++ const from = (0, import_react38.useRef)(0);
++ (0, import_react38.useEffect)(() => {
++ if (reducedMotion() || typeof requestAnimationFrame !== "function") {
++ from.current = value;
++ setShown(value);
++ return void 0;
+ }
+- roomHold = requestAnimationFrame(() => {
+- roomHold = requestAnimationFrame(() => roomRule().style.removeProperty("transition"));
+- });
+- },
+- /** The columns themselves, for a drag's per-frame writes. */
+- columns() {
+- roomRule();
+- return room.selector ? [...document.querySelectorAll(room.selector)] : [];
+- }
+-};
+-var shortAddress = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
+-var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
+-var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab");
+-function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) {
+- const pages = useBrowserPages((map) => map);
+- const wrap = (0, import_react44.useRef)(null);
+- const ind = (0, import_react44.useRef)(null);
+- const spot = (0, import_react44.useRef)(null);
+- const under = (0, import_react44.useRef)(null);
+- (0, import_react44.useLayoutEffect)(() => {
+- const box = wrap.current;
+- const capsule = ind.current;
+- if (!box || !capsule) return void 0;
+- const node = view === "details" ? box.querySelector(".bt-ptab-bot") : box.querySelector(`[data-tab="${active}"]`);
+- under.current = node;
+- if (!node) {
+- capsule.style.opacity = "0";
+- spot.current = null;
+- return void 0;
+- }
+- if (view === "details") box.scrollLeft = 0;
+- else node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
+- const to = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
+- const from = spot.current;
+- spot.current = to;
+- capsule.style.opacity = "1";
+- capsule.style.left = `${to.left}px`;
+- capsule.style.right = `${to.right}px`;
+- if (from === null || from.left === to.left && from.right === to.right || reducedMotion2()) return void 0;
+- const ahead = to.left > from.left ? "right" : "left";
+- const behind = ahead === "right" ? "left" : "right";
+- const lead = capsule.animate([{ [ahead]: `${from[ahead]}px` }, { [ahead]: `${to[ahead]}px` }], { duration: SPRING_LEAD2.duration, easing: SPRING_LEAD2.easing });
+- const trail = capsule.animate([{ [behind]: `${from[behind]}px` }, { [behind]: `${to[behind]}px` }], { duration: SPRING_TRAIL2.duration, easing: SPRING_TRAIL2.easing });
+- return () => {
+- lead.cancel();
+- trail.cancel();
++ const begin = from.current;
++ const started = performance.now();
++ let frame;
++ const step = (now) => {
++ const progress = Math.min(1, (now - started) / 650);
++ from.current = begin + (value - begin) * (1 - (1 - progress) ** 3);
++ setShown(from.current);
++ if (progress < 1) frame = requestAnimationFrame(step);
+ };
+- });
+- (0, import_react44.useEffect)(() => {
+- const box = wrap.current;
+- if (!box) return void 0;
+- const observer = new ResizeObserver(() => {
+- const node = under.current;
+- const capsule = ind.current;
+- if (!node?.isConnected || !capsule || spot.current === null) return;
+- spot.current = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
+- capsule.style.left = `${spot.current.left}px`;
+- capsule.style.right = `${spot.current.right}px`;
+- });
+- observer.observe(box);
+- return () => observer.disconnect();
+- }, []);
+- return (0, import_react44.createElement)(
+- "div",
+- { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") },
+- (0, import_react44.createElement)(
+- "div",
+- { className: "bt-ptabs-in", ref: wrap },
+- (0, import_react44.createElement)(
+- "button",
+- {
+- type: "button",
+- role: "tab",
+- "aria-selected": view === "details",
+- className: "bt-ptab bt-ptab-bot",
+- title: bot.name,
+- onClick: () => onView("details")
+- },
+- (0, import_react44.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }),
+- view === "details" ? (0, import_react44.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null
+- ),
+- ids.map((tabId) => {
+- const page = pages[tabId] ?? EMPTY_PAGE;
+- const on = view === "browser" && tabId === active;
+- return (0, import_react44.createElement)(
+- "button",
+- {
+- key: tabId,
+- type: "button",
+- role: "tab",
+- "aria-selected": on,
+- "data-tab": tabId,
+- className: "bt-ptab",
+- title: page.url || t("New tab"),
+- onClick: () => {
+- actions.browserActivate(bot.id, tabId);
+- onView("browser");
+- },
+- onAuxClick: (event) => {
+- if (event.button === 1) {
+- event.preventDefault();
+- void actions.browserCloseTab(bot.id, tabId);
+- }
+- }
+- },
+- (0, import_react44.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }),
+- (0, import_react44.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)),
+- (0, import_react44.createElement)("span", {
+- className: "bt-ptab-x",
+- role: "button",
+- "aria-label": t("Close tab"),
+- title: t("Close tab"),
+- onClick: (event) => {
+- event.stopPropagation();
+- void actions.browserCloseTab(bot.id, tabId);
+- }
+- }, "×")
+- );
+- }),
+- (0, import_react44.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true })
+- ),
+- // New tab and Close stay outside the scrolling tabs, so a narrow panel keeps both.
+- (0, import_react44.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react44.createElement)(PlusTabIcon)),
+- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn bt-ptab-close", "aria-label": t("Close panel"), title: t("Close panel"), onClick: () => actions.closePanel() }, (0, import_react44.createElement)(CloseIcon2))
+- );
++ frame = requestAnimationFrame(step);
++ return () => cancelAnimationFrame(frame);
++ }, [value]);
++ return shown;
+ }
+-function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) {
+- const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE;
+- const frame = (0, import_react44.useRef)(null);
+- const input = (0, import_react44.useRef)(null);
+- const [draft, setDraft] = (0, import_react44.useState)(null);
+- const showGuest = landed && !leaving && Boolean(page.url) && !page.error;
+- (0, import_react44.useLayoutEffect)(() => {
+- const element = frame.current;
+- if (!element || leaving || !tabId) {
+- void actions.browserShow(null);
+- return void 0;
+- }
+- let raf = 0;
+- const measure = () => {
+- cancelAnimationFrame(raf);
+- raf = 0;
+- const box = element.getBoundingClientRect();
+- void actions.browserShow(tabId, showGuest ? { left: box.left, top: box.top, width: box.width, height: box.height } : null);
++var glyphBox = (size, icon3) => (0, import_react38.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react38.createElement)(icon3));
++function ModelGlyph({ name, paint, size }) {
++ return (0, import_react38.createElement)("span", {
++ className: "bt-us-model",
++ "aria-hidden": true,
++ style: { width: size, height: size, fontSize: Math.round(size * 0.42), background: `color-mix(in srgb, ${paint} 20%, var(--bt-card))`, color: `color-mix(in srgb, ${paint} 72%, var(--bt-ink))` }
++ }, (name.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toUpperCase());
++}
++function ownerFace(owner, roster) {
++ if (owner.kind === "bot") {
++ const bot = roster.byId[owner.id] ?? { id: owner.id, name: owner.name };
++ const name = bot.name || owner.name || t("Deleted Bot");
++ const role = bot.role?.trim() ?? "";
++ return {
++ kind: "bot",
++ name,
++ sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role,
++ paint: paintCss(colorOf(bot)),
++ stops: GRADIENTS[colorOf(bot)],
++ glyph: (size) => (0, import_react38.createElement)(BotAvatar, { bot, size, badge: false, live: false })
+ };
+- const schedule = () => {
+- if (!raf) raf = requestAnimationFrame(measure);
++ }
++ if (owner.kind === "room") {
++ const room2 = roster.roomsById[owner.id];
++ return {
++ kind: "room",
++ name: owner.name || t("Deleted group chat"),
++ sub: owner.deleted ? t("Deleted") : t("Group chat"),
++ paint: "var(--bt-us-rest)",
++ glyph: (size) => room2 ? (0, import_react38.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon)
+ };
+- measure();
+- const observer = new ResizeObserver(measure);
+- observer.observe(element);
+- window.addEventListener("resize", schedule);
+- return () => {
+- cancelAnimationFrame(raf);
+- observer.disconnect();
+- window.removeEventListener("resize", schedule);
++ }
++ if (owner.kind === "agent") {
++ return {
++ kind: "agent",
++ name: owner.name || "DSH Agent",
++ sub: owner.deleted ? t("Deleted") : "",
++ paint: "var(--bt-us-rest)",
++ glyph: (size) => glyphBox(size, ChatsIcon)
+ };
+- }, [tabId, showGuest, leaving]);
+- (0, import_react44.useEffect)(() => {
+- setDraft(null);
+- if (landed && !page.url) input.current?.focus();
+- }, [tabId, landed]);
+- const go = (event) => {
+- event.preventDefault();
+- const text2 = draft ?? "";
+- if (text2.trim() === "") return;
+- setDraft(null);
+- input.current?.blur();
+- void actions.browserNavigate(tabId, text2).then((opened) => {
+- if (opened) actions.browserFocus(tabId);
+- });
+- };
+- const editing = draft !== null;
+- const secure = /^https:/i.test(page.url);
+- return (0, import_react44.createElement)(
++ }
++ return { kind: "other", name: t("Other"), sub: "", paint: "var(--bt-us-rest)", glyph: (size) => glyphBox(size, ChatsIcon) };
++}
++function kindLabel(kind, botFocus) {
++ const labels = { chat: botFocus ? t("Own chat") : t("Conversations"), group: t("Group chats"), compaction: t("Compaction and handoff"), image: t("Image reading"), memory: t("Memory"), "session-title": t("Titles"), other: t("Other") };
++ return labels[kind] ?? kind;
++}
++function Tabs({ label, value, options, onChange, boxRef }) {
++ const names = options.map(([, text2]) => text2).join("\n");
++ const glide = useGlide(`${value}
++${names}`, { box: boxRef });
++ return (0, import_react38.createElement)(
+ "div",
+- { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) },
+- (0, import_react44.createElement)(
+- "div",
+- { className: "bt-browser-bar" },
+- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back"), title: t("Back"), disabled: !page.canGoBack, onClick: () => actions.browserCommand(tabId, "back") }, (0, import_react44.createElement)(BackIcon)),
+- (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Forward"), title: t("Forward"), disabled: !page.canGoForward, onClick: () => actions.browserCommand(tabId, "forward") }, (0, import_react44.createElement)(ForwardIcon)),
+- (0, import_react44.createElement)("button", {
+- type: "button",
+- className: "bt-icon-btn",
+- disabled: !page.url,
+- "aria-label": page.loading ? t("Stop") : t("Reload"),
+- title: page.loading ? t("Stop") : t("Reload"),
+- onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload")
+- }, (0, import_react44.createElement)(page.loading ? StopIcon : ReloadIcon)),
+- (0, import_react44.createElement)(
+- "form",
+- { className: "bt-browser-address", onSubmit: go, "data-editing": editing || void 0 },
+- (0, import_react44.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react44.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)),
+- (0, import_react44.createElement)("input", {
+- ref: input,
+- type: "text",
+- spellCheck: false,
+- autoComplete: "off",
+- "aria-label": t("Address"),
+- placeholder: t("Search or enter address"),
+- value: editing ? draft : shortAddress(page.url),
+- onFocus: (event) => {
+- setDraft(page.url);
+- const target = event.target;
+- requestAnimationFrame(() => target.select());
+- },
+- onBlur: () => setDraft(null),
+- onChange: (event) => setDraft(event.target.value),
+- onKeyDown: (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- setDraft(null);
+- event.currentTarget.blur();
+- }
+- }
+- })
+- ),
+- page.loading ? (0, import_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null
+- ),
+- (0, import_react44.createElement)(
+- "div",
+- { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 },
+- page.error ? (0, import_react44.createElement)(
+- "div",
+- { className: "bt-browser-empty" },
+- (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")),
+- (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)),
+- page.url ? (0, import_react44.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => {
+- void actions.browserNavigate(tabId, page.url);
+- } }, t("Try again")) : null
+- ) : !page.url ? (0, import_react44.createElement)(
+- "div",
+- { className: "bt-browser-empty" },
+- (0, import_react44.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }),
+- (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
+- (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name }))
+- ) : null
+- )
++ { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label },
++ (0, import_react38.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ options.map(([id, text2, icon3]) => (0, import_react38.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react38.createElement)(icon3) : null, text2))
+ );
+ }
+-function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
+- const bot = roster.byId[pane.id];
+- const canBrowse = Boolean(bot && roster.browser === true && actions.browserShow);
+- const view = pane.view === "browser" && canBrowse ? "browser" : "details";
+- const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2);
+- const active = tabState.active;
+- const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE);
+- const root = (0, import_react44.useRef)(null);
+- const fly = (0, import_react44.useRef)(null);
+- const [landed, setLanded] = (0, import_react44.useState)(false);
+- const [settling, setSettling] = (0, import_react44.useState)(false);
+- const [width, setWidth] = (0, import_react44.useState)(savedWidth);
+- const [viewport, setViewport] = (0, import_react44.useState)(() => window.innerWidth);
+- const [dragging, setDragging] = (0, import_react44.useState)(false);
+- const [resizing, setResizing] = (0, import_react44.useState)(false);
+- const instant = dragging || resizing;
+- const [reading, setReading] = (0, import_react44.useState)(false);
+- const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport));
+- (0, import_react44.useLayoutEffect)(() => {
+- if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH);
+- }, [view, width]);
+- (0, import_react44.useLayoutEffect)(() => {
+- chatRoom.hold(instant);
+- }, [instant]);
+- const [last, setLast] = (0, import_react44.useState)({ view, width: panelWidth });
+- if (last.view !== view || last.width !== panelWidth) {
+- setLast({ view, width: panelWidth });
+- if (last.view !== view) setSettling(last.width !== panelWidth && !instant);
+- }
+- const shownView = (0, import_react44.useRef)(view);
+- (0, import_react44.useLayoutEffect)(() => {
+- const switched = settling && shownView.current !== view;
+- shownView.current = view;
+- if (switched) chatRoom.hold(true);
+- chatRoom.set(leaving ? null : panelWidth);
+- if (switched) chatRoom.hold(false);
+- }, [view, panelWidth, leaving]);
+- (0, import_react44.useEffect)(() => () => {
+- chatRoom.set(null);
+- chatRoom.hold(false);
+- }, []);
+- (0, import_react44.useEffect)(() => {
+- let timer;
+- const onResize = () => {
+- setResizing(true);
+- setViewport(window.innerWidth);
+- clearTimeout(timer);
+- timer = setTimeout(() => setResizing(false), 160);
++function Stats({ range, total, previous }) {
++ const shown = useTween(total.total);
++ const change = previous > 0 ? Math.round((total.total - previous) / previous * 100) : null;
++ const cell = (label, value) => (0, import_react38.createElement)("div", { className: "bt-us-stat" }, (0, import_react38.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react38.createElement)("span", { className: "bt-us-stat-value" }, value));
++ return (0, import_react38.createElement)(
++ "section",
++ { className: "bt-us-stats", style: { "--i": 0 } },
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-stat bt-us-stat-main" },
++ (0, import_react38.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)),
++ (0, import_react38.createElement)(
++ "span",
++ { className: "bt-us-stat-value" },
++ compact(shown),
++ (0, import_react38.createElement)("small", null, t("tokens")),
++ change === null ? null : (0, import_react38.createElement)(
++ "span",
++ { className: "bt-us-delta", title: compareText(range) },
++ `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%`
++ )
++ )
++ ),
++ cell(t("Cache hit rate"), `${hitRate(total)}%`),
++ cell(t("Output"), compact(total.output)),
++ cell(t("Calls"), full(total.calls))
++ );
++}
++function niceTop(max) {
++ if (!(max > 0)) return 1;
++ const step = 10 ** Math.floor(Math.log10(max));
++ return [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10].map((scale) => scale * step).find((value) => value >= max);
++}
++function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) {
++ const [hover, setHover] = (0, import_react38.useState)(null);
++ const top = niceTop(Math.max(0, ...columns.map((column) => column.total)));
++ const count = columns.length;
++ const fillOf = new Map(series.map((entry) => [entry.key, entry.fill]));
++ const tipOf = new Map(tipSeries.map((entry) => [entry.key, entry]));
++ const tip = hover === null ? null : columns[hover];
++ const tipSide = hover !== null && hover >= count / 2 ? { right: `calc(${(count - hover) / count * 100}% + 8px)` } : { left: `calc(${(hover + 1) / count * 100}% + 8px)` };
++ return (0, import_react38.createElement)(
++ "section",
++ { className: "bt-us-card bt-us-chart", style: { "--i": 1 } },
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-card-head" },
++ (0, import_react38.createElement)("h3", null, t("Tokens over time"), (0, import_react38.createElement)("span", null, unitLabel(range.unit))),
++ onBy ? (0, import_react38.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null
++ ),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-chart-body" },
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-plot", "data-hover": hover === null ? void 0 : "", onMouseLeave: () => setHover(null) },
++ [1, 0.5].map((level) => (0, import_react38.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react38.createElement)("span", null, compact(top * level)))),
++ (0, import_react38.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react38.createElement)("span", null, "0")),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-cols" },
++ columns.map((column, at) => (0, import_react38.createElement)("div", {
++ key: `${range.from ? `${range.from}~${range.to}` : range.id}:${column.key}`,
++ className: "bt-us-col",
++ "data-on": hover === at ? "" : void 0,
++ onMouseEnter: () => setHover(at)
++ }, column.total > 0 ? (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-stack", style: { height: `${column.total / top * 100}%`, "--i": at } },
++ column.parts.map(([key, value]) => value > 0 ? (0, import_react38.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null)
++ ) : (0, import_react38.createElement)("div", { className: "bt-us-stub" })))
++ ),
++ tip ? (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-tip", style: tipSide },
++ (0, import_react38.createElement)("div", { className: "bt-us-tip-title" }, tip.title),
++ (0, import_react38.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(tip.total) })),
++ tip.tip.filter(([, value]) => value > 0).sort((a, b) => b[1] - a[1]).map(([key, value]) => (0, import_react38.createElement)(
++ "div",
++ { key, className: "bt-us-tip-row" },
++ (0, import_react38.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }),
++ (0, import_react38.createElement)("span", null, tipOf.get(key)?.name),
++ (0, import_react38.createElement)("b", null, compact(value))
++ ))
++ ) : null
++ ),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-ticks", "aria-hidden": true },
++ // A preset counts its labels back from now; a picked span forward from its first day.
++ columns.map((column, at) => (0, import_react38.createElement)(
++ "span",
++ { key: column.key, "data-now": at === count - 1 && range.ends !== false ? "" : void 0 },
++ (range.from ? at : count - 1 - at) % range.stride === 0 ? column.tick : ""
++ ))
++ )
++ ),
++ series.length > 1 || series[0]?.pick ? (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-legend" },
++ series.map((entry) => (0, import_react38.createElement)(entry.pick ? "button" : "span", {
++ key: entry.key,
++ className: "bt-us-key",
++ ...entry.pick ? { type: "button", onClick: () => onPick(entry.pick) } : {}
++ }, (0, import_react38.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name))
++ ) : null
++ );
++}
++function Row3({ kind, glyph, name, sub, sum, whole, fill: fill3, index, onClick }) {
++ const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0;
++ return (0, import_react38.createElement)(
++ onClick ? "button" : "div",
++ {
++ className: "bt-us-row",
++ "data-kind": kind,
++ style: { "--i": index },
++ title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }),
++ ...onClick ? { type: "button", onClick } : {}
++ },
++ glyph ? (0, import_react38.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null,
++ (0, import_react38.createElement)(
++ "span",
++ { className: "bt-us-row-body" },
++ (0, import_react38.createElement)(
++ "span",
++ { className: "bt-us-row-line" },
++ (0, import_react38.createElement)("span", { className: "bt-us-row-name" }, name),
++ sub ? (0, import_react38.createElement)("span", { className: "bt-us-row-sub" }, sub) : null
++ ),
++ (0, import_react38.createElement)("span", { className: "bt-us-track" }, (0, import_react38.createElement)("span", { style: { width: `${width}%`, background: fill3 } }))
++ ),
++ (0, import_react38.createElement)("span", { className: "bt-us-row-num" }, (0, import_react38.createElement)("b", null, compact(sum.total)), (0, import_react38.createElement)("small", null, `${percent(sum.total, whole)}%`))
++ );
++}
++function ListCard({ title, count, index, children }) {
++ return (0, import_react38.createElement)(
++ "section",
++ { className: "bt-us-card", style: { "--i": index } },
++ (0, import_react38.createElement)("div", { className: "bt-us-card-head" }, (0, import_react38.createElement)("h3", null, title, count === void 0 ? null : (0, import_react38.createElement)("span", null, count))),
++ (0, import_react38.createElement)("div", { className: "bt-us-rows" }, children)
++ );
++}
++function Profile({ face, share, range }) {
++ const arc = Math.max(0, Math.min(1, share)) * RING2;
++ return (0, import_react38.createElement)(
++ "section",
++ { className: "bt-us-profile" },
++ (0, import_react38.createElement)(
++ "span",
++ { className: "bt-us-ring" },
++ (0, import_react38.createElement)(
++ "svg",
++ { viewBox: "0 0 96 96", "aria-hidden": true },
++ face.stops ? (0, import_react38.createElement)("defs", null, (0, import_react38.createElement)(
++ "linearGradient",
++ { id: "bt-us-ring-paint", x1: 0, y1: 1, x2: 1, y2: 0 },
++ face.stops.map(([offset, color]) => (0, import_react38.createElement)("stop", { key: offset, offset, stopColor: color }))
++ )) : null,
++ (0, import_react38.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }),
++ arc > 0 ? (0, import_react38.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null
++ ),
++ (0, import_react38.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64))
++ ),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-profile-copy" },
++ (0, import_react38.createElement)("h3", null, face.name),
++ face.sub ? (0, import_react38.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null,
++ (0, import_react38.createElement)("span", { className: "bt-us-profile-share" }, range.from ? t("{share}% of all usage from {span}", { share: percent(share, 1), span: spanText(range.from, range.to) }) : t("{share}% of all usage in the last {range}", { share: percent(share, 1), range: rangeLabel(range.id) }))
++ )
++ );
++}
++var monthOf = (ms) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth(), 1);
++var addMonths = (ms, count) => Date.UTC(yearOf(ms), new Date(ms).getUTCMonth() + count, 1);
++function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) {
++ const box = (0, import_react38.useRef)(null);
++ const todayMs = hourMs(today);
++ const thisMonth = monthOf(todayMs);
++ const [right, setRight] = (0, import_react38.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today))));
++ const [anchor, setAnchor] = (0, import_react38.useState)(null);
++ const [hover, setHover] = (0, import_react38.useState)(null);
++ (0, import_react38.useEffect)(() => {
++ const onDown = (event) => {
++ if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose();
+ };
+- window.addEventListener("resize", onResize);
++ const onKey = (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ onClose();
++ }
++ };
++ window.addEventListener("mousedown", onDown);
++ window.addEventListener("keydown", onKey, true);
+ return () => {
+- clearTimeout(timer);
+- window.removeEventListener("resize", onResize);
++ window.removeEventListener("mousedown", onDown);
++ window.removeEventListener("keydown", onKey, true);
+ };
+ }, []);
+- (0, import_react44.useEffect)(() => {
+- if (!settling) return void 0;
+- const timer = setTimeout(() => setSettling(false), SPRING_SHAPE.duration + 40);
+- return () => clearTimeout(timer);
+- }, [settling, view]);
+- (0, import_react44.useEffect)(() => {
+- const timer = setTimeout(() => setLanded(true), 360);
+- return () => clearTimeout(timer);
+- }, []);
+- (0, import_react44.useEffect)(() => {
+- if (canBrowse) actions.browserEnsure(bot.id);
+- }, [bot?.id, canBrowse]);
+- (0, import_react44.useEffect)(() => {
+- if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details");
+- }, [view, active, tabState.ids.length]);
+- (0, import_react44.useEffect)(() => {
+- if (view !== "browser" || leaving) void actions.browserShow?.(null);
+- }, [view, leaving]);
+- (0, import_react44.useEffect)(() => () => {
+- void actions.browserShow?.(null);
+- }, []);
+- (0, import_react44.useEffect)(() => {
+- if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0;
+- setReading(true);
+- const timer = setTimeout(() => setReading(false), READ_FLASH_MS);
+- return () => clearTimeout(timer);
+- }, [page.readAt]);
+- const beginSwitch = (next) => {
+- if (reducedMotion2()) return;
+- const panel = root.current;
+- const outgoing = panel?.querySelector(".bt-panel-view");
+- if (outgoing) {
+- const ghostEl = document.createElement("div");
+- ghostEl.className = "bt-panel-ghost";
+- ghostEl.style.width = `${outgoing.offsetWidth}px`;
+- ghostEl.append(outgoing.cloneNode(true));
+- outgoing.parentElement.append(ghostEl);
+- void ghostEl.animate([
+- { opacity: 1, transform: "scale(1)" },
+- { opacity: 0, transform: "scale(0.985)" }
+- ], { duration: 160, easing: "ease-out", fill: "forwards" }).finished.catch(() => {
+- }).finally(() => ghostEl.remove());
++ const [low, high] = anchor ? [anchor, hover ?? anchor].sort() : span ? [span.from, span.to] : [null, null];
++ const press = (key) => {
++ if (!anchor) {
++ setAnchor(key);
++ return;
+ }
+- const source = next === "browser" ? panel?.querySelector(".bt-drawer-id .bt-mark") : panel?.querySelector(".bt-ptab-bot .bt-mark");
+- if (source) fly.current = { node: source.cloneNode(true), from: source.getBoundingClientRect() };
+- };
+- const switchView = (next) => {
+- if (next === view || leaving) return;
+- beginSwitch(next);
+- actions.setPanelView(next);
++ const [from, to] = [anchor, key].sort();
++ onPick(from, to);
+ };
+- const panelActions = { ...actions };
+- for (const name of ["openBrowser", "browserNewTab", "browserPick"]) {
+- if (actions[name]) panelActions[name] = (...args) => {
+- if (view !== "browser") beginSwitch("browser");
+- return actions[name](...args);
+- };
+- }
+- (0, import_react44.useLayoutEffect)(() => {
+- const move = fly.current;
+- if (!move) return;
+- fly.current = null;
+- const target = view === "browser" ? root.current?.querySelector(".bt-ptab-bot .bt-mark") : root.current?.querySelector(".bt-drawer-id .bt-mark");
+- if (!target || move.from.width === 0) return;
+- const growing = root.current.getAnimations({ subtree: true }).filter((animation) => animation.animationName === "bt-strip-in");
+- const at = growing.map((animation) => animation.currentTime);
+- for (const animation of growing) animation.currentTime = animation.effect.getComputedTiming().endTime;
+- const to = target.getBoundingClientRect();
+- growing.forEach((animation, index) => {
+- animation.currentTime = at[index];
+- });
+- const clone = move.node;
+- Object.assign(clone.style, {
+- position: "fixed",
+- left: `${move.from.left}px`,
+- top: `${move.from.top}px`,
+- width: `${move.from.width}px`,
+- height: `${move.from.height}px`,
+- margin: "0",
+- transformOrigin: "0 0",
+- zIndex: "80",
+- pointerEvents: "none",
+- visibility: ""
+- });
+- document.body.append(clone);
+- target.style.visibility = "hidden";
+- const scale = to.width / move.from.width;
+- const anim = clone.animate([
+- { transform: "translate(0px, 0px) scale(1)" },
+- { transform: `translate(${to.left - move.from.left}px, ${to.top - move.from.top}px) scale(${scale})` }
+- ], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, fill: "forwards" });
+- void anim.finished.catch(() => {
+- }).finally(() => {
+- clone.remove();
+- target.style.visibility = "";
+- });
+- }, [view, pane.id]);
+- const startResize = (event) => {
+- if (event.button !== 0 || leaving) return;
+- event.preventDefault();
+- const panel = root.current;
+- const grip = event.currentTarget;
+- grip.setPointerCapture?.(event.pointerId);
+- const range = widthRange(view, window.innerWidth);
+- const from = { x: event.clientX, width: panelWidth };
+- let next = panelWidth;
+- setDragging(true);
+- setSettling(false);
+- chatRoom.hold(true);
+- const columns = chatRoom.columns();
+- for (const column of columns) column.style.setProperty("transition", "none", "important");
+- const onMove = (move) => {
+- next = fitWidth(from.width + from.x - move.clientX, range);
+- panel.style.width = `${next}px`;
+- if (columns.length === 0) chatRoom.set(next);
+- for (const column of columns) column.style.setProperty("margin-right", `${next}px`, "important");
+- };
+- const ends = ["pointerup", "pointercancel", "lostpointercapture"];
+- const onEnd = (end) => {
+- if (end.type === "pointerup") onMove(end);
+- grip.removeEventListener("pointermove", onMove);
+- for (const name of ends) grip.removeEventListener(name, onEnd);
+- chatRoom.set(next);
+- for (const column of columns) {
+- column.style.removeProperty("margin-right");
+- column.style.removeProperty("transition");
+- }
+- panel.style.setProperty("--bt-panel-w", `${next}px`);
+- panel.style.removeProperty("width");
+- setDragging(false);
+- if (next === from.width) return;
+- setWidth(next);
+- try {
+- localStorage.setItem(WIDTH_KEY, String(next));
+- } catch {
+- }
+- };
+- grip.addEventListener("pointermove", onMove);
+- for (const name of ends) grip.addEventListener(name, onEnd);
++ const weekdays = (0, import_react38.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []);
++ const month = (start, side) => {
++ const length = new Date(addMonths(start, 1) - DAY_MS).getUTCDate();
++ const lead = (new Date(start).getUTCDay() + 6) % 7;
++ const cells = Array.from({ length: lead }, (_, at) => (0, import_react38.createElement)("span", { key: `b${at}` }));
++ for (let day = 1; day <= length; day += 1) {
++ const ms = start + (day - 1) * DAY_MS;
++ const key = dayKey(ms);
++ const weekday = (lead + day - 1) % 7;
++ const flag = (on) => on ? "" : void 0;
++ cells.push((0, import_react38.createElement)("button", {
++ key,
++ type: "button",
++ className: "bt-us-day",
++ disabled: ms > todayMs,
++ "aria-label": dates({ year: "numeric", month: "long", day: "numeric", weekday: "short" }).format(ms),
++ "aria-pressed": key === low || key === high,
++ "data-in": flag(low !== null && key >= low && key <= high),
++ "data-start": flag(key === low),
++ "data-end": flag(key === high),
++ "data-l": flag(weekday === 0 || day === 1),
++ "data-r": flag(weekday === 6 || day === length),
++ "data-today": flag(key === today),
++ "data-has": flag(active.has(key)),
++ onMouseEnter: () => setHover(key),
++ onClick: () => press(key)
++ }, (0, import_react38.createElement)("b", null, day)));
++ }
++ const nav = side === "left" ? (0, import_react38.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "left", "aria-label": t("Previous month"), disabled: start <= oldest, onClick: () => setRight(addMonths(right, -1)) }, (0, import_react38.createElement)(ChevronLeftIcon)) : (0, import_react38.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "right", "aria-label": t("Next month"), disabled: start >= thisMonth, onClick: () => setRight(addMonths(right, 1)) }, (0, import_react38.createElement)(ChevronRightIcon));
++ return (0, import_react38.createElement)(
++ "div",
++ { key: start, className: "bt-us-month" },
++ (0, import_react38.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react38.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))),
++ (0, import_react38.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react38.createElement)("span", { key: at }, name))),
++ (0, import_react38.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells)
++ );
+ };
+- const hasStrip = canBrowse && tabState.ids.length > 0;
+- return (0, import_react44.createElement)(
+- "aside",
+- {
+- ref: root,
+- className: "bt-panel",
+- "data-view": view,
+- "aria-label": view === "browser" && bot ? t("{name}'s browser", { name: bot.name }) : t("Conversation details"),
+- "data-leaving": leaving || void 0,
+- "aria-hidden": leaving || void 0,
+- "data-settling": settling || void 0,
+- "data-instant": instant || void 0,
+- "data-dragging": dragging || void 0,
+- style: { "--bt-panel-w": `${panelWidth}px` },
+- onAnimationEnd: (event) => {
+- if (event.target === event.currentTarget) setLanded(true);
+- }
+- },
+- leaving ? null : (0, import_react44.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }),
+- // Over the whole window while dragging: the cursor stays a resize cursor and the
+- // pages underneath (the chat, the guest) see no pointer.
+- dragging ? (0, import_react_dom10.createPortal)((0, import_react44.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null,
+- hasStrip ? (0, import_react44.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null,
+- view === "browser" && bot ? (0, import_react44.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react44.createElement)(
++ const shortcuts = [
++ ["month", t("This month"), dayKey(thisMonth), today],
++ ["last", t("Last month"), dayKey(addMonths(thisMonth, -1)), dayKey(thisMonth - DAY_MS)],
++ ["year", t("This year"), `${today.slice(0, 4)}-01-01`, today]
++ ];
++ const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1;
++ return (0, import_react38.createElement)(
++ "div",
++ { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") },
++ (0, import_react38.createElement)(
+ "div",
+- { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" },
+- (0, import_react44.createElement)(DetailsDrawer, {
+- sessionId: pane.id,
+- startRenaming: pane.renaming,
+- startTab: pane.tab,
+- hasStrip,
+- roster,
+- actions: panelActions,
+- useSessions,
+- useSessionStatus,
+- useActivity,
+- useWork,
+- useBrowserTabs,
+- useBrowserPages
+- })
++ { className: "bt-us-pick-quick" },
++ shortcuts.map(([id, label, from, to]) => (0, import_react38.createElement)("button", {
++ key: id,
++ type: "button",
++ "aria-pressed": span?.from === from && span?.to === to,
++ onClick: () => onPick(from, to)
++ }, label))
++ ),
++ (0, import_react38.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-pick-foot", role: "status" },
++ (0, import_react38.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")),
++ low === null ? null : (0, import_react38.createElement)("span", null, (0, import_react38.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`)
+ )
+ );
+ }
+-var BROWSER_CSS = `
+-.bt-panel{--bt-panel-view-fade:${SPRING_SHAPE.duration}ms}
+-.bt-ptabs{position:relative;display:flex;align-items:center;height:44px;flex:none;border-bottom:.5px solid var(--bt-line);padding:0 6px;gap:0;animation:bt-strip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
+-@keyframes bt-strip-in{from{height:0;opacity:0}}
+-.bt-ptabs-in{position:relative;display:flex;align-items:center;gap:2px;flex:1;min-width:0;height:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;padding:4px 2px;scroll-padding-left:48px;mask-image:linear-gradient(to right,#000,#000 calc(100% - 10px),transparent)}
+-.bt-ptabs-in>.bt-ptab-bot{position:sticky;left:0;z-index:2;background:var(--bt-main)}
+-.bt-ptabs-in>.bt-ptab-bot[aria-selected=true]{background:transparent}
+-.bt-ptabs-in::-webkit-scrollbar{display:none}
+-.bt-ptab{position:relative;z-index:1;display:inline-flex;align-items:center;gap:6px;height:32px;max-width:180px;padding:0 8px;flex:none;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12.5px;cursor:pointer;white-space:nowrap;transition:color .12s ease}
+-.bt-ptab:hover{color:var(--bt-ink)}
+-.bt-ptab[aria-selected=true]{color:var(--bt-ink)}
+-.bt-ptab-bot{padding:0 7px}
+-.bt-ptab-bot .bt-mark{flex:none}
+-.bt-ptab-name{max-width:96px;overflow:hidden;text-overflow:ellipsis;font-weight:500;animation:bt-ptab-label .18s ease}
+-@keyframes bt-ptab-label{from{opacity:0;filter:blur(4px);max-width:0}}
+-.bt-ptab-fav{width:14px;height:14px;flex:none;border-radius:3px;object-fit:cover;color:var(--bt-ink-3)}
+-.bt-ptab-globe{display:inline-flex}
+-.bt-ptab-globe svg{width:14px;height:14px}
+-.bt-ptab-title{max-width:140px;overflow:hidden;text-overflow:ellipsis}
+-.bt-ptab-x{flex:none;width:16px;height:16px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--bt-ink-3);opacity:0;transition:opacity .12s ease,background-color .12s ease}
+-.bt-ptab:hover .bt-ptab-x,.bt-ptab[aria-selected=true] .bt-ptab-x{opacity:1}
+-.bt-ptab-x:hover{background:var(--bt-active);color:var(--bt-ink)}
+-.bt-ptab-new{position:relative;z-index:1;flex:none;width:28px;height:32px;border:0;border-radius:9px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
+-.bt-ptab-new:hover{color:var(--bt-ink)}
+-.bt-ptab-new svg{width:15px;height:15px}
+-.bt-ptab-ind{position:absolute;top:4px;bottom:4px;border-radius:9px;background:var(--bt-hover);opacity:0;pointer-events:none;z-index:0;transition:opacity .15s ease}
+-.bt-ptab-close{flex:none;margin-left:2px}
+-.bt-panel-ghost{position:absolute;top:44px;right:0;bottom:0;overflow:hidden;pointer-events:none;z-index:2}
+-.bt-panel-ghost>.bt-panel-view{position:absolute;inset:0}
+-.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column;animation:bt-panel-view-in .22s cubic-bezier(.16,1,.3,1)}
+-@keyframes bt-panel-view-in{from{opacity:0}}
+-/* While the panel changes width its views keep their final width on the right edge, so
+- only the panel edge moves and nothing inside reflows frame by frame. */
+-.bt-panel[data-settling]{overflow:hidden}
+-.bt-panel[data-settling]>.bt-ptabs,.bt-panel[data-settling]>.bt-panel-view{flex-shrink:0;align-self:flex-end;box-sizing:border-box;width:calc(var(--bt-panel-w,320px) - .5px)}
+-.bt-panel-browser{position:relative}
+-.bt-browser-bar{position:relative;display:flex;align-items:center;gap:2px;height:52px;padding:0 10px 0 12px;flex:none;border-bottom:.5px solid var(--bt-line)}
+-.bt-browser-bar .bt-icon-btn:disabled{opacity:.35;cursor:default;background:none}
+-.bt-browser-address{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:32px;margin:0 4px;padding:0 12px;border-radius:999px;background:var(--bt-hover);border:.5px solid transparent;transition:background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
+-.bt-browser-address:hover{background:var(--bt-active)}
+-.bt-browser-address[data-editing]{background:var(--bt-main);border-color:var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04)}
+-.bt-browser-scheme{display:inline-flex;color:var(--bt-ink-3);flex:none}
+-.bt-browser-scheme svg{width:14px;height:14px}
+-.bt-browser-address input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;padding:0;text-overflow:ellipsis}
+-.bt-browser-address input::placeholder{color:var(--bt-ink-3)}
+-.bt-browser-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;pointer-events:none}
+-.bt-browser-progress::after{content:"";position:absolute;inset:0;width:40%;background:var(--bt-accent,#4D6BFE);border-radius:2px;animation:bt-browser-load 1.1s cubic-bezier(.4,0,.2,1) infinite}
+-@keyframes bt-browser-load{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
+-.bt-browser-frame{position:relative;flex:1;min-height:0;background:var(--bt-main)}
+-.bt-browser-frame[data-guest]{background:#fff}
+-.bt-browser-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:32px;text-align:center;animation:bt-foot-in .3s ease}
+-.bt-browser-empty-title{font-size:15px;line-height:22px;font-weight:500;margin-top:6px}
+-.bt-browser-empty-text{max-width:300px;font-size:13px;line-height:19px;color:var(--bt-ink-2);overflow-wrap:anywhere}
+-.bt-browser-retry{margin-top:6px;height:32px;padding:0 14px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer}
+-.bt-browser-retry:hover{background:var(--bt-hover)}
+-.bt-bcards{display:flex;flex-direction:column;gap:12px}
+-.bt-bcard{position:relative;border:.5px solid var(--bt-line-2);border-radius:12px;overflow:hidden;background:var(--bt-main);animation:bt-bcard-in .24s cubic-bezier(.16,1,.3,1) both;transition:border-color .12s ease,box-shadow .12s ease}
+-@keyframes bt-bcard-in{from{opacity:0;filter:blur(4px);transform:translateY(4px)}}
+-.bt-bcard[data-active]{border-color:var(--bt-accent,#4D6BFE);box-shadow:0 0 0 1px var(--bt-accent,#4D6BFE)}
+-.bt-bcard-shot{position:relative;display:block;width:100%;aspect-ratio:16/10;border:0;background:var(--bt-hover);padding:0;cursor:pointer}
+-.bt-bcard-blank{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--bt-ink-3);font-size:12px}
+-.bt-bcard-blank svg{width:20px;height:20px}
+-.bt-bcard-preview{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:14px 16px;text-align:left;background:linear-gradient(to bottom,var(--bt-main),var(--bt-hover));overflow:hidden}
+-.bt-bcard-ptitle{font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+-.bt-bcard-excerpt{font-size:12px;line-height:17px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
+-.bt-bcard-row{display:flex;align-items:center;gap:7px;padding:8px 10px;min-width:0}
+-.bt-bcard-fav{width:14px;height:14px;flex:none;border-radius:3px;color:var(--bt-ink-3)}
+-.bt-bcard-row svg.bt-bcard-fav{width:14px;height:14px}
+-.bt-bcard-title{flex:1;min-width:0;font-size:12.5px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink)}
+-.bt-bcard-host{flex:none;font-size:11px;line-height:15px;color:var(--bt-ink-3);max-width:38%;overflow:hidden;text-overflow:ellipsis}
+-.bt-bcard-x{position:absolute;top:6px;right:6px;width:22px;height:22px;border:0;border-radius:7px;background:color-mix(in srgb,var(--bt-main) 88%,transparent);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;opacity:0;transition:opacity .12s ease;backdrop-filter:blur(4px)}
+-.bt-bcard:hover .bt-bcard-x{opacity:1}
+-.bt-bcard-new{display:flex;align-items:center;justify-content:center;gap:6px;height:40px;border:.5px dashed var(--bt-line-2);border-radius:12px;background:none;color:var(--bt-ink-3);font:inherit;font-size:12.5px;cursor:pointer}
+-.bt-bcard-new:hover{color:var(--bt-ink);border-color:var(--bt-ink-3)}
+-.bt-bcard-new svg{width:14px;height:14px}
+-.bt-browser-empty-mark{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center}
+-@media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}}
+-`;
+-
+-// src/client/remote-bot-settings.js
+-var import_react45 = require("react");
+-var REMOTE_BOT_SETTINGS_CSS = `
+-.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
+-.bt-remote-sheet-layer[data-leaving]{pointer-events:none}
+-.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto}
+-.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
+-.bt-remote-sheet:focus{outline:none}
+-.bt-remote-sheet{position:relative;z-index:1;pointer-events:auto;width:min(320px,calc(100vw - 32px));max-height:min(70vh,420px);margin:0 auto 0;border-radius:14px;background:var(--bt-card,#fff);border:.5px solid var(--bt-line-2);box-shadow:0 12px 40px rgba(20,20,20,.16);display:flex;flex-direction:column;overflow:hidden;animation:bt-remote-sheet-in .2s ease-out}
+-.bt-remote-sheet-layer[data-leaving] .bt-remote-sheet{animation:bt-remote-sheet-out .16s ease-in forwards}
+-.bt-remote-sheet-head{display:flex;align-items:center;gap:10px;padding:12px 12px 8px}
+-.bt-remote-sheet-who{display:flex;flex-direction:column;min-width:0;gap:1px;flex:1 1 auto}
+-.bt-remote-sheet-name{font-size:16px;line-height:22px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-remote-sheet-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+-.bt-remote-sheet-body{padding:0 12px 14px;overflow:auto}
+-@keyframes bt-remote-sheet-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
+-@keyframes bt-remote-sheet-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(8px)}}
+-@media (min-width:520px){
+-.bt-remote-sheet-layer{align-items:center;padding:24px}
+-.bt-remote-sheet{margin:0}
+-}
+-`;
+-function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) {
+- const close = () => actions.closeSettings();
+- useEscape(close);
+- const dialog = (0, import_react45.useRef)(null);
+- (0, import_react45.useEffect)(() => {
+- const before = document.activeElement;
+- dialog.current?.focus?.({ preventScroll: true });
+- return () => {
+- if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true });
+- };
+- }, []);
+- const onKeyDown = (event) => {
+- if (event.key === "Escape") {
+- event.stopPropagation();
+- close();
++function Heat({ view, keep, paint, index }) {
++ const [hover, setHover] = (0, import_react38.useState)(null);
++ const today = hourMs(`${view.now.slice(0, 10)} 00`);
++ const first = mondayOf(today) - (HEAT_WEEKS - 1) * 7 * DAY_MS;
++ const from = dayKey(first);
++ const daily = /* @__PURE__ */ new Map();
++ for (const row of view.rows) {
++ const day = row[HOUR].slice(0, 10);
++ if (day >= from && keep(row)) daily.set(day, (daily.get(day) ?? 0) + tokensOf(row));
++ }
++ const most = Math.max(0, ...daily.values());
++ const levels = [0.3, 0.52, 0.76, 1];
++ const months = [];
++ const cells = [];
++ for (let week = 0; week < HEAT_WEEKS; week += 1) {
++ const monday = first + week * 7 * DAY_MS;
++ if (week > 0 && new Date(monday).getUTCMonth() !== new Date(monday - 7 * DAY_MS).getUTCMonth()) {
++ months.push((0, import_react38.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday)));
+ }
+- };
+- if (!bot?.remote) return null;
+- const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot");
+- return (0, import_react45.createElement)(
+- "div",
+- { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" },
+- (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
+- (0, import_react45.createElement)(
++ for (let day = 0; day < 7; day += 1) {
++ const ms = monday + day * DAY_MS;
++ const value = daily.get(dayKey(ms)) ?? 0;
++ const level = value > 0 ? Math.max(1, Math.ceil(4 * Math.sqrt(value / most))) : 0;
++ cells.push((0, import_react38.createElement)("span", {
++ key: ms,
++ className: "bt-us-cell",
++ style: { "--w": week },
++ "data-on": hover?.ms === ms ? "" : void 0,
++ "data-future": ms > today ? "" : void 0,
++ "data-today": ms === today ? "" : void 0,
++ onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day })
++ }, level > 0 ? (0, import_react38.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null));
++ }
++ }
++ const tipSide = hover ? {
++ ...hover.week >= HEAT_WEEKS / 2 ? { right: `calc(${(HEAT_WEEKS - hover.week) / HEAT_WEEKS * 100}% + 8px)` } : { left: `calc(${(hover.week + 1) / HEAT_WEEKS * 100}% + 8px)` },
++ ...hover.day < 4 ? { top: `calc(${hover.day / 7 * 100}% - 2px)` } : { top: "auto", bottom: `calc(${(6 - hover.day) / 7 * 100}% - 2px)` }
++ } : null;
++ const active = [...daily.values()].filter((value) => value > 0).length;
++ const summary = active === 1 ? t("Active on 1 day in the last year") : t("Active on {count} days in the last year", { count: active });
++ return (0, import_react38.createElement)(
++ "section",
++ { className: "bt-us-card bt-us-heat", style: { "--i": index } },
++ (0, import_react38.createElement)("div", { className: "bt-us-card-head" }, (0, import_react38.createElement)("h3", null, t("Activity")), (0, import_react38.createElement)("span", { className: "bt-us-head-note" }, summary)),
++ (0, import_react38.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months),
++ (0, import_react38.createElement)(
+ "div",
+- { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown },
+- (0, import_react45.createElement)(
++ { className: "bt-us-heat-plot", onMouseLeave: () => setHover(null) },
++ (0, import_react38.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells),
++ hover ? (0, import_react38.createElement)(
+ "div",
+- { className: "bt-remote-sheet-head" },
+- (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }),
+- (0, import_react45.createElement)(
+- "div",
+- { className: "bt-remote-sheet-who" },
+- (0, import_react45.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name),
+- (0, import_react45.createElement)("span", { className: "bt-remote-sheet-sub" }, who)
+- ),
+- (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react45.createElement)(CloseIcon))
+- ),
+- (0, import_react45.createElement)(
++ { className: "bt-us-tip bt-us-heat-tip", style: tipSide },
++ (0, import_react38.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)),
++ (0, import_react38.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) }))
++ ) : null
++ ),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-heat-scale", "aria-hidden": true },
++ t("Light"),
++ levels.map((level) => (0, import_react38.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react38.createElement)("i", { style: { background: soften(paint, level) } }))),
++ t("Heavy")
++ )
++ );
++}
++function Skeleton2() {
++ return (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us", "aria-busy": true },
++ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }),
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-sheet bt-us-skel-sheet" },
++ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }),
++ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }),
++ (0, import_react38.createElement)(
+ "div",
+- { className: "bt-remote-sheet-body" },
+- bot.remote.handle ? (0, import_react45.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null,
+- (0, import_react45.createElement)(FlyBotDetails, { bot, roster, actions })
++ { className: "bt-us-pair" },
++ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }),
++ (0, import_react38.createElement)("div", { className: "bt-us-skel", style: { height: 200 } })
+ )
+ )
+ );
+ }
+-
+-// src/client/overlays.js
+-var import_react46 = require("react");
+-var import_react_dom11 = require("react-dom");
+-var DIALOG_EXIT_MS = 160;
+-var MENU_EXIT_MS3 = 120;
+-function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) {
+- const roster = useRoster((value) => value);
+- const ui2 = useUi((value) => value);
+- useRegistry((value) => value);
+- const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null);
+- const previous = (0, import_react46.useRef)(current);
+- (0, import_react46.useEffect)(() => {
+- if (previous.current === current) return;
+- previous.current = current;
+- if (current) actions.followPanel?.(current);
+- }, [current]);
+- const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null;
+- const [kept, setKept] = (0, import_react46.useState)(open);
+- (0, import_react46.useEffect)(() => {
+- if (open) {
+- setKept(open);
+- return void 0;
++var focusOf = (id) => typeof id !== "string" ? null : id.startsWith("model:") ? { kind: "model", key: id.slice("model:".length) } : id.startsWith("owner:") ? { kind: "owner", key: id.slice("owner:".length) } : null;
++function UsagePage({ roster, actions, focusId }) {
++ const [view, setView] = (0, import_react38.useState)(null);
++ const [error, setError] = (0, import_react38.useState)("");
++ const [rangeId, setRangeId] = (0, import_react38.useState)("7d");
++ const [span, setSpan] = (0, import_react38.useState)(null);
++ const [picking, setPicking] = (0, import_react38.useState)(false);
++ const [by, setBy] = (0, import_react38.useState)("bot");
++ const tabs = (0, import_react38.useRef)(null);
++ const focus = focusOf(focusId);
++ (0, import_react38.useEffect)(() => {
++ let stopped = false;
++ let timer;
++ const resized = Promise.all((document.querySelector(".bt-settings")?.getAnimations() ?? []).filter((animation) => animation.transitionProperty === "width" || animation.transitionProperty === "height").map((animation) => animation.finished.catch(() => {
++ })));
++ const load = async () => {
++ try {
++ const value = await actions.usage();
++ await resized;
++ if (stopped) return;
++ (0, import_react38.startTransition)(() => setView(value));
++ setError("");
++ timer = setTimeout(load, value.scanning ? 3e3 : 2e4);
++ } catch (failure) {
++ if (stopped) return;
++ setError(failure?.message ?? String(failure));
++ timer = setTimeout(load, 1e4);
++ }
++ };
++ void load();
++ return () => {
++ stopped = true;
++ clearTimeout(timer);
++ };
++ }, []);
++ const today = view?.now.slice(0, 10);
++ const range = rangeId === "custom" && span ? { ...spanRange(span.from, span.to), ends: span.to >= today } : RANGES.find((item) => item.id === rangeId);
++ const shaped = (0, import_react38.useMemo)(() => {
++ if (!view) return null;
++ const faces2 = view.owners.map((owner) => ownerFace(owner, roster));
++ const lifetime = /* @__PURE__ */ new Map();
++ for (const row of view.rows) lifetime.set(row[MODEL], (lifetime.get(row[MODEL]) ?? 0) + tokensOf(row));
++ const modelPaint = new Map([...lifetime].sort((a, b) => b[1] - a[1]).map(([model], rank) => [model, `var(--bt-ink-${MODEL_INKS[rank % MODEL_INKS.length]})`]));
++ const models2 = view.models.map((model, at) => {
++ const name = model.name || model.model || t("Unknown model");
++ const paint = modelPaint.get(at) ?? "var(--bt-us-rest)";
++ return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react38.createElement)(ModelGlyph, { name, paint, size }) };
++ });
++ const ownerAt2 = focus?.kind === "owner" ? view.owners.findIndex((owner) => owner.key === focus.key) : -1;
++ const modelAt = focus?.kind === "model" ? view.models.findIndex((model) => model.key === focus.key) : -1;
++ const isBot2 = (at) => ["bot", "agent"].includes(view.owners[at]?.kind);
++ const all2 = summarize(view, range, void 0, (row) => by === "bot" ? isBot2(row[OWNER]) ? `o${row[OWNER]}` : "rest" : `m${row[MODEL]}`);
++ const keep2 = ownerAt2 >= 0 ? (row) => row[OWNER] === ownerAt2 : modelAt >= 0 ? (row) => row[MODEL] === modelAt : () => true;
++ const face2 = ownerAt2 >= 0 ? faces2[ownerAt2] : modelAt >= 0 ? models2[modelAt] : null;
++ const part2 = face2 ? summarize(view, range, keep2) : all2;
++ const activeDays2 = /* @__PURE__ */ new Set();
++ let first = view.now;
++ for (const row of view.rows) {
++ if (row[HOUR] < first) first = row[HOUR];
++ if (keep2(row) && tokensOf(row) > 0) activeDays2.add(row[HOUR].slice(0, 10));
+ }
+- const timer = setTimeout(() => setKept(null), PANEL_LINGER_MS);
+- return () => clearTimeout(timer);
+- }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]);
+- const pane = open ?? kept;
+- const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS);
+- const remoteBotId = ui2.remoteBotSettings && roster.byId[ui2.remoteBotSettings]?.remote ? ui2.remoteBotSettings : null;
+- const [remoteBotSettings, remoteLeaving] = useLinger(remoteBotId, DIALOG_EXIT_MS);
+- const [memory, memoryLeaving] = useLinger(ui2.memory && roster.byId[ui2.memory] ? ui2.memory : null, DIALOG_EXIT_MS);
+- const [menu, menuLeaving] = useLinger(ui2.menu, MENU_EXIT_MS3);
+- const [inbox, inboxLeaving] = useLinger(ui2.inbox ?? null, DIALOG_EXIT_MS);
+- const [board, boardLeaving] = useLinger(ui2.board ?? null, DIALOG_EXIT_MS);
+- return (0, import_react46.createElement)(
+- import_react46.Fragment,
+- null,
+- pane ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null,
+- ui2.newChat ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null,
+- ui2.palette ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(CommandPalette, { key: ui2.palette.token, start: ui2.palette.query, token: ui2.palette.token, roster, actions, useSessions }), document.body) : null,
+- ui2.exchange ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null,
+- ui2.voice ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null,
+- menu ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null,
+- settings ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null,
+- remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null,
+- memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null,
+- inbox ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null,
+- board ? (0, import_react_dom11.createPortal)((0, import_react46.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null
+- );
+-}
+-
+-// src/client/read-page.js
+-var READ_LIMITS = {
+- chars: 4e5,
+- nodes: 15e4,
+- depth: 400,
+- ms: 1500,
+- items: 3e3,
+- tableRows: 5e3,
+- tableCols: 30,
+- frameDepth: 3
+-};
+-function readPage(limits) {
+- const started = performance.now();
+- const SKIP = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "head", "title", "meta", "link", "svg", "object", "embed", "audio", "video", "source", "track", "map", "datalist", "option", "optgroup"]);
+- const ITEM = 'a[href], button, input:not([type=hidden]), textarea, select, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=switch], [role=radio], [contenteditable=""], [contenteditable=true]';
+- const ROLES = { a: "link", button: "button", select: "select", textarea: "textbox", summary: "button" };
+- const INPUT_ROLES = { checkbox: "checkbox", radio: "radio", submit: "button", button: "button", reset: "button", image: "button", search: "searchbox", range: "slider", file: "button", color: "button" };
+- const NO_VALUE_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio", "submit", "button", "reset", "image", "file", "color"]);
+- const SECRET_HINT = /(^|[-_\s])(cvv|cvc|csc|cc-?(num|number|csc|exp)|card-?(num|number)|otp|one-?time-?code|pin)([-_\s]|$)/i;
+- const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|flow-root|table-caption|table-row-group|table-header-group|table-footer-group|table-row|-webkit-box)$/;
+- const INLINE_BLOCK = /^(inline-block|inline-flex|inline-grid|inline-table|table-cell|-webkit-inline-box)$/;
+- const SEMANTIC = /^(ul|ol|table|pre|blockquote|h[1-6]|code|details)$/;
+- const items = [];
+- const stash = [];
+- const stats = { nodes: 0, frames: 0 };
+- let chars = 0;
+- let stop = "";
+- const collapse = (value) => String(value || "").replace(/\s+/g, " ").trim();
+- const isSecret = (el) => {
+- if (el.localName !== "input" && el.localName !== "textarea") return false;
+- const auto = String(el.getAttribute("autocomplete") || "").toLowerCase();
+- return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`);
+- };
+- const keep = (text3, end = "") => {
+- if (text3 === "") return "";
+- stash.push(text3);
+- return `
+-
+-${stash.length - 1}${end}
+-
+-`;
+- };
+- const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)]));
+- const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim();
+- const block = (text3) => expand(flow(text3));
+- const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim();
+- const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el);
+- const childrenOf = (el) => {
+- if (el.shadowRoot) return el.shadowRoot.childNodes;
+- if (el.localName === "slot" && typeof el.assignedNodes === "function") {
+- const assigned = el.assignedNodes();
+- if (assigned.length > 0) return assigned;
++ const oldest2 = Math.min(monthOf(hourMs(first)), addMonths(monthOf(hourMs(view.now)), -12));
++ const ranked2 = (map) => [...map].filter(([, sum]) => sum.total > 0).sort((a, b) => b[1].total - a[1].total);
++ let series2;
++ if (face2) {
++ series2 = [{ key: "all", name: face2.name, fill: soften(face2.paint) }];
++ } else {
++ if (by === "bot") {
++ const bots = ranked2(all2.owners).filter(([at]) => isBot2(at));
++ series2 = bots.slice(0, TOP_SERIES).map(([at]) => ({ key: `o${at}`, name: faces2[at].name, paint: faces2[at].paint, pick: { kind: "owner", key: view.owners[at].key } }));
++ if (bots.length > TOP_SERIES) series2.push({ key: "more", name: t("More Bots"), paint: "var(--bt-us-more)" });
++ if (ranked2(all2.owners).some(([at]) => !isBot2(at))) series2.push({ key: "rest", name: t("Other"), paint: "var(--bt-us-rest)" });
++ } else {
++ const used = ranked2(all2.models);
++ series2 = used.slice(0, TOP_SERIES).map(([at]) => ({ key: `m${at}`, name: models2[at].name, paint: models2[at].paint, pick: { kind: "model", key: view.models[at].key } }));
++ if (used.length > TOP_SERIES) series2.push({ key: "more", name: t("More models"), paint: "var(--bt-us-more)" });
++ }
++ const repeats = /* @__PURE__ */ new Map();
++ for (const entry of series2) {
++ const seen = repeats.get(entry.paint) ?? 0;
++ repeats.set(entry.paint, seen + 1);
++ entry.fill = soften(entry.paint, [1, 0.6, 0.36][seen % 3]);
++ }
+ }
+- return el.childNodes;
+- };
+- const over = () => {
+- if (stop) return true;
+- if (chars > limits.chars) stop = `the text passed ${limits.chars} characters`;
+- else if (stats.nodes > limits.nodes) stop = `the page has more than ${limits.nodes} nodes`;
+- else if (stats.nodes % 512 === 0 && performance.now() - started > limits.ms) stop = `reading took longer than ${limits.ms} ms`;
+- return stop !== "";
+- };
+- const roleOf = (el) => el.getAttribute("role") || ROLES[el.localName] || (el.localName === "input" ? INPUT_ROLES[el.type] || "textbox" : el.isContentEditable ? "textbox" : el.localName);
+- const sized = (el) => {
+- const box = el.getBoundingClientRect();
+- return box.width > 0 && box.height > 0;
+- };
+- const itemFor = (el, style) => {
+- if (items.length >= limits.items || style.visibility !== "visible" || !el.matches(ITEM) || !sized(el)) return null;
+- const item = { role: roleOf(el) };
+- const secret = isSecret(el);
+- if (el.href) item.href = String(el.href);
+- if ((el.localName === "input" || el.localName === "textarea") && !secret && !NO_VALUE_TYPES.has(el.type) && el.value) item.value = String(el.value).slice(0, 120);
+- if (el.localName === "select" && el.selectedOptions?.length) item.value = collapse([...el.selectedOptions].map((option) => option.label || option.text).join(", ")).slice(0, 120);
+- if (el.checked) item.checked = true;
+- if (el.disabled) item.disabled = true;
+- items.push(item);
+- return { item, number: items.length, secret };
+- };
+- const nameOf = (el, text3, secret) => collapse(
+- el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || ""
+- ).slice(0, 80);
+- const fenced = (code, lang) => {
+- const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length));
+- const fence = "`".repeat(longest + 1);
+- return `${fence}${lang}
+-${code.replace(/\n+$/, "")}
+-${fence}`;
+- };
+- const langOf = (el) => {
+- const names = `${el.className || ""} ${el.querySelector?.("code")?.className || ""}`;
+- return /(?:^|\s)(?:lang|language)-([\w+#.-]+)/.exec(names)?.[1] || "";
+- };
+- const list = (el, ctx) => {
+- const ordered = el.localName === "ol";
+- let number = ordered ? Number(el.getAttribute("start") || 1) || 1 : 1;
+- const lines = [];
+- for (const child of childrenOf(el)) {
+- if (over()) break;
+- if (child.nodeType !== 1 || child.localName !== "li") {
+- const loose = block(render(child, ctx));
+- if (loose) lines.push(loose);
+- continue;
+- }
+- const style = styleOf(child);
+- if (style.display === "none") continue;
+- let body2 = block(children(child, { ...ctx, depth: ctx.depth + 1, shown: style.visibility === "visible" }));
+- if (!body2.includes("```")) body2 = body2.replace(/\n{2,}/g, "\n");
+- const marker = ordered ? `${number++}. ` : "- ";
+- if (body2 === "") continue;
+- const pad2 = " ".repeat(marker.length);
+- lines.push(body2.split("\n").map((line2, index) => (index === 0 ? marker : line2 ? pad2 : "") + line2).join("\n"));
+- }
+- return keep(lines.join("\n"));
+- };
+- const cellText = (cell, ctx) => block(children(cell, ctx)).replace(/\n+/g, "
").replace(/\|/g, "\\|");
+- const layoutTable = (table, rows) => {
+- const role = table.getAttribute("role");
+- if (role === "presentation" || role === "none") return true;
+- if (table.querySelector("table")) return true;
+- return !rows.some((row) => row.cells.length > 1);
+- };
+- const tableMarkdown = (table, ctx) => {
+- const rows = [...table.rows].filter((row) => styleOf(row).display !== "none");
+- const caption = table.caption ? oneLine(children(table.caption, ctx)) : "";
+- if (layoutTable(table, rows)) {
+- return rows.map((row) => [...row.cells].map((cell) => `
+-
+-${children(cell, ctx)}
+-
+-`).join("")).join("");
+- }
+- const grid = [];
+- const carry = [];
+- let width = 0;
+- for (const row of rows.slice(0, limits.tableRows + 1)) {
+- if (over()) break;
+- const line2 = [];
+- let column = 0;
+- const place = () => {
+- while (carry[column]?.left > 0) {
+- line2[column] = carry[column].text;
+- carry[column].left -= 1;
+- column += 1;
+- }
+- };
+- for (const cell of row.cells) {
+- place();
+- if (styleOf(cell).display === "none") continue;
+- const text3 = cellText(cell, ctx);
+- const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1));
+- const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1));
+- for (let offset = 0; offset < span; offset += 1) {
+- line2[column] = offset === 0 ? text3 : "";
+- if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 };
+- column += 1;
+- }
+- }
+- place();
+- width = Math.max(width, line2.length);
+- grid.push(line2);
++ const tipSeries2 = face2 ? PARTS.map(([key, strength]) => ({ key, name: partLabel(key), fill: soften(face2.paint, strength) })) : series2;
++ const fillOf2 = new Map(series2.map((entry) => [entry.key, entry.fill]));
++ const shownKeys = new Set(series2.map((entry) => entry.key));
++ const columns2 = part2.buckets.map((bucket) => {
++ const parts = face2 ? [["all", bucket.sum.total]] : series2.map((entry) => [entry.key, entry.key === "more" ? [...bucket.stacks].filter(([key]) => key !== "rest" && !shownKeys.has(key)).reduce((sum, [, value]) => sum + value, 0) : bucket.stacks.get(entry.key) ?? 0]);
++ const tip = face2 ? PARTS.map(([key]) => [key, bucket.sum[key]]) : parts;
++ return { key: bucket.key, tick: tickOf(range, bucket), title: titleOf(range, bucket), total: bucket.sum.total, parts, tip };
++ });
++ return { faces: faces2, models: models2, ownerAt: ownerAt2, modelAt, face: face2, keep: keep2, all: all2, part: part2, series: series2, tipSeries: tipSeries2, fillOf: fillOf2, columns: columns2, ranked: ranked2, isBot: isBot2, activeDays: activeDays2, oldest: oldest2 };
++ }, [view, rangeId, span, by, focusId, roster]);
++ const root = (0, import_react38.useRef)(null);
++ (0, import_react38.useEffect)(() => {
++ root.current?.closest(".bt-settings-scroll")?.scrollTo?.({ top: 0 });
++ }, [focusId]);
++ if (!view) {
++ return error ? (0, import_react38.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react38.createElement)(Skeleton2);
++ }
++ if (view.rows.length === 0) {
++ return (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us" },
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-empty" },
++ (0, import_react38.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }),
++ (0, import_react38.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")),
++ (0, import_react38.createElement)("span", null, t("Token usage shows up here after a model is called."))
++ )
++ );
++ }
++ const { faces, models, ownerAt, face, keep, all, part, series, tipSeries, fillOf, columns, ranked, isBot, activeDays, oldest } = shaped;
++ const pick2 = (next) => actions.openSettings("usage", `${next.kind}:${next.key}`);
++ const chooseRange = (id) => {
++ if (id !== "custom") {
++ setRangeId(id);
++ setPicking(false);
++ return;
+ }
+- width = Math.min(width, limits.tableCols);
+- if (grid.length === 0 || width === 0) return caption ? `
+-
+-${caption}
+-
+-` : "";
+- const format = (cells) => `| ${Array.from({ length: width }, (_, index) => cells[index] ?? "").join(" | ")} |`;
+- const out = [format(grid[0]), `|${" --- |".repeat(width)}`, ...grid.slice(1).map(format)];
+- if (rows.length > limits.tableRows + 1) out.push(`… (table cut: ${rows.length - limits.tableRows - 1} more rows)`);
+- return `${caption ? `
+-
+-Table: ${caption}` : ""}${keep(out.join("\n"))}`;
++ if (span) setRangeId("custom");
++ setPicking((open) => !open);
+ };
+- const frame = (el, ctx) => {
+- let doc = null;
+- try {
+- doc = el.contentDocument;
+- } catch {
+- doc = null;
+- }
+- const address = (() => {
+- try {
+- return doc?.location?.href || el.src || el.getAttribute("src") || "about:blank";
+- } catch {
+- return el.src || "about:blank";
+- }
+- })();
+- if (!doc) return `
+-
+-[iframe (cross-origin, not readable): ${address}]
+-
+-`;
+- if (ctx.frames >= limits.frameDepth) return `
+-
+-[iframe (nested too deep, not read): ${address}]
+-
+-`;
+- stats.frames += 1;
+- const title = collapse(el.title || doc.title);
+- const body2 = doc.body ? block(children(doc.body, { ...ctx, frames: ctx.frames + 1, depth: ctx.depth + 1, shown: true, pre: false })) : "";
+- return keep(`[iframe: ${title ? `${title} (${address})` : address}]
+-${body2 || "(no text)"}
+-[iframe end]`);
++ const pickSpan = (from, to) => {
++ setSpan({ from, to });
++ setRangeId("custom");
++ setPicking(false);
+ };
+- function children(el, ctx) {
+- let out = "";
+- for (const child of childrenOf(el)) {
+- if (over()) break;
+- out += render(child, ctx);
+- }
+- return out;
++ const rangeOptions = [
++ ...RANGES.map((item) => [item.id, rangeLabel(item.id)]),
++ ["custom", rangeId === "custom" && span ? spanText(span.from, span.to) : t("Custom"), CalendarIcon]
++ ];
++ const whole = part.total.total;
++ const ownerRow = ([at, sum], index, total = whole) => (0, import_react38.createElement)(Row3, {
++ key: `o${at}`,
++ kind: faces[at].kind,
++ glyph: faces[at].glyph(32),
++ name: faces[at].name,
++ sub: faces[at].sub,
++ sum,
++ whole: total,
++ index,
++ fill: face ? soften(face.paint, 0.8) : fillOf.get(`o${at}`) ?? soften(faces[at].paint),
++ onClick: () => pick2({ kind: "owner", key: view.owners[at].key })
++ });
++ const modelRow = ([at, sum], index) => (0, import_react38.createElement)(Row3, {
++ key: `m${at}`,
++ kind: "model",
++ glyph: models[at].glyph(32),
++ name: models[at].name,
++ sub: models[at].sub,
++ sum,
++ whole,
++ index,
++ fill: face ? soften(face.paint, 0.8) : fillOf.get(`m${at}`) ?? soften(models[at].paint),
++ onClick: () => pick2({ kind: "model", key: view.models[at].key })
++ });
++ const nothing = (0, import_react38.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range."));
++ let body;
++ if (face) {
++ const kinds = ranked(part.kinds);
++ const usedModels = ranked(part.models);
++ const owners = ranked(part.owners);
++ body = [
++ (0, import_react38.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }),
++ (0, import_react38.createElement)(
++ "div",
++ { key: "sheet", className: "bt-us-sheet" },
++ (0, import_react38.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }),
++ (0, import_react38.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }),
++ (0, import_react38.createElement)(
++ "div",
++ { key: "pair", className: "bt-us-pair" },
++ ownerAt >= 0 ? (0, import_react38.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react38.createElement)(ListCard, { title: t("Who used it"), count: owners.length, index: 2 }, owners.length > 0 ? owners.map((entry, index) => ownerRow(entry, index)) : nothing),
++ (0, import_react38.createElement)(
++ ListCard,
++ { title: t("What for"), index: 3 },
++ kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react38.createElement)(Row3, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing
++ )
++ ),
++ (0, import_react38.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 })
++ )
++ ];
++ } else {
++ const owners = ranked(all.owners);
++ const bots = owners.filter(([at]) => isBot(at));
++ const rest = owners.filter(([at]) => !isBot(at));
++ const usedModels = ranked(all.models);
++ body = [
++ (0, import_react38.createElement)(
++ "div",
++ { key: "sheet", className: "bt-us-sheet" },
++ (0, import_react38.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }),
++ (0, import_react38.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }),
++ (0, import_react38.createElement)(
++ "div",
++ { key: "pair", className: "bt-us-pair" },
++ (0, import_react38.createElement)(
++ ListCard,
++ { title: t("Bots"), count: bots.length, index: 2 },
++ owners.length > 0 ? null : nothing,
++ bots.map((entry, index) => ownerRow(entry, index)),
++ bots.length > 0 && rest.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-us-split", role: "separator" }) : null,
++ rest.map((entry, index) => ownerRow(entry, bots.length + index))
++ ),
++ (0, import_react38.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing)
++ ),
++ (0, import_react38.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 })
++ )
++ ];
+ }
+- function render(node, ctx) {
+- stats.nodes += 1;
+- if (node.nodeType === 3) {
+- if (!ctx.shown) return "";
+- const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
+- const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " ");
+- chars += text3.length;
+- return text3;
+- }
+- if (node.nodeType !== 1) return "";
+- const el = node;
+- const tag = el.localName;
+- if (SKIP.has(tag) || ctx.depth > limits.depth || over()) return "";
+- const style = styleOf(el);
+- const display = style.display;
+- if (display === "none" || style.contentVisibility === "hidden") return "";
+- const shown = style.visibility === "visible";
+- const keepsSpace = String(style.whiteSpace || "").startsWith("pre") || style.whiteSpace === "break-spaces";
+- const next = { ...ctx, depth: ctx.depth + 1, shown };
+- if (display === "contents" && !SEMANTIC.test(tag) && !el.matches(ITEM)) return children(el, next);
+- if (style.overflowX !== "visible" || style.overflowY !== "visible") {
+- const box = el.getBoundingClientRect();
+- if (box.width === 0 || box.height === 0) return "";
+- }
+- if (tag === "br") return "\n";
+- if (tag === "hr") return "\n\n---\n\n";
+- if (tag === "img") {
+- const alt = collapse(el.getAttribute("alt"));
+- return shown && alt ? `[image: ${alt}]` : "";
++ return (0, import_react38.createElement)(
++ "div",
++ { ref: root, className: "bt-us", key: focusId ?? "all" },
++ (0, import_react38.createElement)(
++ "div",
++ { className: "bt-us-top" },
++ (0, import_react38.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }),
++ view.scanning ? (0, import_react38.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null,
++ picking ? (0, import_react38.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null
++ ),
++ error ? (0, import_react38.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null,
++ body
++ );
++}
++var USAGE_CSS = `
++.bt-us{--bt-us-ease:cubic-bezier(.22,1,.36,1);--bt-us-rest:color-mix(in srgb,var(--bt-ink) 30%,var(--bt-card));--bt-us-more:color-mix(in srgb,var(--bt-ink) 16%,var(--bt-card));display:flex;flex-direction:column;gap:14px;padding-bottom:4px}
++.bt-us-top{position:relative;z-index:4;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
++.bt-us-tabs{position:relative;display:inline-flex;flex:none;padding:3px;border-radius:999px;background:var(--bt-hover)}
++.bt-us-tabs button{position:relative;z-index:1;display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 12px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:26px;white-space:nowrap;cursor:pointer;transition:color .15s ease}
++.bt-us-tabs button svg{flex:none;width:13px;height:13px;margin-left:-2px}
++.bt-us-pick{position:absolute;top:calc(100% + 8px);left:0;max-width:100%;box-sizing:border-box;padding:14px 16px 12px;border-radius:18px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 22px 50px -18px rgba(0,0,0,.32),0 2px 8px rgba(0,0,0,.05);transform-origin:24px 0;animation:bt-us-drop .2s var(--bt-us-ease)}
++.bt-us-pick-quick{display:flex;gap:6px;margin-bottom:12px}
++.bt-us-pick-quick button{height:26px;padding:0 11px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease}
++.bt-us-pick-quick button:hover{background:var(--bt-active);color:var(--bt-ink)}
++.bt-us-pick-quick button[aria-pressed=true]{background:color-mix(in srgb,var(--bt-accent) 14%,transparent);color:var(--bt-accent)}
++.bt-us-pick-months{display:flex;flex-wrap:wrap;gap:8px 28px}
++.bt-us-month{width:252px;animation:bt-us-fade .22s ease}
++.bt-us-month-head{position:relative;display:flex;align-items:center;justify-content:center;height:28px;margin-bottom:4px;font-size:13px;line-height:18px;font-weight:600}
++.bt-us-month-nav{position:absolute;top:0;width:28px;height:28px;padding:0;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease}
++.bt-us-month-nav[data-side=left]{left:0}
++.bt-us-month-nav[data-side=right]{right:0}
++.bt-us-month-nav:hover:not(:disabled){background:var(--bt-hover);color:var(--bt-ink)}
++.bt-us-month-nav:disabled{opacity:.3;cursor:default}
++.bt-us-weekdays,.bt-us-days{display:grid;grid-template-columns:repeat(7,36px)}
++.bt-us-weekdays span{height:24px;font-size:11px;line-height:24px;text-align:center;color:var(--bt-ink-3)}
++.bt-us-day{position:relative;height:36px;padding:0;border:0;background:none;color:var(--bt-ink);font:inherit;font-size:12px;font-variant-numeric:tabular-nums;cursor:pointer}
++.bt-us-day::before{content:'';position:absolute;top:4px;bottom:4px;left:0;right:0;transition:background-color .12s ease}
++.bt-us-day>b{position:relative;z-index:1;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;font-weight:400;transition:background-color .12s ease,color .12s ease}
++.bt-us-day:hover:not(:disabled)>b{background:var(--bt-hover)}
++.bt-us-day[data-today]>b{color:var(--bt-accent);font-weight:600}
++.bt-us-day[data-has]::after{content:'';position:absolute;z-index:2;left:50%;bottom:6px;width:3px;height:3px;margin-left:-1.5px;border-radius:50%;background:var(--bt-ink-3)}
++.bt-us-day[data-in]::before{background:color-mix(in srgb,var(--bt-accent) 13%,transparent)}
++.bt-us-day[data-in][data-l]::before{left:4px;border-radius:14px 0 0 14px}
++.bt-us-day[data-in][data-r]::before{right:4px;border-radius:0 14px 14px 0}
++.bt-us-day[data-in][data-l][data-r]::before{border-radius:14px}
++.bt-us-day[data-in][data-start]::before{left:50%;border-radius:0}
++.bt-us-day[data-in][data-end]::before{right:50%;border-radius:0}
++.bt-us-day[data-in][data-start][data-r]::before,.bt-us-day[data-in][data-end][data-l]::before,.bt-us-day[data-in][data-start][data-end]::before{display:none}
++.bt-us-day[data-start]>b,.bt-us-day[data-end]>b,.bt-us-day[data-start]:hover>b,.bt-us-day[data-end]:hover>b{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
++.bt-us-day[data-start][data-has]::after,.bt-us-day[data-end][data-has]::after{background:color-mix(in srgb,var(--bt-accent-ink,#fff) 75%,transparent)}
++.bt-us-day:disabled{color:var(--bt-ink-3);opacity:.45;cursor:default}
++.bt-us-pick-foot{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:8px;padding-top:10px;border-top:1px solid var(--bt-line);font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap}
++.bt-us-pick-foot b{color:var(--bt-ink);font-weight:500}
++.bt-us-tabs button:hover,.bt-us-tabs button[aria-pressed=true]{color:var(--bt-ink)}
++.bt-us-tabs:not([data-glide]) button[aria-pressed=true]{background:var(--bt-card)}
++.bt-us-tabs>.bt-thumb{top:3px;bottom:3px;border-radius:999px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08),0 0 0 .5px var(--bt-line-2)}
++.bt-us-note{display:inline-flex;align-items:center;gap:8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
++.bt-us-note::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--bt-accent);animation:bt-us-pulse 1.4s ease-in-out infinite}
++.bt-us-sheet{display:flex;flex-direction:column;gap:4px}
++.bt-us-sheet>.bt-us-stats,.bt-us-sheet>.bt-us-card,.bt-us-sheet>.bt-us-pair>.bt-us-card{border-radius:0}
++.bt-us-sheet>:first-child{border-radius:16px 16px 0 0}
++.bt-us-sheet>:last-child{border-radius:0 0 16px 16px}
++.bt-us-stats{display:grid;grid-template-columns:1.6fr 1fr 1fr 1fr;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards}
++.bt-us-stat{display:flex;flex-direction:column;justify-content:flex-end;gap:6px;min-width:0;padding:16px 18px}
++.bt-us-stat+.bt-us-stat{border-left:1px solid var(--bt-line)}
++.bt-us-stat-label{overflow:hidden;font-size:12px;line-height:16px;color:var(--bt-ink-3);white-space:nowrap;text-overflow:ellipsis}
++.bt-us-stat-value{display:flex;align-items:baseline;gap:6px;min-width:0;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;font-variant-numeric:tabular-nums;white-space:nowrap}
++.bt-us-stat-main .bt-us-stat-value{font-size:34px;line-height:40px;letter-spacing:-.025em}
++.bt-us-stat-value small{font-size:13px;font-weight:400;letter-spacing:0;color:var(--bt-ink-3)}
++.bt-us-delta{align-self:center;margin-left:2px;padding:0 8px;border-radius:999px;background:var(--bt-tag-bg);color:var(--bt-ink-2);font-size:11px;line-height:20px;font-weight:500;letter-spacing:0}
++.bt-us-card{display:flex;flex-direction:column;gap:12px;min-width:0;padding:14px 18px 16px;border-radius:16px;border:1px solid var(--bt-line);background:var(--bt-card);animation:bt-us-in .45s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 45ms)}
++.bt-us-card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:32px}
++.bt-us-card-head h3{display:flex;align-items:baseline;gap:8px;min-width:0;margin:0;font-size:13px;line-height:18px;font-weight:600}
++.bt-us-card-head h3 span,.bt-us-head-note{font-size:12px;font-weight:400;color:var(--bt-ink-3)}
++.bt-us-chart-body{display:flex;flex-direction:column;gap:8px;padding-top:6px}
++.bt-us-plot{position:relative;height:200px;margin-left:40px}
++.bt-us-grid{position:absolute;left:0;right:0;border-top:1px dashed var(--bt-line-2);pointer-events:none}
++.bt-us-grid.bt-us-base{border-top:1px solid var(--bt-line-2)}
++.bt-us-grid span{position:absolute;right:calc(100% + 8px);top:-8px;font-size:10px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums;white-space:nowrap}
++.bt-us-cols{position:absolute;inset:0;display:flex;align-items:flex-end}
++.bt-us-col{position:relative;flex:1 1 0;min-width:0;height:100%;display:flex;align-items:flex-end;justify-content:center}
++.bt-us-stack{display:flex;flex-direction:column-reverse;gap:2px;width:56%;min-width:4px;max-width:28px;transform-origin:50% 100%;animation:bt-us-grow .6s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 14ms);transition:height .45s var(--bt-us-ease),opacity .18s ease}
++.bt-us-stack>i{flex:1 1 0;min-height:2px;border-radius:5px;transition:flex-grow .45s var(--bt-us-ease)}
++.bt-us-plot[data-hover] .bt-us-col:not([data-on]) .bt-us-stack{opacity:.3}
++.bt-us-stub{width:4px;height:4px;margin-bottom:-2px;border-radius:50%;background:var(--bt-line-2)}
++.bt-us-ticks{display:flex;margin-left:40px;font-size:11px;line-height:14px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums}
++.bt-us-ticks span{flex:1 1 0;min-width:0;text-align:center;white-space:nowrap}
++.bt-us-ticks span[data-now]{color:var(--bt-ink);font-weight:500}
++.bt-us-tip{position:absolute;top:6px;z-index:3;min-width:160px;max-width:240px;padding:10px 12px;border-radius:12px;border:.5px solid var(--bt-line-2);background:color-mix(in srgb,var(--bt-card) 90%,transparent);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);box-shadow:0 14px 34px -12px rgba(0,0,0,.28);font-size:12px;line-height:16px;pointer-events:none;animation:bt-us-pop .16s var(--bt-us-ease)}
++.bt-us-tip-title{color:var(--bt-ink-3)}
++.bt-us-tip-total{margin:2px 0 8px;font-size:15px;line-height:20px;font-weight:600;font-variant-numeric:tabular-nums}
++.bt-us-tip-total:last-child{margin-bottom:0}
++.bt-us-tip-row{display:flex;align-items:center;gap:8px;color:var(--bt-ink-2)}
++.bt-us-tip-row+.bt-us-tip-row{margin-top:5px}
++.bt-us-tip-row span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-us-tip-row b{font-weight:500;color:var(--bt-ink);font-variant-numeric:tabular-nums}
++.bt-us-legend{display:flex;flex-wrap:wrap;gap:6px}
++.bt-us-key{display:inline-flex;align-items:center;gap:6px;min-width:0;height:24px;padding:0 10px 0 8px;border:0;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2);font:inherit;font-size:12px;line-height:24px}
++button.bt-us-key{cursor:pointer;transition:background-color .12s ease,color .12s ease}
++button.bt-us-key:hover{background:var(--bt-active);color:var(--bt-ink)}
++.bt-us-dot{flex:none;width:8px;height:8px;border-radius:50%}
++.bt-us-pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:4px}
++.bt-us-rows{display:flex;flex-direction:column;margin:0 -8px}
++.bt-us-row{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;padding:8px;border:0;border-radius:12px;background:none;color:var(--bt-ink);font:inherit;text-align:left;animation:bt-us-in .4s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 90ms)}
++button.bt-us-row{cursor:pointer;transition:background-color .12s ease}
++button.bt-us-row:hover{background:var(--bt-hover)}
++.bt-us-row-glyph{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px}
++.bt-us-row-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:7px}
++.bt-us-row-line{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:13px;line-height:18px}
++.bt-us-row-name{flex:0 1 auto;min-width:0;overflow:hidden;font-weight:500;text-overflow:ellipsis;white-space:nowrap}
++.bt-us-row-sub{flex:0 1 auto;min-width:0;overflow:hidden;font-size:12px;color:var(--bt-ink-3);text-overflow:ellipsis;white-space:nowrap}
++.bt-us-track{display:block;height:4px;border-radius:999px;background:var(--bt-hover);overflow:hidden}
++.bt-us-track>span{display:block;height:100%;border-radius:inherit;transform-origin:0 50%;animation:bt-us-fill .7s var(--bt-us-ease) backwards;animation-delay:calc(var(--i,0) * 35ms + 140ms);transition:width .45s var(--bt-us-ease)}
++.bt-us-row-num{flex:none;display:flex;flex-direction:column;align-items:flex-end;gap:1px;min-width:52px;font-variant-numeric:tabular-nums}
++.bt-us-row-num b{font-size:13px;line-height:18px;font-weight:600}
++.bt-us-row-num small{font-size:11px;line-height:14px;color:var(--bt-ink-3)}
++.bt-us-split{height:1px;margin:6px 8px;background:var(--bt-line)}
++.bt-us-none{padding:18px 8px;font-size:12px;color:var(--bt-ink-3);text-align:center}
++.bt-us-glyph{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2)}
++.bt-us-model{display:inline-flex;align-items:center;justify-content:center;border-radius:30%;font-weight:600;line-height:1}
++.bt-us-profile{display:flex;align-items:center;gap:20px;padding:6px 4px 2px;animation:bt-us-in .45s var(--bt-us-ease) backwards}
++.bt-us-ring{position:relative;flex:none;width:96px;height:96px}
++.bt-us-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
++.bt-us-ring>svg circle{fill:none;stroke-width:4}
++.bt-us-ring-track{stroke:var(--bt-line)}
++.bt-us-ring-arc{stroke-linecap:round;transition:stroke-dasharray .6s var(--bt-us-ease);animation:bt-us-ring 1s var(--bt-us-ease) .1s backwards}
++.bt-us-ring-face{position:absolute;inset:0;display:flex;align-items:center;justify-content:center}
++.bt-us-profile-copy{display:flex;flex-direction:column;gap:3px;min-width:0}
++.bt-us-profile-copy h3{margin:0;overflow:hidden;font-size:22px;line-height:28px;font-weight:600;letter-spacing:-.01em;text-overflow:ellipsis;white-space:nowrap}
++.bt-us-profile-sub{font-size:13px;line-height:18px;color:var(--bt-ink-2)}
++.bt-us-profile-share{font-size:12px;line-height:16px;color:var(--bt-ink-3)}
++.bt-us-heat{gap:8px}
++.bt-us-heat-months,.bt-us-heat-grid{display:grid;grid-template-columns:repeat(${HEAT_WEEKS},1fr);gap:3px}
++.bt-us-heat-months{height:14px;font-size:10px;line-height:14px;color:var(--bt-ink-3)}
++.bt-us-heat-months span{white-space:nowrap}
++.bt-us-heat-plot{position:relative}
++.bt-us-heat-grid{grid-template-rows:repeat(7,auto);grid-auto-flow:column}
++.bt-us-heat-grid .bt-us-cell[data-on]{outline:1.5px solid var(--bt-ink-2);outline-offset:1px}
++.bt-us-heat-tip{min-width:0;white-space:nowrap}
++.bt-us-cell{position:relative;aspect-ratio:1;border-radius:3px;background:var(--bt-hover);overflow:hidden}
++.bt-us-heat-grid .bt-us-cell{animation:bt-us-cell .5s var(--bt-us-ease) backwards;animation-delay:calc(var(--w,0) * 7ms + 120ms)}
++.bt-us-cell>i{position:absolute;inset:0}
++.bt-us-cell[data-today]{outline:1.5px solid var(--bt-ink-3);outline-offset:1px}
++.bt-us-cell[data-future]{visibility:hidden}
++.bt-us-heat-scale{display:flex;align-items:center;justify-content:flex-end;gap:4px;font-size:11px;line-height:14px;color:var(--bt-ink-3)}
++.bt-us-heat-scale .bt-us-cell{width:10px}
++.bt-us-skel{border-radius:16px;background:linear-gradient(90deg,var(--bt-hover) 0%,var(--bt-active) 50%,var(--bt-hover) 100%);background-size:200% 100%;animation:bt-us-shine 1.4s ease-in-out infinite}
++.bt-us-skel-sheet>.bt-us-skel:first-child{border-radius:16px 16px 0 0}
++.bt-us-skel-sheet>.bt-us-skel:not(:first-child){border-radius:0}
++.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:first-child{border-radius:0 0 0 16px}
++.bt-us-skel-sheet>.bt-us-pair>.bt-us-skel:last-child{border-radius:0 0 16px 0}
++.bt-us-empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:72px 16px;text-align:center;font-size:13px;line-height:18px;color:var(--bt-ink-3)}
++.bt-us-empty b{margin-top:6px;font-size:15px;font-weight:600;color:var(--bt-ink)}
++@keyframes bt-us-in{from{opacity:0;transform:translateY(6px)}}
++@keyframes bt-us-grow{from{transform:scaleY(0)}}
++@keyframes bt-us-fill{from{transform:scaleX(0)}}
++@keyframes bt-us-pop{from{opacity:0;transform:translateY(3px) scale(.97)}}
++@keyframes bt-us-drop{from{opacity:0;transform:translateY(-4px) scale(.96)}}
++@keyframes bt-us-fade{from{opacity:0}}
++@keyframes bt-us-cell{from{opacity:0;transform:scale(.5)}}
++@keyframes bt-us-ring{from{stroke-dasharray:0 ${Math.ceil(RING2)}}}
++@keyframes bt-us-pulse{50%{opacity:.25}}
++@keyframes bt-us-shine{from{background-position:200% 0}to{background-position:-200% 0}}
++@media (prefers-reduced-motion:reduce){.bt-us *,.bt-us *::before{animation:none!important;transition:none!important}}
++`;
++
++// src/client/connectors.js
++var import_react39 = require("react");
++var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new";
++var COMPOSIO_KEY_URL = "https://platform.composio.dev/";
++var FEISHU_WEBHOOK_URL = "https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot";
++var APPS = [
++ ["gmail", "Gmail"],
++ ["googlecalendar", "Google Calendar"],
++ ["googledrive", "Google Drive"],
++ ["googledocs", "Google Docs"],
++ ["googlesheets", "Google Sheets"],
++ ["outlook", "Outlook"],
++ ["slack", "Slack"],
++ ["microsoft_teams", "Microsoft Teams"],
++ ["discord", "Discord"],
++ ["telegram", "Telegram"],
++ ["whatsapp", "WhatsApp"],
++ ["notion", "Notion"],
++ ["linear", "Linear"],
++ ["jira", "Jira"],
++ ["asana", "Asana"],
++ ["trello", "Trello"],
++ ["todoist", "Todoist"],
++ ["airtable", "Airtable"],
++ ["dropbox", "Dropbox"],
++ ["hubspot", "HubSpot"],
++ ["salesforce", "Salesforce"],
++ ["stripe", "Stripe"],
++ ["zoom", "Zoom"],
++ ["calendly", "Calendly"],
++ ["figma", "Figma"],
++ ["twitter", "X (Twitter)"],
++ ["youtube", "YouTube"]
++];
++var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug;
++var GitHubMark = () => (0, import_react39.createElement)(
++ "svg",
++ { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true },
++ (0, import_react39.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" })
++);
++var FeishuMark = () => (0, import_react39.createElement)(
++ "svg",
++ { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true },
++ (0, import_react39.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }),
++ (0, import_react39.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }),
++ (0, import_react39.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" })
++);
++var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark };
++var ABOUT = {
++ github: () => t("Repositories, issues, and pull requests"),
++ composio: () => t("Gmail, Slack, Calendar, Notion and many more apps, through one Composio key"),
++ feishu: () => t("Posts into one Feishu group through a custom bot webhook")
++};
++function statusText2(connector) {
++ if (connector.state === "connecting") return t("Connecting…");
++ if (connector.state === "error") return t("Could not connect: {error}", { error: connector.error ?? "" });
++ if (connector.state === "ready" && connector.kind === "channel") {
++ return connector.mode === "app" ? t("Connected: app, over the long connection") : t("Connected: custom bot webhook");
++ }
++ if (connector.state === "ready" && connector.apps?.length > 0) return t("Connected: {apps}", { apps: connector.apps.map(appName).join(", ") });
++ if (connector.state === "ready") return connector.account ? t("Connected as {account}", { account: connector.account }) : t("Connected");
++ return ABOUT[connector.id]?.() ?? "";
++}
++function CredentialField({ credential, value, onChange, onEnter, autoFocus }) {
++ const label = t(credential.label);
++ if (credential.masked) return (0, import_react39.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus });
++ return (0, import_react39.createElement)(
++ "label",
++ { className: "bt-q-field" },
++ (0, import_react39.createElement)("input", {
++ className: "bt-input",
++ type: "text",
++ value,
++ "aria-label": label,
++ placeholder: label,
++ spellCheck: false,
++ autoFocus,
++ onChange: (event) => onChange(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Enter") onEnter?.();
++ }
++ })
++ );
++}
++function AllowedSenders({ connector, actions, run, busy }) {
++ const [openId, setOpenId] = (0, import_react39.useState)("");
++ const allowed = connector.allowed ?? [];
++ const refused = connector.inbound?.refusedSenders ?? [];
++ const allow = (id) => void run(actions.connectorAllow(connector.id, id));
++ return (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-allowed" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")),
++ allowed.length === 0 && refused.length === 0 ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null,
++ allowed.map((entry) => (0, import_react39.createElement)(
++ "div",
++ { key: entry.openId, className: "bt-connector-person" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block"))
++ )),
++ refused.map((entry) => (0, import_react39.createElement)(
++ "div",
++ { key: entry.openId, className: "bt-connector-person" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow"))
++ )),
++ (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form-row" },
++ (0, import_react39.createElement)("input", {
++ className: "bt-input",
++ type: "text",
++ value: openId,
++ spellCheck: false,
++ "aria-label": t("Feishu open_id"),
++ placeholder: t("Paste an open_id to allow"),
++ onChange: (event) => setOpenId(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Enter" && openId.trim() !== "") {
++ allow(openId.trim());
++ setOpenId("");
++ }
++ }
++ }),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => {
++ allow(openId.trim());
++ setOpenId("");
++ } }, t("Allow"))
++ )
++ );
++}
++var APP_STATUS = { ready: () => t("Ready"), "sign-in": () => t("Needs sign-in") };
++function AppChip({ name, status, onRemove }) {
++ const label = APP_STATUS[status]?.();
++ return (0, import_react39.createElement)(
++ "button",
++ { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove },
++ name,
++ label ? (0, import_react39.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null
++ );
++}
++function AppPicker({ apps, onChange, search, statuses = {} }) {
++ const [query, setQuery] = (0, import_react39.useState)("");
++ const [found, setFound] = (0, import_react39.useState)(null);
++ const [failed, setFailed] = (0, import_react39.useState)(null);
++ const names = (0, import_react39.useRef)(new Map(APPS));
++ const asked = (0, import_react39.useRef)(0);
++ (0, import_react39.useEffect)(() => {
++ const text2 = query.trim();
++ if (text2 === "" || search === void 0) {
++ setFound(null);
++ setFailed(null);
++ return void 0;
+ }
+- if (tag === "canvas") {
+- const box = el.getBoundingClientRect();
+- return box.width >= 200 && box.height >= 100 ? "\n\n[canvas: drawn content, not readable]\n\n" : "";
++ const ask = ++asked.current;
++ const timer = setTimeout(() => {
++ search(text2).then((list) => {
++ if (ask !== asked.current) return;
++ for (const app of list) names.current.set(app.slug, app.name);
++ setFound(list);
++ setFailed(null);
++ }, (failure) => {
++ if (ask !== asked.current) return;
++ setFound(null);
++ setFailed(failure?.message ?? String(failure));
++ });
++ }, 250);
++ return () => clearTimeout(timer);
++ }, [query, search]);
++ const nameOf = (slug) => names.current.get(slug) ?? appName(slug);
++ const picked = new Set(apps);
++ const needle = query.trim().toLowerCase();
++ const local = APPS.filter(([slug, name]) => needle === "" || slug.includes(needle) || name.toLowerCase().includes(needle)).map(([slug, name]) => ({ slug, name }));
++ const results = (found ?? local).filter((app) => !picked.has(app.slug));
++ return (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-apps" },
++ apps.length > 0 ? (0, import_react39.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react39.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null,
++ (0, import_react39.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }),
++ failed ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null,
++ (0, import_react39.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react39.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => {
++ names.current.set(app.slug, app.name);
++ onChange([...apps, app.slug]);
++ } }, app.name)))
++ );
++}
++function AppStatusList({ apps, statuses }) {
++ if (apps.length === 0 || Object.keys(statuses).length === 0) return null;
++ return (0, import_react39.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react39.createElement)(
++ "li",
++ { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)),
++ (0, import_react39.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "")
++ )));
++}
++function ConnectorRow({ connector, roster, actions }) {
++ const [form, setForm] = (0, import_react39.useState)(false);
++ const [bots, setBots] = (0, import_react39.useState)(false);
++ const [token, setToken] = (0, import_react39.useState)("");
++ const [apps, setApps] = (0, import_react39.useState)(() => connector.apps ?? []);
++ const [picking, setPicking] = (0, import_react39.useState)(false);
++ const [busy, setBusy] = (0, import_react39.useState)(false);
++ const [error, setError] = (0, import_react39.useState)(null);
++ const [message, setMessage] = (0, import_react39.useState)("");
++ const run = (task, after) => {
++ setBusy(true);
++ setError(null);
++ return task.then(() => {
++ after?.();
++ }, (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false));
++ };
++ const gateway = connector.id === "composio";
++ const sender = connector.kind === "sender";
++ const channel = connector.kind === "channel";
++ const modes = connector.modes ?? [];
++ const [mode, setMode] = (0, import_react39.useState)(() => connector.mode ?? modes[0]?.id);
++ const [values, setValues] = (0, import_react39.useState)({});
++ const chosen = modes.find((entry) => entry.id === mode) ?? modes[0];
++ const field = (name) => values[name] ?? "";
++ const setField = (name, value) => setValues((current) => ({ ...current, [name]: value }));
++ const filled = chosen !== void 0 && chosen.credentials.every((credential) => field(credential.name).trim() !== "");
++ const [statuses, setStatuses] = (0, import_react39.useState)({});
++ const appsKey = (connector.apps ?? []).join(",");
++ (0, import_react39.useEffect)(() => {
++ if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) {
++ setStatuses({});
++ return void 0;
+ }
+- if (tag === "iframe" || tag === "frame") {
+- const box = el.getBoundingClientRect();
+- return tag === "iframe" && (box.width < 2 || box.height < 2) ? "" : frame(el, next);
++ let live = true;
++ actions.connectorAppStatus(connector.id).then((value) => {
++ if (live) setStatuses(value ?? {});
++ }, () => {
++ if (live) setStatuses({});
++ });
++ return () => {
++ live = false;
++ };
++ }, [gateway, connector.state, appsKey, picking]);
++ const search = (0, import_react39.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]);
++ const connect = () => {
++ if (busy) return;
++ if (channel) {
++ if (chosen === void 0 || !filled) return;
++ const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)]));
++ void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => {
++ setValues({});
++ setForm(false);
++ });
++ return;
+ }
+- const found = itemFor(el, style);
+- const mark = found ? `[#${found.number}]` : "";
+- if (tag === "input" || tag === "textarea" || tag === "select") {
+- if (found) found.item.name = nameOf(el, tag === "select" ? "" : el.labels?.[0] ? collapse(el.labels[0].textContent) : "", found.secret);
+- return mark ? ` ${mark} ` : "";
++ if (token.trim() === "") return;
++ if (gateway && apps.length === 0) {
++ setError(t("Pick at least one app"));
++ return;
+ }
+- let body2;
+- if (tag === "pre") {
+- const code = shown ? String(el.innerText || el.textContent || "") : "";
+- chars += code.length;
+- body2 = code.trim() ? keep(fenced(code, langOf(el))) : "";
+- } else if (tag === "table") {
+- body2 = tableMarkdown(el, next);
+- } else if (tag === "ul" || tag === "ol") {
+- body2 = list(el, next);
+- } else if (tag === "details" && !el.open) {
+- const summary = [...el.children].find((child) => child.localName === "summary");
+- body2 = summary ? render(summary, next) : "";
+- } else {
+- body2 = children(el, { ...next, pre: keepsSpace });
++ void run(actions.connectorConnect(connector.id, token, gateway ? apps : void 0), () => {
++ setToken("");
++ setForm(false);
++ });
++ };
++ const saveApps = () => {
++ if (busy) return;
++ if (apps.length === 0) {
++ setError(t("Pick at least one app"));
++ return;
+ }
+- if (found) found.item.name = nameOf(el, oneLine(body2), found.secret);
+- const level = /^h([1-6])$/.exec(tag)?.[1] || (el.getAttribute("role") === "heading" ? el.getAttribute("aria-level") || "2" : "");
+- if (level) {
+- const title = oneLine(body2);
+- return title ? `
++ void run(actions.connectorApps(connector.id, apps), () => setPicking(false));
++ };
++ const on = connector.state !== "off";
++ const canPost = sender || channel && connector.mode === "webhook";
++ const perBot = !sender && (!channel || connector.mode === "app");
++ const Mark = MARKS[connector.id];
++ const row = (0, import_react39.useRef)(null);
++ useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`);
++ return (0, import_react39.createElement)(
++ "div",
++ { ref: row, className: "bt-connector", "data-state": connector.state },
++ (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-head" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react39.createElement)(Mark) : null),
++ (0, import_react39.createElement)(
++ "span",
++ { className: "bt-connector-text" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-name" }, connector.name),
++ (0, import_react39.createElement)("span", { className: "bt-connector-status" }, statusText2(connector))
++ ),
++ (0, import_react39.createElement)(
++ "span",
++ { className: "bt-connector-actions" },
++ connector.state === "error" ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null,
++ on && gateway && !picking ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
++ setApps(connector.apps ?? []);
++ setPicking(true);
++ } }, t("Choose apps")) : null,
++ on && perBot ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null,
++ on ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect"))
++ )
++ ),
++ on && gateway && !picking ? (0, import_react39.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null,
++ on && perBot ? (0, import_react39.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null,
++ on && bots ? (0, import_react39.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null,
++ on && channel && connector.mode === "app" ? (0, import_react39.createElement)(AllowedSenders, { connector, actions, run, busy }) : null,
++ on && canPost ? (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form" },
++ (0, import_react39.createElement)("label", { className: "bt-q-field" }, (0, import_react39.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })),
++ (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form-row" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-link" }),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send"))
++ )
++ ) : null,
++ !on && form ? (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form" },
++ channel ? (0, import_react39.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react39.createElement)("button", {
++ key: entry.id,
++ type: "button",
++ className: "bt-state",
++ "aria-pressed": entry.id === chosen?.id,
++ onClick: () => {
++ setMode(entry.id);
++ setValues({});
++ setError(null);
++ }
++ }, t(entry.label)))) : null,
++ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react39.createElement)(CredentialField, {
++ key: credential.name,
++ credential,
++ value: field(credential.name),
++ onChange: (value) => setField(credential.name, value),
++ onEnter: connect,
++ autoFocus: index === 0
++ })) : (0, import_react39.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react39.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })),
++ gateway ? (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null,
++ (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form-row" },
++ (0, import_react39.createElement)(
++ "a",
++ { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" },
++ channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub")
++ ),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
++ setForm(false);
++ setToken("");
++ setValues({});
++ setError(null);
++ } }, t("Cancel")),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect"))
++ )
++ ) : null,
++ on && gateway && picking ? (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form" },
++ (0, import_react39.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }),
++ (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")),
++ (0, import_react39.createElement)(
++ "div",
++ { className: "bt-connector-form-row" },
++ (0, import_react39.createElement)("span", { className: "bt-connector-link" }),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => {
++ setPicking(false);
++ setError(null);
++ } }, t("Cancel")),
++ (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save"))
++ )
++ ) : null,
++ error ? (0, import_react39.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null
++ );
++}
++function ConnectorsPage({ roster, actions }) {
++ const list = roster?.connectors ?? [];
++ return (0, import_react39.createElement)(
++ "section",
++ { className: "bt-set-section" },
++ (0, import_react39.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react39.createElement)("div", { className: "bt-set-row" }, (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react39.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions })))
++ );
++}
++
++// src/client/feedback.js
++var import_react40 = require("react");
++var FeedbackIcon = () => (0, import_react40.createElement)(
++ "svg",
++ { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
++ (0, import_react40.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }),
++ (0, import_react40.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" })
++);
++function download(name, text2) {
++ const url = URL.createObjectURL(new Blob([text2], { type: "text/plain;charset=utf-8" }));
++ const anchor = document.createElement("a");
++ anchor.href = url;
++ anchor.download = name;
++ anchor.click();
++ setTimeout(() => URL.revokeObjectURL(url), 1e3);
++}
++var copy = (text2) => navigator.clipboard === void 0 ? Promise.resolve(false) : navigator.clipboard.writeText(text2).then(() => true, () => false);
++function whenText2(time) {
++ const date = new Date(time);
++ const today = (/* @__PURE__ */ new Date()).toDateString() === date.toDateString();
++ const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "2-digit", minute: "2-digit" });
++ return today ? clock4 : `${date.toLocaleDateString(dateLocale(), { month: "short", day: "numeric" })} ${clock4}`;
++}
++function entrySummary(entry) {
++ if (entry.source === "turn") return failureLabel(entry.code, entry.text);
++ return entry.text;
++}
++var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "This window" };
++function FeedbackPage({ actions, run, Section, Row: Row4 }) {
++ const [what, setWhat] = (0, import_react40.useState)("");
++ const [report, setReport] = (0, import_react40.useState)(null);
++ const [preview, setPreview] = (0, import_react40.useState)(false);
++ const [note, setNote] = (0, import_react40.useState)("");
++ const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => {
++ setReport(next);
++ return next;
++ });
++ const load = () => fetchReport().then(() => void 0);
++ (0, import_react40.useEffect)(() => {
++ void run(load());
++ }, []);
++ const copyReport = () => run(fetchReport().then(async (next) => {
++ const copied = await copy(next.markdown);
++ setNote(copied ? t("Copied the diagnostics.") : t("Could not copy. Select the text below and copy it."));
++ if (!copied) setPreview(true);
++ }));
++ const [issueLink, setIssueLink] = (0, import_react40.useState)(null);
++ const openIssue = () => {
++ const win = window.open("", "_blank");
++ if (win !== null) win.opener = null;
++ run(fetchReport().then(async (next) => {
++ const copied = await copy(next.markdown);
++ const { url, inline } = issueUrl(next.issuesUrl, { what, diagnostics: next.markdown });
++ if (win !== null) {
++ win.location.href = url;
++ setIssueLink(null);
++ setNote(inline ? t("Opened a new GitHub issue with the diagnostics filled in. Check it before you submit.") : copied ? t("Opened a new GitHub issue. The diagnostics are long, so they are on the clipboard: paste them into the issue.") : t("Opened a new GitHub issue. Copy the diagnostics below into it."));
++ } else {
++ setIssueLink(url);
++ setNote(t("The browser blocked the new window."));
++ }
++ if (!inline && !copied) setPreview(true);
++ }));
++ };
++ const downloadLog = () => run(actions.diagnosticsLog().then((text2) => {
++ download("ds-bot-diagnostics.log", text2 || "No entries.\n");
++ setNote(t("Downloaded the log. Keys, tokens, and your home folder are hidden in it."));
++ }));
++ const clearLog = () => run(actions.diagnosticsClear().then(clearClientErrors).then(load).then(() => setNote(t("Cleared the error log."))));
++ const browserErrors = report ? clientErrors().map((entry) => ({ ...entry, source: "browser" })) : [];
++ const entries2 = [...report?.entries ?? [], ...browserErrors].sort((a, b) => new Date(b.time) - new Date(a.time));
++ const total = (report?.total ?? 0) + browserErrors.length;
++ const shown = entries2.slice(0, 8);
++ return [
++ (0, import_react40.createElement)(
++ Section,
++ { key: "report", title: t("Report a problem") },
++ (0, import_react40.createElement)(
++ "div",
++ { className: "bt-set-row bt-set-paste bt-fb-what" },
++ (0, import_react40.createElement)(
++ "label",
++ { className: "bt-set-copy", htmlFor: "bt-fb-what" },
++ (0, import_react40.createElement)("span", null, t("What happened?")),
++ (0, import_react40.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue."))
++ ),
++ (0, import_react40.createElement)("textarea", { id: "bt-fb-what", className: "bt-input bt-input-area", value: what, maxLength: WHAT_MAX, placeholder: t("For example: I asked Writer in the group chat and it showed an error."), onChange: (event) => setWhat(event.target.value) })
++ ),
++ (0, import_react40.createElement)(
++ Row4,
++ { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") },
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")),
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy"))
++ ),
++ preview ? (0, import_react40.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react40.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null,
++ (0, import_react40.createElement)(
++ Row4,
++ { label: t("GitHub issue"), hint: t("Opens a new issue in the FlyBot repository with all of this filled in. Nothing is sent until you submit it there.") },
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub"))
++ )
++ ),
++ note ? (0, import_react40.createElement)(
++ "div",
++ { key: "note", className: "bt-note bt-fb-note", role: "status" },
++ note,
++ issueLink ? [" ", (0, import_react40.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null
++ ) : null,
++ (0, import_react40.createElement)(
++ Section,
++ { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") },
++ shown.length === 0 ? (0, import_react40.createElement)(Row4, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react40.createElement)(
++ "div",
++ { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text },
++ (0, import_react40.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }),
++ (0, import_react40.createElement)(
++ "div",
++ { className: "bt-set-copy" },
++ (0, import_react40.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)),
++ (0, import_react40.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · "))
++ ),
++ entry.code ? (0, import_react40.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null
++ )),
++ (0, import_react40.createElement)(
++ Row4,
++ { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") },
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")),
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear"))
++ )
++ )
++ ];
++}
++var TOAST_MS = 6e3;
++function ErrorToast({ useUi, actions }) {
++ const toast = useUi((state) => state.toast);
++ const [leaving, setLeaving] = (0, import_react40.useState)(false);
++ (0, import_react40.useEffect)(() => {
++ if (!toast) return void 0;
++ setLeaving(false);
++ const fade = setTimeout(() => setLeaving(true), TOAST_MS);
++ const gone = setTimeout(() => actions.dismissToast(toast.token), TOAST_MS + 200);
++ return () => {
++ clearTimeout(fade);
++ clearTimeout(gone);
++ };
++ }, [toast?.token]);
++ if (!toast) return null;
++ const info = toast.tone === "info";
++ return (0, import_react40.createElement)(
++ "div",
++ { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 },
++ (0, import_react40.createElement)("span", { className: "bt-toast-text" }, toast.text),
++ info ? null : (0, import_react40.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => {
++ actions.dismissToast(toast.token);
++ actions.openSettings("feedback");
++ } }, t("Details")),
++ (0, import_react40.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react40.createElement)(CloseIcon))
++ );
++}
++var FEEDBACK_CSS = `
++.bt-fb-what{cursor:default}
++.bt-fb-what:hover,.bt-fb-entry:hover,.bt-fb-preview:hover{background:none}
++.bt-fb-what .bt-input-area{min-height:80px}
++.bt-fb-preview{cursor:default;padding:0}
++.bt-fb-preview pre{margin:0;width:100%;max-height:240px;overflow:auto;padding:10px 12px;box-sizing:border-box;font:11px/16px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:var(--bt-ink-2);white-space:pre-wrap;word-break:break-word;outline:none}
++.bt-fb-primary{height:28px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);padding:0 14px;font-size:12px;cursor:pointer;transition:filter .12s ease}
++.bt-fb-primary:hover{filter:brightness(1.08)}
++.bt-fb-primary:active{filter:brightness(.94)}
++.bt-fb-note{margin:-8px 4px 0}
++.bt-fb-entry{cursor:default;align-items:flex-start}
++.bt-fb-dot{flex:none;width:6px;height:6px;border-radius:50%;margin-top:6px;background:#e02135}
++.bt-fb-dot[data-level=warn]{background:#e3a008}
++.bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word}
++.bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
++.bt-event-error{color:#e02135}
++.bt-toast{position:fixed;left:0;right:0;bottom:28px;z-index:1000;display:flex;align-items:center;gap:8px;width:fit-content;max-width:min(560px,calc(100vw - 32px));margin-inline:auto;padding:8px 8px 8px 14px;border-radius:12px;background:var(--bt-card,#fff);color:var(--bt-ink,#141414);border:1px solid var(--bt-line-2,rgba(0,0,0,.15));box-shadow:0 8px 28px rgba(0,0,0,.14);font-size:13px;line-height:18px;animation:bt-toast-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out}
++.bt-toast[data-leaving]{opacity:0;transform:translateY(6px) scale(.98);filter:blur(3px);transition:opacity .18s ease-in,transform .18s ease-in,filter .18s ease-in}
++.bt-toast-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical}
++.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
++.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
++.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
++.bt-toast-close{flex:none}
++@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
++@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
++`;
+
+-${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark}
++// src/client/about.js
++var import_react41 = require("react");
++var starCount = (count) => new Intl.NumberFormat(dateLocale(), { notation: "compact", maximumFractionDigits: 1 }).format(count);
++function botHint(update) {
++ if (update.phase === "installing") return t("Updating FlyBot core…");
++ if (update.phase === "installed") return t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest });
++ if (update.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(update.error ?? "") });
++ if (update.available) return t("FlyBot core {version} is available", { version: update.latest });
++ if (update.enabled === false) return t("Update checks are off.");
++ if (update.source === "path") return t("Installed from a local folder; update it there.");
++ if (update.source === "git" && update.build?.branch !== void 0) return branchHint(update);
++ if (update.source === "git" && update.build?.pinned) return t("Pinned to commit {commit}. Updates come from the maintainer: reinstall FlyBot to get them.", { commit: update.build.commit });
++ if (!update.checkedAt) return null;
++ if (!update.latest && update.source !== "registry") return t("Updates are not offered for this install.");
++ const checked = t("Checked {time}", { time: whenText2(update.checkedAt) });
++ return `${update.latest ? t("Up to date") : t("Could not check for updates.")} · ${checked}`;
++}
++function branchHint({ build, behind, checkedAt }) {
++ const where = [build.branch, build.commit].filter(Boolean).join(" ");
++ if (build.commit === void 0 || !checkedAt) return t("Installed from GitHub ({where}). Updates come from the maintainer: reinstall FlyBot to get them.", { where });
++ const checked = t("Checked {time}", { time: whenText2(checkedAt) });
++ const state = behind === null ? t("Could not compare with {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : behind === 0 ? t("Up to date with {branch}.", { branch: build.branch }) : behind === 1 ? t("1 commit behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { branch: build.branch }) : t("{count} commits behind {branch}. Updates come from the maintainer: reinstall FlyBot to get them.", { count: behind, branch: build.branch });
++ return `${state} · ${checked}`;
++}
++var versionLine = (update) => [t("Version {version}", { version: update.current }), [update.build?.branch, update.build?.commit].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
++function dshHint(dsh, shown) {
++ if (dsh.phase === "installing") return t("Updating DSH…");
++ if (dsh.phase === "installed") return t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified });
++ if (dsh.phase === "failed") return t("The update did not finish: {reason}", { reason: hostText(dsh.error ?? "") });
++ if (dsh.available === false && dsh.blockedBy) return t("Update FlyBot core {version} first.", { version: dsh.blockedBy });
++ if (shown) return t("DSH {version} is available", { version: dsh.verified });
++ return t("Version {version}", { version: dsh.current });
++}
++var linkProps = (href) => ({ className: "bt-soft bt-soft-sm bt-about-link", href, target: "_blank", rel: "noopener noreferrer" });
++function AboutPage({ useUpdate, actions, run, Section, Row: Row4 }) {
++ const update = useUpdate((value) => value) ?? {};
++ const [checking, setChecking] = (0, import_react41.useState)(false);
++ const [stars, setStars] = (0, import_react41.useState)(null);
++ (0, import_react41.useEffect)(() => {
++ let live = true;
++ actions.repoStars().then((count) => {
++ if (live && Number.isInteger(count)) setStars(count);
++ }, () => {
++ });
++ return () => {
++ live = false;
++ };
++ }, []);
++ const check = () => {
++ setChecking(true);
++ void run(actions.checkUpdate()).then(() => {
++ setChecking(false);
++ });
++ };
++ const phase = update.phase ?? "idle";
++ const dsh = update.dsh;
++ const dshPhase = dsh?.phase ?? "idle";
++ const dshNew = showsDshRow(dsh, update.app);
++ const dshShown = dshNew && dsh.available;
++ const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react41.createElement)(
++ "button",
++ { type: "button", className: "bt-fb-primary", disabled: phase === "installing", onClick: () => run(actions.installUpdate()) },
++ phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")
++ ) : (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: checking || update.enabled === false, onClick: check }, checking ? t("Checking…") : t("Check for updates"));
++ const dshControl = !dshNew || dshPhase === "installed" ? null : dsh.kind === "app" ? dsh.available ? (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.openAppUpdates(dsh.verified)) }, t("Open updates")) : null : dsh.available || dshPhase !== "idle" ? (0, import_react41.createElement)(
++ "button",
++ { type: "button", className: "bt-fb-primary", disabled: dshPhase === "installing", onClick: () => run(actions.installDshUpdate()) },
++ dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update")
++ ) : null;
++ const repo = update.repoUrl;
++ return [
++ (0, import_react41.createElement)(
++ "div",
++ { key: "hero", className: "bt-about-hero" },
++ (0, import_react41.createElement)("span", { className: "bt-about-mark" }, (0, import_react41.createElement)(FlyBotMark, { size: 52 })),
++ (0, import_react41.createElement)(
++ "div",
++ { className: "bt-about-copy" },
++ (0, import_react41.createElement)("span", { className: "bt-about-name" }, t("FlyBot")),
++ update.current ? (0, import_react41.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null
++ ),
++ repo ? (0, import_react41.createElement)(
++ "a",
++ {
++ className: "bt-star",
++ href: repo,
++ target: "_blank",
++ rel: "noopener noreferrer",
++ title: t("Star FlyBot on GitHub"),
++ "aria-label": stars === null ? t("Star FlyBot on GitHub") : `${t("Star FlyBot on GitHub")} · ${t("{count} stars", { count: stars })}`
++ },
++ (0, import_react41.createElement)("span", { className: "bt-star-main" }, (0, import_react41.createElement)("span", { className: "bt-star-icon" }, (0, import_react41.createElement)(StarLineIcon)), "Star"),
++ stars === null ? null : (0, import_react41.createElement)("span", { className: "bt-star-count" }, starCount(stars))
++ ) : null
++ ),
++ repo ? (0, import_react41.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If FlyBot helps you, a star on GitHub helps more people find it.")) : null,
++ (0, import_react41.createElement)(
++ Section,
++ { key: "updates", title: t("Updates") },
++ (0, import_react41.createElement)(Row4, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react41.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null),
++ dsh?.current ? (0, import_react41.createElement)(Row4, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null
++ ),
++ (0, import_react41.createElement)(
++ Section,
++ { key: "links", title: t("Links") },
++ repo ? (0, import_react41.createElement)(Row4, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react41.createElement)("a", linkProps(repo), (0, import_react41.createElement)(GitHubMark), t("Open"))) : null,
++ update.notesUrl ? (0, import_react41.createElement)(Row4, { label: t("Release notes") }, (0, import_react41.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null,
++ (0, import_react41.createElement)(Row4, { label: t("Report a problem") }, (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open")))
++ )
++ ];
++}
++var ABOUT_CSS = `
++.bt-about-hero{display:flex;align-items:center;gap:14px;padding:16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)}
++.bt-about-mark{flex:none;display:inline-flex}
++.bt-about-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
++.bt-about-name{font-size:16px;line-height:22px;font-weight:600}
++.bt-about-version{font-size:12px;line-height:16px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums}
++.bt-about-ask{margin:-10px 4px 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)}
++.bt-star{flex:none;display:inline-flex;align-items:stretch;height:30px;border-radius:999px;border:1px solid var(--bt-line-2,var(--bt-line));overflow:hidden;color:var(--bt-ink);text-decoration:none;font-size:12px;line-height:28px;transition:transform .12s ease,border-color .15s ease}
++.bt-star:hover{border-color:color-mix(in srgb,#e3a008 55%,var(--bt-line))}
++.bt-star:active{transform:scale(.96)}
++.bt-star-main{display:inline-flex;align-items:center;gap:6px;padding:0 12px 0 10px;background:var(--bt-hover);font-weight:500}
++.bt-star-icon{display:inline-flex;color:var(--bt-ink-2);transition:color .15s ease}
++.bt-star-icon path{transition:fill .15s ease}
++.bt-star:hover .bt-star-icon{color:#e3a008;animation:bt-star-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}}
++.bt-star:hover .bt-star-icon path{fill:currentColor}
++.bt-star-count{display:inline-flex;align-items:center;padding:0 11px;border-left:1px solid var(--bt-line-2,var(--bt-line));color:var(--bt-ink-2);font-variant-numeric:tabular-nums;animation:bt-star-count-in ${SPRING_POP.duration}ms ${SPRING_POP.easing},bt-veil-in .2s ease-out}
++@keyframes bt-star-pop{from{scale:.6;rotate:-30deg}}
++@keyframes bt-star-count-in{from{translate:-6px 0}}
++.bt-about-link{display:inline-flex;align-items:center;gap:6px;text-decoration:none}
++.bt-about-link svg{width:14px;height:14px}
++.bt-fb-primary:disabled{opacity:.6;cursor:default;filter:none}
++@media (prefers-reduced-motion:reduce){.bt-star:hover .bt-star-icon,.bt-star-count{animation:none}}
++`;
+
+-` : mark;
+- }
+- if (tag === "code" && !ctx.pre) {
+- const code = oneLine(body2);
+- if (!code) return mark;
+- const ticks = code.includes("`") ? "``" : "`";
+- return `${ticks}${code}${ticks}${mark}`;
+- }
+- if (tag === "blockquote") {
+- const quoted = block(body2);
+- return quoted ? keep(quoted.split("\n").map((line2) => line2 ? `> ${line2}` : ">").join("\n")) : "";
+- }
+- const end = body2.trimEnd().length;
+- const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2;
+- if (ctx.pre && !BLOCK_DISPLAY.test(display)) return marked;
+- if (keepsSpace && BLOCK_DISPLAY.test(display) && tag !== "pre") {
+- return keep(expand(marked).replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").replace(/^\n+|\s+$/g, ""), "");
++// src/client/settings.js
++var import_react42 = require("react");
++var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1);
++var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["projects", "Projects"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]];
++var PAGE_ICONS = { ...SETTINGS_ICONS, projects: FolderIcon, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon };
++function SetSection({ title, children }) {
++ return (0, import_react42.createElement)("section", { className: "bt-set-section" }, (0, import_react42.createElement)("h3", null, title), (0, import_react42.createElement)("div", { className: "bt-set-card" }, children));
++}
++function SetRow({ label, hint, children }) {
++ return (0, import_react42.createElement)(
++ "div",
++ { className: "bt-set-row" },
++ (0, import_react42.createElement)("div", { className: "bt-set-copy" }, (0, import_react42.createElement)("span", null, label), hint ? (0, import_react42.createElement)("span", { className: "bt-set-hint" }, hint) : null),
++ children ? (0, import_react42.createElement)("div", { className: "bt-set-control" }, children) : null
++ );
++}
++var NOTIFY_KINDS = [
++ ["approval", "Waiting for approval", "Once more if it still waits after 15 min."],
++ ["finished", "Finished"],
++ ["failed", "Failed"]
++];
++function GeneralPage({ roster, actions, run }) {
++ const prefs = roster.prefs ?? {};
++ const themes = allThemes();
++ const current = themes[prefs.theme] ? prefs.theme : "deepseek";
++ const [custom, setCustom] = (0, import_react42.useState)(prefs.accent ?? "#4d6bfe");
++ const timer = (0, import_react42.useRef)(0);
++ (0, import_react42.useEffect)(() => () => clearTimeout(timer.current), []);
++ const pickCustom = (value) => {
++ setCustom(value);
++ clearTimeout(timer.current);
++ timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350);
++ };
++ return [
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "look", title: t("Appearance") },
++ (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Theme"), hint: t("Colors for the whole team, on every device.") },
++ (0, import_react42.createElement)(
++ "select",
++ { className: "bt-select", "aria-label": t("Theme"), value: current, onChange: (event) => run(actions.setPrefs({ theme: event.target.value })) },
++ Object.entries(themes).map(([id, theme]) => (0, import_react42.createElement)("option", { key: id, value: id }, t(theme.label)))
++ )
++ ),
++ (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") },
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-swatches" },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-swatch bt-swatch-default", title: t("Theme accent"), "aria-label": t("Theme accent"), "aria-pressed": !prefs.accent, onClick: () => run(actions.setPrefs({ accent: null })) }),
++ ACCENT_INKS.map((ink) => (0, import_react42.createElement)("button", {
++ key: ink,
++ type: "button",
++ className: "bt-swatch",
++ title: t(capitalize2(ink)),
++ "aria-label": t(capitalize2(ink)),
++ "aria-pressed": prefs.accent === INKS[ink][0],
++ style: { background: INKS[ink][0] },
++ onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] }))
++ })),
++ (0, import_react42.createElement)(
++ "label",
++ { className: "bt-swatch bt-swatch-custom", title: t("Custom accent"), "data-on": Boolean(prefs.accent) && !ACCENT_INKS.some((ink) => INKS[ink][0] === prefs.accent) },
++ (0, import_react42.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) })
++ )
++ )
++ )
++ ),
++ // One switch for every system notification (#353), then one per kind (#469).
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "notify", title: t("Notifications") },
++ (0, import_react42.createElement)(SwitchRow, {
++ label: t("In other chats"),
++ hint: t("When your Bots finish, fail, or wait for your approval while you are in another chat or app."),
++ on: prefs.notifications !== false,
++ onChange: (on) => run(actions.setPrefs({ notifications: on }))
++ }),
++ NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react42.createElement)(SwitchRow, {
++ key: kind,
++ label: t(label),
++ hint: hint ? t(hint) : void 0,
++ disabled: prefs.notifications === false,
++ on: prefs.notify?.[kind] !== false,
++ onChange: (on) => run(actions.setPrefs({ notify: { [kind]: on } }))
++ }))
++ )
++ ];
++}
++function MainBotsSection({ roster, actions, run }) {
++ const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean);
++ const [first, ...others] = mains;
++ const own = roster.bots.filter((bot) => !bot.remote);
++ const candidates = own.filter((bot) => !isMainOf(roster, bot.id));
++ return (0, import_react42.createElement)(
++ SetSection,
++ { title: t("Main Bot") },
++ (0, import_react42.createElement)(
++ SetRow,
++ {
++ label: t("Main Bot"),
++ hint: first ? t("First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.", { name: first.name }) : void 0
++ },
++ (0, import_react42.createElement)("select", {
++ className: "bt-select",
++ "aria-label": t("Main Bot"),
++ value: first?.id ?? "",
++ onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {}))
++ }, first ? null : (0, import_react42.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react42.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
++ ),
++ others.map((bot) => (0, import_react42.createElement)(
++ SetRow,
++ { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t("Move to first")),
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role"))
++ )),
++ candidates.length > 0 ? (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Add a Main Bot"), hint: t("Main Bots can create and change Bots, and run every group chat.") },
++ (0, import_react42.createElement)("select", {
++ className: "bt-select",
++ "aria-label": t("Add a Main Bot"),
++ value: "",
++ onChange: (event) => {
++ if (event.target.value) void run(actions.setMain(event.target.value, true));
++ }
++ }, (0, import_react42.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react42.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
++ ) : null
++ );
++}
++var MOTIONS = [["quiet", "Quiet"], ["normal", "Normal"], ["lively", "Lively"]];
++function MotionSection({ roster, actions, run }) {
++ const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal";
++ return (0, import_react42.createElement)(
++ SetSection,
++ { title: t("Animation") },
++ (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Bot animation") },
++ (0, import_react42.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react42.createElement)("button", {
++ key: id,
++ type: "button",
++ "aria-pressed": motion === id,
++ onClick: () => {
++ if (motion !== id) run(actions.setPrefs({ motion: id }));
++ }
++ }, t(label))))
++ )
++ );
++}
++function BotsPage({ roster, actions, run }) {
++ const [creating, setCreating] = (0, import_react42.useState)(false);
++ return [
++ (0, import_react42.createElement)(MainBotsSection, { key: "main", roster, actions, run }),
++ (0, import_react42.createElement)(MotionSection, { key: "motion", roster, actions, run }),
++ creating ? (0, import_react42.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react42.createElement)(
++ "div",
++ { key: "new", className: "bt-actions" },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react42.createElement)(PlusIcon), t("New Bot"))
++ ),
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) },
++ roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react42.createElement)(
++ "button",
++ { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) },
++ (0, import_react42.createElement)(
++ "span",
++ { className: "bt-set-bot" },
++ (0, import_react42.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }),
++ (0, import_react42.createElement)("span", { className: "bt-row-title" }, bot.name),
++ bot.role ? (0, import_react42.createElement)("span", { className: "bt-tag" }, bot.role) : null,
++ isMainOf(roster, bot.id) ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null,
++ bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
++ ),
++ (0, import_react42.createElement)("span", { className: "bt-chevron" }, (0, import_react42.createElement)(ChevronRightIcon))
++ ))
++ )
++ ];
++}
++function GroupsPage({ roster, actions, run }) {
++ const [creating, setCreating] = (0, import_react42.useState)(false);
++ return [
++ creating ? (0, import_react42.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react42.createElement)(
++ "div",
++ { key: "new", className: "bt-actions" },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", disabled: roster.bots.length < 2, title: roster.bots.length < 2 ? t("A group chat needs at least two Bots") : void 0, onClick: () => setCreating(true) }, (0, import_react42.createElement)(PlusIcon), t("Create group chat"))
++ ),
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) },
++ roster.rooms.length === 0 ? (0, import_react42.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => {
++ const admin = roster.byId[room2.admin];
++ return (0, import_react42.createElement)(
++ "button",
++ { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) },
++ (0, import_react42.createElement)(
++ "span",
++ { className: "bt-set-bot" },
++ (0, import_react42.createElement)(RoomAvatar, { room: room2, roster, size: 28 }),
++ (0, import_react42.createElement)("span", { className: "bt-row-title" }, room2.name),
++ (0, import_react42.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })),
++ admin ? (0, import_react42.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react42.createElement)(AdminStar), admin.name) : null,
++ room2.hidden ? (0, import_react42.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null
++ ),
++ (0, import_react42.createElement)("span", { className: "bt-chevron" }, (0, import_react42.createElement)(ChevronRightIcon))
++ );
++ })
++ )
++ ];
++}
++function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) {
++ const [error, setError] = (0, import_react42.useState)("");
++ const close = (0, import_react42.useCallback)(() => actions.closeSettings(), []);
++ useEscape(close);
++ const run = (promise) => Promise.resolve(promise).then(() => {
++ setError("");
++ return true;
++ }).catch((failure) => {
++ setError(failure?.message ?? String(failure));
++ return false;
++ });
++ const bot = page === "bots" && id ? roster.byId[id] : void 0;
++ const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
++ const extra = useSettingsPages().map((entry) => ({ entry, key: `plugin:${entry.id}` }));
++ const pages = SETTINGS_PAGES.flatMap((item) => item[0] === "connectors" ? [item, ...extra.map(({ entry, key }) => [key, entry.label ?? entry.id])] : [item]);
++ const shown = pages.some(([key]) => key === page) ? page : "general";
++ const pluginPage = extra.find(({ key }) => key === shown)?.entry;
++ const usageFocus = shown === "usage" && id ? id : null;
++ const label = t(pages.find(([key]) => key === shown)[1]);
++ const back = bot ? t("Back to Bots") : room2 ? t("Back to group chats") : usageFocus ? t("All usage") : null;
++ const nav = useGlide(shown, { axis: "y" });
++ const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`;
++ const depth = bot || room2 ? 1 : 0;
++ const last = (0, import_react42.useRef)({ key: pageKey, depth, move: "up" });
++ if (last.current.key !== pageKey) {
++ last.current = { key: pageKey, depth, move: depth > last.current.depth ? "in" : depth < last.current.depth ? "out" : "up" };
++ }
++ const [usageOpened, setUsageOpened] = (0, import_react42.useState)(shown === "usage");
++ if (shown === "usage" && !usageOpened) setUsageOpened(true);
++ const errorLine = error ? (0, import_react42.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
++ return (0, import_react42.createElement)(
++ "div",
++ { className: "bt-settings-layer", "data-leaving": leaving || void 0 },
++ (0, import_react42.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 },
++ (0, import_react42.createElement)(
++ "nav",
++ { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") },
++ (0, import_react42.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }),
++ pages.map(([key, text2]) => (0, import_react42.createElement)("button", {
++ key,
++ type: "button",
++ className: "bt-settings-item",
++ "aria-current": shown === key ? "page" : void 0,
++ onClick: () => actions.openSettings(key)
++ }, (0, import_react42.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text2))),
++ // DeepSeek Harness's own settings are a separate window with its own title and look; FlyBot
++ // does not embed or retitle it (never patch DSH), so the entry says it leaves (#421).
++ (0, import_react42.createElement)("button", {
++ type: "button",
++ className: "bt-settings-item bt-settings-shell",
++ title: t("Opens DeepSeek Harness's own settings (models, providers, permissions) in a separate window"),
++ "aria-label": t("Harness settings, opens DeepSeek Harness's own settings window"),
++ onClick: () => {
++ close();
++ openHarnessSettings();
++ }
++ }, (0, import_react42.createElement)(SlidersIcon), t("Harness settings"), (0, import_react42.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗"))
++ ),
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-settings-main" },
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-settings-head" },
++ back ? (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react42.createElement)(ChevronLeftIcon)) : null,
++ (0, import_react42.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label)
++ ),
++ shown === "usage" ? null : (0, import_react42.createElement)(
++ "div",
++ { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move },
++ errorLine,
++ shown === "general" ? (0, import_react42.createElement)(GeneralPage, { roster, actions, run }) : null,
++ bot ? (0, import_react42.createElement)("div", { className: "bt-settings-form" }, (0, import_react42.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null,
++ shown === "bots" && !bot ? (0, import_react42.createElement)(BotsPage, { roster, actions, run }) : null,
++ room2 ? (0, import_react42.createElement)("div", { className: "bt-settings-form" }, (0, import_react42.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null,
++ shown === "groups" && !room2 ? (0, import_react42.createElement)(GroupsPage, { roster, actions, run }) : null,
++ shown === "projects" ? (0, import_react42.createElement)(ProjectsPage, { roster, actions, run, call: call2 }) : null,
++ shown === "connectors" ? (0, import_react42.createElement)(ConnectorsPage, { roster, actions }) : null,
++ shown === "workflows" ? (0, import_react42.createElement)(WorkflowsPage, { call: call2, roster }) : null,
++ pluginPage ? (0, import_react42.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null,
++ shown === "capabilities" ? (0, import_react42.createElement)(CapabilitiesPage, { roster, actions, run }) : null,
++ shown === "schedules" ? (0, import_react42.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null,
++ shown === "secrets" ? (0, import_react42.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null,
++ shown === "feedback" ? (0, import_react42.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null,
++ shown === "about" ? (0, import_react42.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null
++ ),
++ usageOpened ? (0, import_react42.createElement)(
++ "div",
++ { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true },
++ shown === "usage" ? errorLine : null,
++ (0, import_react42.createElement)(UsagePage, { roster, actions, focusId: usageFocus })
++ ) : null
++ ),
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react42.createElement)(CloseIcon))
++ )
++ );
++}
++var openProjectBoard = (id) => {
++ if (typeof window !== "undefined") window.location.hash = `#project=${id}`;
++};
++var projectMembers = (roster) => [
++ ...(roster?.bots ?? []).filter((bot) => !bot.remote).map((bot) => ({ kind: "bot", id: bot.id, name: bot.name, projectId: bot.projectId ?? null })),
++ ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
++];
++function ProjectsList({ projects, roster, call: call4, reload }) {
++ const members = projectMembers(roster);
++ const bind = async (choice, project) => {
++ const [kind, id] = String(choice ?? "").split(":");
++ if (!kind || !id) return;
++ await call4("project-bind", { kind, id, projectId: project.id });
++ await reload?.();
++ };
++ const unbind = async (member) => {
++ await call4("project-bind", { kind: member.kind, id: member.id, projectId: null });
++ await reload?.();
++ };
++ const bound2 = members.filter((member) => member.projectId !== null && member.projectId !== void 0);
++ return [
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "projects", title: t("Projects · {count}", { count: projects.length }) },
++ projects.length === 0 ? (0, import_react42.createElement)("div", { className: "bt-set-row" }, (0, import_react42.createElement)("div", { className: "bt-set-copy" }, (0, import_react42.createElement)("span", null, t("No Project yet — create one.")))) : projects.map((project) => (0, import_react42.createElement)(
++ SetRow,
++ {
++ key: project.id,
++ label: project.name,
++ hint: project.workDir === null || project.workDir === void 0 ? void 0 : project.workDir
++ },
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-project-row-controls" },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
++ (0, import_react42.createElement)(
++ "select",
++ {
++ className: "bt-select",
++ "aria-label": t("Bind to {name}", { name: project.name }),
++ value: "",
++ onChange: (event) => {
++ void bind(event.target.value, project);
++ }
++ },
++ (0, import_react42.createElement)("option", { value: "" }, t("Bind a Bot or group")),
++ members.map((member) => (0, import_react42.createElement)(
++ "option",
++ { key: `${member.kind}:${member.id}`, value: `${member.kind}:${member.id}` },
++ `${member.kind === "group" ? t("Group") : t("Bot")} · ${member.name}`
++ ))
++ )
++ )
++ ))
++ ),
++ bound2.length === 0 ? null : (0, import_react42.createElement)(
++ SetSection,
++ { key: "bindings", title: t("Bindings") },
++ bound2.map((member) => (0, import_react42.createElement)(
++ SetRow,
++ {
++ key: `${member.kind}:${member.id}`,
++ label: member.name,
++ hint: member.kind === "group" ? t("Group") : t("Bot")
++ },
++ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
++ void unbind(member);
++ } }, t("Unbind"))
++ ))
++ )
++ ];
++}
++function ProjectsPage({ roster, actions, run, call: call4 }) {
++ const [state, setState] = (0, import_react42.useState)({ projects: [], error: null });
++ const [name, setName] = (0, import_react42.useState)("");
++ const [workDir, setWorkDir] = (0, import_react42.useState)(null);
++ const reload = (0, import_react42.useCallback)(async () => {
++ const listing = await call4("projects");
++ setState({ projects: listing?.projects ?? [], error: null });
++ }, [call4]);
++ (0, import_react42.useEffect)(() => {
++ void reload().catch((error) => setState({ projects: [], error: error?.message ?? String(error) }));
++ }, [reload]);
++ const create = async () => {
++ const trimmed3 = name.trim();
++ if (trimmed3 === "") return;
++ const body = { name: trimmed3 };
++ if (workDir !== null && workDir !== "") body.workDir = workDir;
++ try {
++ await call4("project-create", body);
++ setName("");
++ setWorkDir(null);
++ await reload();
++ } catch (error) {
++ setState({ projects: state.projects, error: error?.message ?? String(error) });
+ }
+- if (BLOCK_DISPLAY.test(display)) return `
++ };
++ return [
++ state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
++ (0, import_react42.createElement)(
++ SetSection,
++ { key: "new", title: t("New Project") },
++ (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Name") },
++ (0, import_react42.createElement)("input", {
++ className: "bt-input",
++ "aria-label": t("Project name"),
++ value: name,
++ placeholder: t("e.g. Website"),
++ onChange: (event) => setName(event.target.value)
++ })
++ ),
++ (0, import_react42.createElement)(
++ SetRow,
++ { label: t("Working folder"), hint: t("Optional. Where this Project's work happens.") },
++ (0, import_react42.createElement)(FolderMenu, { value: workDir, actions, run, onPick: setWorkDir })
++ ),
++ (0, import_react42.createElement)(
++ "div",
++ { className: "bt-actions" },
++ (0, import_react42.createElement)("button", {
++ type: "button",
++ className: "bt-primary bt-with-icon",
++ disabled: name.trim() === "",
++ onClick: () => {
++ void create();
++ }
++ }, (0, import_react42.createElement)(PlusIcon), t("New Project"))
++ )
++ ),
++ // The list is rendered here rather than as a child element: it holds no state of its own,
++ // and the page keeps one reload path for both halves.
++ ...ProjectsList({ projects: state.projects, roster, call: call4, reload })
++ ];
++}
+
+-${marked}
++// src/client/context-menu.js
++var import_react43 = require("react");
++function ContextMenu({ menu, roster, actions, leaving = false }) {
++ useEscape(() => actions.closeMenu());
++ (0, import_react43.useEffect)(() => {
++ const onDown = (event) => {
++ if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu();
++ };
++ window.addEventListener("mousedown", onDown);
++ return () => window.removeEventListener("mousedown", onDown);
++ }, []);
++ const structure = useSidebarStructure();
++ const ref = (0, import_react43.useRef)(null);
++ const [place, setPlace] = (0, import_react43.useState)({ left: menu.x, top: menu.y });
++ (0, import_react43.useLayoutEffect)(() => {
++ const box = ref.current?.getBoundingClientRect();
++ if (!box) return;
++ setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
++ }, [menu.x, menu.y]);
++ if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react43.createElement)(AgentMenu, { menu, roster, actions, leaving });
++ const bot = roster.byId[menu.id];
++ const room2 = roster.roomsById[menu.id];
++ const entry = bot ?? room2;
++ if (entry === void 0) return null;
++ const isMainBot = isMainOf(roster, menu.id);
++ const isTile = roster.mainBotId === menu.id;
++ const mainCount = (roster.mainBotIds ?? []).length;
++ const groups = bot?.remote ? [
++ [
++ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
++ ],
++ [
++ [t("Bot settings"), GearIcon, () => actions.openSettings("bots", bot.id)]
++ ],
++ pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]),
++ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(currentPart(bot))]]
++ ] : [
++ [
++ isTile ? null : [entry.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(menu.id, { pinned: !entry.pinned })],
++ [t("Mark as Unread"), UnreadIcon, () => actions.markUnread(menu.id)]
++ ],
++ [
++ // One way to edit a Bot (#385): its name is edited in Edit Bot too.
++ bot ? [t("Edit Bot"), GearIcon, () => actions.openSettings("bots", bot.id)] : null,
++ room2 ? [t("Group settings"), GearIcon, () => actions.openSettings("groups", room2.id)] : null,
++ room2 ? [t("Rename group"), PencilIcon, () => actions.renameBot(room2.id)] : null,
++ bot ? [t("Duplicate"), DuplicateIcon, async () => {
++ const copy2 = await actions.duplicateBot(bot.id);
++ actions.openSession(copy2.id);
++ }] : null,
++ bot && !isMainBot ? [t("Make Main Bot"), StarLineIcon, () => actions.setMain(bot.id, true)] : null,
++ bot && isMainBot && mainCount > 1 ? [t("Remove Main Bot role"), StarLineIcon, () => actions.setMain(bot.id, false)] : null
++ ],
++ bot ? pluginItems(structure, bot, actions).map(([label, run]) => [label, LinkIcon, run]) : [],
++ [[t("Copy conversation ID"), CopyIcon, () => navigator.clipboard?.writeText(bot ? currentPart(bot) : menu.id)]],
++ [
++ isMainBot ? null : [t("Hide from sidebar"), HideIcon, () => actions.setFlags(menu.id, { hidden: true })],
++ isMainBot ? null : [room2 ? t("Delete group") : t("Delete"), TrashIcon, () => bot ? actions.deleteBot(bot.id) : actions.deleteRoom(menu.id), true]
++ ]
++ ];
++ const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0);
++ return (0, import_react43.createElement)(
++ "div",
++ { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 },
++ shown.map((group, index) => (0, import_react43.createElement)(
++ import_react43.Fragment,
++ { key: index },
++ index > 0 ? (0, import_react43.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
++ group.map(([label, Icon, run, danger]) => (0, import_react43.createElement)("button", {
++ key: label,
++ type: "button",
++ role: "menuitem",
++ className: danger ? "bt-danger" : void 0,
++ onClick: () => {
++ actions.closeMenu();
++ void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu"));
++ }
++ }, (0, import_react43.createElement)(Icon), label))
++ ))
++ );
++}
++function AgentMenu({ menu, roster, actions, leaving }) {
++ const ref = (0, import_react43.useRef)(null);
++ const [place, setPlace] = (0, import_react43.useState)({ left: menu.x, top: menu.y });
++ (0, import_react43.useLayoutEffect)(() => {
++ const box = ref.current?.getBoundingClientRect();
++ if (!box) return;
++ setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) });
++ }, [menu.x, menu.y]);
++ const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId];
++ if (agent === void 0) return null;
++ const groups = menu.kind === "agent" ? [
++ [
++ [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })]
++ ],
++ [
++ [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true]
++ ]
++ ] : [
++ [
++ [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)]
++ ],
++ [
++ [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true]
++ ]
++ ];
++ return (0, import_react43.createElement)(
++ "div",
++ { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 },
++ groups.map((group, index) => (0, import_react43.createElement)(
++ import_react43.Fragment,
++ { key: index },
++ index > 0 ? (0, import_react43.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null,
++ group.map(([label, Icon, run, danger]) => (0, import_react43.createElement)("button", {
++ key: label,
++ type: "button",
++ role: "menuitem",
++ className: danger ? "bt-danger" : void 0,
++ onClick: () => {
++ actions.closeMenu();
++ void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error));
++ }
++ }, (0, import_react43.createElement)(Icon), label))
++ ))
++ );
++}
+
+-`;
+- if (INLINE_BLOCK.test(display)) return ` ${marked} `;
+- if (tag === "li") return ` · ${marked}`;
+- return marked;
+- }
+- const body = document.body;
+- let text2 = "";
+- if (body) {
+- text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " ");
+- }
+- if (text2.length > limits.chars) {
+- text2 = text2.slice(0, limits.chars);
+- stop ||= `the text passed ${limits.chars} characters`;
+- }
+- const page = {
+- url: location.href,
+- title: document.title,
+- lang: document.documentElement.lang || "",
+- description: document.querySelector('meta[name="description"]')?.content || "",
+- selection: String(getSelection() || "").slice(0, 4e3),
+- format: "markdown",
+- text: text2,
+- items,
+- stats: { ...stats, ms: Math.round(performance.now() - started) }
+- };
+- if (stop) page.truncated = stop;
+- return page;
++// src/client/pane-width.js
++var DEFAULT_WIDTH = 520;
++var DETAILS_WIDTH = 380;
++var DETAILS_MAX = 520;
++var MIN_WIDTH = 360;
++var MIN_CHAT = 420;
++var MIN_DETAILS = 240;
++function panelWidthRange(view, viewport) {
++ const room2 = Math.max(0, viewport - MIN_CHAT);
++ if (view === "browser") return [MIN_WIDTH, Math.max(MIN_WIDTH, room2)];
++ const low = Math.max(MIN_DETAILS, Math.min(DETAILS_WIDTH, room2));
++ return [low, Math.max(low, Math.min(DETAILS_MAX, room2))];
+ }
+-var readPageScript = (limits = READ_LIMITS) => `(${readPage})(${JSON.stringify(limits)})`;
+-var READ_PAGE_SCRIPT = readPageScript();
+
+-// src/client/bot-browsers.js
+-var STORE_KEY = STORAGE.browsers;
+-var MAX_LIVE = 6;
+-var READ_TIMEOUT_MS = 1e4;
+-var PREVIEW_TIMEOUT_MS = 3e3;
+-var RESIZE_LATER_MS = 200;
+-var SEARCH_URL = "https://www.bing.com/search?q=";
+-var PREVIEW_SCRIPT = `(() => {
+- const meta = key => document.querySelector('meta[name="' + key + '"],meta[property="' + key + '"]')?.content ?? ''
+- const paragraph = [...document.querySelectorAll('main p, article p, p')].map(node => node.innerText ?? '').find(text => text.trim().length > 40) ?? ''
+- const text = meta('description') || meta('og:description') || paragraph || document.body?.innerText || ''
+- return text.replace(/\\s+/g, ' ').trim().slice(0, 200)
+-})()`;
+-var EMPTY = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, live: false, excerpt: "" };
+-var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.internal)$/i;
+-var BLOCKED = "Cloud metadata addresses do not open here.";
+-var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname);
+-function browserAddress(input) {
+- const text2 = String(input ?? "").trim();
+- if (text2 === "") return null;
+- if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null;
+- if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null;
+- const host = text2.split(/[/?#]/)[0];
+- const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host);
+- const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host));
+- if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href;
+- return `${SEARCH_URL}${encodeURIComponent(text2)}`;
++// src/client/storage.js
++var STORAGE = {
++ unread: "ds-bot:unread",
++ reactions: "ds-bot:reactions",
++ seen: "ds-bot:seen",
++ friendLines: "ds-bot:friend-lines",
++ prefs: "ds-bot:prefs",
++ surface: "ds-bot:surface",
++ agentSessions: "ds-bot:agent-sessions",
++ browserWidth: "ds-bot:browser-width",
++ browsers: "ds-bot.browser.v2"
++};
++var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"];
++var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"];
++var LEGACY_THEMES = { grok: "mono" };
++function legacyKeys(key) {
++ if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS;
++ const name = key.slice(key.indexOf(":") + 1);
++ return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`);
+ }
+-function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) {
+- const views = /* @__PURE__ */ new Map();
+- const owner = /* @__PURE__ */ new Map();
+- const tabs = createSource2({});
+- const pages = createSource2({});
+- let saved = {};
+- try {
+- saved = JSON.parse(storage?.getItem(STORE_KEY) ?? "{}") ?? {};
+- } catch {
+- saved = {};
++var migrateBrowsers = (storage) => {
++ if (storage.getItem(STORAGE.browsers) !== null) {
++ for (const key of LEGACY_BROWSER_KEYS) storage.removeItem(key);
++ return;
+ }
+- let layer;
+- let shown = null;
+- let rect = null;
+- let visible = false;
+- let resizeLater = 0;
+- let disposed = false;
+- let serial = 0;
+- const savedTab = (botId, tabId) => saved[botId]?.tabs?.find((entry) => entry.id === tabId);
+- const publish2 = (tabId, patch) => pages.set((state) => {
+- const botId = owner.get(tabId);
+- return { ...state, [tabId]: { ...EMPTY, ...savedTab(botId, tabId), ...state[tabId], ...patch } };
+- });
+- const writeSaved = () => {
+- try {
+- storage?.setItem(STORE_KEY, JSON.stringify(saved));
+- } catch {
+- }
+- };
+- const persist = (botId) => {
+- const state = tabs.getSnapshot()[botId];
+- if (state === void 0) {
+- if (saved[botId] === void 0) return;
+- const { [botId]: _, ...rest } = saved;
+- saved = rest;
+- } else {
+- const snapshot = pages.getSnapshot();
+- saved = {
+- ...saved,
+- [botId]: {
+- tabs: state.ids.map((id) => ({ id, url: snapshot[id]?.url ?? "", title: snapshot[id]?.title ?? "" })),
+- active: state.active
+- }
+- };
+- }
+- writeSaved();
+- };
+- const ensureTabs = (botId) => {
+- if (tabs.getSnapshot()[botId] !== void 0) return;
+- const entries2 = saved[botId]?.tabs ?? [];
+- const ids = entries2.map((entry) => entry.id);
+- const active = ids.includes(saved[botId]?.active) ? saved[botId].active : ids.at(-1) ?? null;
+- for (const entry of entries2) owner.set(entry.id, botId);
+- tabs.set((state) => ({ ...state, [botId]: { ids, active } }));
+- for (const entry of entries2) publish2(entry.id, { url: entry.url ?? "", title: entry.title ?? "", live: false });
+- };
+- const ensureLayer = () => {
+- if (layer?.isConnected) return layer;
+- layer = doc.createElement("div");
+- layer.className = "bt-browser-layer";
+- Object.assign(layer.style, { position: "fixed", inset: "0", zIndex: "41", pointerEvents: "none" });
+- doc.body.append(layer);
+- return layer;
+- };
+- const place = (entry) => {
+- const box = rect ?? { left: 0, top: 0, width: 800, height: 600 };
+- Object.assign(entry.element.style, {
+- left: `${box.left}px`,
+- top: `${box.top}px`,
+- width: `${box.width}px`,
+- height: `${box.height}px`,
+- visibility: shown === entry.tabId && visible && rect ? "visible" : "hidden"
+- });
+- };
+- async function preview(tabId) {
+- const entry = views.get(tabId);
+- if (entry === void 0 || !entry.ready || !/^https?:/i.test(entry.element.getURL())) return false;
+- let timer;
+- const timeout = new Promise((resolve) => {
+- timer = setTimeout(() => resolve(""), PREVIEW_TIMEOUT_MS);
+- });
++ for (const key of LEGACY_BROWSER_KEYS) {
+ try {
+- const excerpt = await Promise.race([entry.element.executeJavaScript(PREVIEW_SCRIPT), timeout]);
+- if (typeof excerpt !== "string" || excerpt === "" || views.get(tabId) !== entry) return false;
+- publish2(tabId, { excerpt });
+- return true;
++ const value = storage.getItem(key);
++ if (value === null) continue;
++ const previous = JSON.parse(value) ?? {};
++ const tabs = Object.fromEntries(Object.entries(previous).filter(([, page]) => page && typeof page === "object").map(([botId, page]) => {
++ const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" };
++ return [botId, { tabs: [tab], active: tab.id }];
++ }));
++ storage.setItem(STORAGE.browsers, JSON.stringify(tabs));
++ for (const earlier of LEGACY_BROWSER_KEYS) storage.removeItem(earlier);
++ return;
+ } catch {
+- return false;
+- } finally {
+- clearTimeout(timer);
+ }
+ }
+- const observe = (entry) => {
+- const { element } = entry;
+- if (!entry.ready) return;
+- const url = element.getURL();
+- if (url.startsWith("about:blank#")) return;
+- if (entry.fresh) {
+- element.clearHistory();
+- entry.fresh = false;
+- }
+- const title = element.getTitle();
+- publish2(entry.tabId, { url, title, loading: element.isLoading(), canGoBack: element.canGoBack(), canGoForward: element.canGoForward(), live: true });
+- persist(entry.botId);
+- };
+- async function create(tabId) {
+- const botId = owner.get(tabId);
+- const reservation = await bridge.acquire(`bot:${botId}`);
+- if (disposed) {
+- await bridge.release(reservation.lease).catch(() => {
+- });
+- return void 0;
+- }
+- const element = doc.createElement("webview");
+- element.dataset.sidebarBrowserFrame = "webview";
+- Object.assign(element.style, { position: "fixed", display: "flex", border: "0", pointerEvents: "auto", background: "#fff" });
+- element.dataset.btBrowser = tabId;
+- element.setAttribute("name", reservation.lease);
+- element.setAttribute("partition", reservation.partition);
+- element.setAttribute("allowpopups", "");
+- element.setAttribute("src", `about:blank#${reservation.lease}`);
+- const entry = { botId, tabId, element, lease: reservation.lease, ready: false, fresh: true, pending: pages.getSnapshot()[tabId]?.url || savedTab(botId, tabId)?.url, usedAt: Date.now() };
+- const lifetime = new AbortController();
+- entry.stop = () => lifetime.abort();
+- const { signal } = lifetime;
+- const unsubscribe = bridge.onOpenRequested(reservation.lease, (url) => void openTab(botId, url));
+- signal.addEventListener("abort", unsubscribe, { once: true });
+- element.addEventListener("dom-ready", () => {
+- entry.ready = true;
+- observe(entry);
+- if (entry.pending) {
+- const url = entry.pending;
+- entry.pending = void 0;
+- void element.loadURL(url).catch(() => {
+- });
++};
++function migrateStorage(storage = globalThis.localStorage) {
++ for (const key of Object.values(STORAGE)) {
++ if (key === STORAGE.browsers) continue;
++ for (const legacy of legacyKeys(key)) {
++ try {
++ const value = storage.getItem(legacy);
++ if (value !== null && storage.getItem(key) === null) storage.setItem(key, value);
++ storage.removeItem(legacy);
++ } catch {
+ }
+- }, { signal });
+- for (const name of ["did-navigate", "did-navigate-in-page", "did-start-loading", "did-stop-loading", "page-title-updated"]) {
+- element.addEventListener(name, () => observe(entry), { signal });
+ }
+- element.addEventListener("did-stop-loading", () => {
+- void preview(tabId);
+- }, { signal });
+- element.addEventListener("page-favicon-updated", (event) => {
+- const favicon = event.favicons?.[0];
+- if (typeof favicon === "string" && favicon.startsWith("data:")) publish2(tabId, { favicon });
+- }, { signal });
+- element.addEventListener("did-start-navigation", (event) => {
+- if (!event.isMainFrame) return;
+- if (blockedAddress(event.url)) {
+- element.stop();
+- publish2(tabId, { loading: false, error: BLOCKED });
+- return;
+- }
+- publish2(tabId, { loading: true, error: null });
+- }, { signal });
+- element.addEventListener("did-fail-load", (event) => {
+- if (event.isMainFrame && event.errorCode !== -3) publish2(tabId, { loading: false, error: event.errorDescription || `Error ${event.errorCode}` });
+- }, { signal });
+- element.addEventListener("render-process-gone", () => {
+- if (views.get(tabId) === void 0) return;
+- void release(tabId);
+- publish2(tabId, { loading: false, error: "The page crashed." });
+- }, { signal });
+- views.set(tabId, entry);
+- ensureLayer().append(element);
+- place(entry);
+- publish2(tabId, { live: true, loading: Boolean(entry.pending) });
+- return entry;
+ }
+- const opening = /* @__PURE__ */ new Map();
+- function open(tabId) {
+- const entry = views.get(tabId);
+- if (entry) return Promise.resolve(entry);
+- let job = opening.get(tabId);
+- if (job === void 0) {
+- job = create(tabId).catch((error) => {
+- console.warn("[ds-bot] browser acquire", error);
+- publish2(tabId, { loading: false, error: String(error?.message ?? error) });
+- return void 0;
+- }).finally(() => opening.delete(tabId));
+- opening.set(tabId, job);
+- void job.then(() => trim());
++ try {
++ migrateBrowsers(storage);
++ } catch {
++ }
++ try {
++ const prefs = JSON.parse(storage.getItem(STORAGE.prefs) ?? "null");
++ if (prefs && Object.hasOwn(LEGACY_THEMES, prefs.theme ?? "")) {
++ storage.setItem(STORAGE.prefs, JSON.stringify({ ...prefs, theme: LEGACY_THEMES[prefs.theme] }));
+ }
+- return job;
++ } catch {
+ }
+- function trim() {
+- const idle = [...views.values()].filter((entry) => entry.tabId !== shown).sort((a, b) => a.usedAt - b.usedAt);
+- while (views.size > MAX_LIVE && idle.length > 0) void release(idle.shift().tabId);
++}
++
++// src/client/details-drawer.js
++var import_react44 = require("react");
++var TAB_LABELS = { details: "Details", browser: "Browser" };
++var hostOf = (url) => {
++ try {
++ return new URL(url).hostname.replace(/^www\./, "");
++ } catch {
++ return "";
+ }
+- async function release(tabId) {
+- const entry = views.get(tabId);
+- if (entry === void 0) return;
+- views.delete(tabId);
+- entry.stop();
+- entry.element.remove();
+- publish2(tabId, { live: false, loading: false });
+- await bridge.release(entry.lease).catch((error) => console.warn("[ds-bot] browser release", error));
++};
++var shortUrl = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
++var EMPTY_TABS = { ids: [], active: null };
++var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" };
++var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]);
++function PermissionRow({ bot, roster, actions, run }) {
++ const [pending, setPending] = (0, import_react44.useState)(null);
++ const catalog = roster.permissions;
++ const saved = bot.permission ?? catalog?.defaultPreset;
++ (0, import_react44.useEffect)(() => {
++ if (pending === saved) setPending(null);
++ }, [pending, saved]);
++ if (!catalog?.options?.length) return null;
++ const current = pending ?? saved;
++ const pick2 = (value) => {
++ if (value === current && bot.permission !== void 0) return;
++ setPending(value);
++ void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null));
++ };
++ return (0, import_react44.createElement)(
++ "div",
++ { className: "bt-drawer-permission" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Permissions")),
++ (0, import_react44.createElement)(
++ Segmented,
++ { label: t("Permissions"), value: current },
++ catalog.options.map((option) => (0, import_react44.createElement)("button", {
++ key: option.value,
++ type: "button",
++ "aria-pressed": option.value === current,
++ title: option.description,
++ onClick: () => pick2(option.value),
++ className: DANGER.has(option.value) ? "bt-perm-danger" : void 0
++ }, PERMISSION_LABELS[option.value] ? t(PERMISSION_LABELS[option.value]) : option.name))
++ )
++ );
++}
++function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) {
++ const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS);
++ const pages = useBrowserPages((map) => map);
++ (0, import_react44.useEffect)(() => {
++ actions.browserEnsure?.(bot.id);
++ }, [bot.id]);
++ if (roster.browser !== true || !actions.browserShow) {
++ return (0, import_react44.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app."));
+ }
+- function openTab(botId, url) {
+- ensureTabs(botId);
+- const tabId = `t${Date.now().toString(36)}-${++serial}`;
+- owner.set(tabId, botId);
+- tabs.set((state) => {
+- const own = state[botId] ?? { ids: [], active: null };
+- return { ...state, [botId]: { ids: [...own.ids, tabId], active: tabId } };
+- });
+- publish2(tabId, { url: "", title: "", live: false });
+- if (typeof url === "string" && url !== "") void navigate(tabId, url);
+- persist(botId);
+- return tabId;
++ if (state.ids.length === 0) {
++ return (0, import_react44.createElement)(
++ "div",
++ { className: "bt-browser-empty-mark" },
++ (0, import_react44.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }),
++ (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
++ (0, import_react44.createElement)("div", { className: "bt-muted" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })),
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser"))
++ );
+ }
+- async function closeTab(botId, tabId) {
+- const state = tabs.getSnapshot()[botId];
+- if (state === void 0 || !state.ids.includes(tabId)) return;
+- await opening.get(tabId)?.catch(() => {
+- });
+- if (shown === tabId) await show(null);
+- await release(tabId);
+- tabs.set((snapshot) => {
+- const current = snapshot[botId];
+- if (current === void 0 || !current.ids.includes(tabId)) return snapshot;
+- const at = current.ids.indexOf(tabId);
+- const ids = current.ids.filter((id) => id !== tabId);
+- const active = current.active === tabId ? ids[Math.min(at, ids.length - 1)] ?? null : current.active;
+- const next = { ...snapshot };
+- if (ids.length === 0) delete next[botId];
+- else next[botId] = { ids, active };
+- return next;
+- });
+- owner.delete(tabId);
+- pages.set((snapshot) => {
+- if (snapshot[tabId] === void 0) return snapshot;
+- const { [tabId]: _, ...rest } = snapshot;
+- return rest;
++ return (0, import_react44.createElement)(
++ "div",
++ { className: "bt-bcards" },
++ state.ids.map((tabId, index) => {
++ const page = pages[tabId] ?? {};
++ const label = page.title || shortUrl(page.url) || t("New tab");
++ return (0, import_react44.createElement)(
++ "div",
++ { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } },
++ (0, import_react44.createElement)(
++ "button",
++ { type: "button", className: "bt-bcard-shot", "aria-label": t("Open {title}", { title: label }), title: page.url, onClick: () => actions.browserPick(sessionId, tabId) },
++ page.excerpt ? (0, import_react44.createElement)(
++ "span",
++ { className: "bt-bcard-preview" },
++ (0, import_react44.createElement)("span", { className: "bt-bcard-ptitle" }, label),
++ (0, import_react44.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt)
++ ) : (0, import_react44.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react44.createElement)(GlobeIcon), (0, import_react44.createElement)("span", null, label))
++ ),
++ (0, import_react44.createElement)("button", {
++ type: "button",
++ className: "bt-bcard-x",
++ "aria-label": t("Close tab"),
++ title: t("Close tab"),
++ onClick: () => {
++ void actions.browserCloseTab(bot.id, tabId);
++ }
++ }, "×"),
++ (0, import_react44.createElement)(
++ "div",
++ { className: "bt-bcard-row" },
++ (0, import_react44.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }),
++ (0, import_react44.createElement)("span", { className: "bt-bcard-title" }, label),
++ page.url ? (0, import_react44.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null
++ )
++ );
++ }),
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react44.createElement)(PlusIcon), t("New tab"))
++ );
++}
++function About({ text: text2, title = "About" }) {
++ const [open, setOpen] = (0, import_react44.useState)(false);
++ const long = text2.split("\n").length > 5 || text2.length > 240;
++ const body = (0, import_react44.useRef)(null);
++ useMorph(body, open);
++ return (0, import_react44.createElement)(
++ "div",
++ null,
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t(title)),
++ (0, import_react44.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text2),
++ long ? (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null
++ );
++}
++function SharedRoomApprovals({ room: room2 }) {
++ const [items, setItems] = (0, import_react44.useState)(room2.pendingApprovals ?? []);
++ const [busy, setBusy] = (0, import_react44.useState)("");
++ const face = room2.origin === "face-to-face";
++ (0, import_react44.useEffect)(() => {
++ const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length;
++ if (haveItems) setItems(room2.pendingApprovals);
++ if (room2.viewerRole !== "invited-owner" && !face) return void 0;
++ let alive = true;
++ call("social/status", {}).then((value) => {
++ if (!alive) return;
++ if (room2.viewerRole === "invited-owner" && !haveItems) {
++ setItems(Array.isArray(value?.approvals) ? value.approvals : []);
++ }
++ }, () => {
+ });
+- persist(botId);
+- }
+- function activate(botId, tabId) {
+- ensureTabs(botId);
+- const state = tabs.getSnapshot()[botId];
+- if (state === void 0 || !state.ids.includes(tabId) || state.active === tabId) return;
+- tabs.set((snapshot) => ({ ...snapshot, [botId]: { ...state, active: tabId } }));
+- persist(botId);
+- }
+- const activeTab = (botId) => tabs.getSnapshot()[botId]?.active ?? null;
+- async function show(tabId, box = null) {
+- if (tabId !== null && tabId === shown && visible && box !== null) {
+- rect = box;
+- const entry2 = views.get(tabId);
+- if (entry2) place(entry2);
+- clearTimeout(resizeLater);
+- resizeLater = setTimeout(() => {
+- for (const other of views.values()) place(other);
+- }, RESIZE_LATER_MS);
++ return () => {
++ alive = false;
++ };
++ }, [room2.id, room2.viewerRole, room2.pendingCount, face]);
++ if (room2.viewerRole !== "invited-owner" && !face || !items.length) return null;
++ const decide = async (item, decision) => {
++ setBusy(item.id);
++ try {
++ await call("social/decide-approval", { approvalId: item.id, decision });
++ setItems((list) => list.filter((entry) => entry.id !== item.id));
++ } finally {
++ setBusy("");
++ }
++ };
++ return (0, import_react44.createElement)(
++ "div",
++ { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react44.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))),
++ items.map((item) => (0, import_react44.createElement)(
++ "div",
++ { key: item.id, className: "bt-set-row" },
++ (0, import_react44.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")),
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")),
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject"))
++ ))
++ );
++}
++function FaceMembers({ room: room2, roster, actions }) {
++ const people2 = facePeople(room2, roster);
++ const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot });
++ return (0, import_react44.createElement)(
++ "div",
++ null,
++ (0, import_react44.createElement)(FaceLead, { room: room2, roster, pick: pick2 }),
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })),
++ (0, import_react44.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react44.createElement)(FacePersonRow, {
++ key: person.key,
++ person,
++ roster,
++ className: "bt-member",
++ onOpenBot: (id) => actions.openSession(id),
++ lead: room2.face?.lead?.handle
++ })))
++ );
++}
++function GroupOverview({ room: room2, roster, actions, usage, useWork, openSettings }) {
++ const live = room2.presentedParticipants ?? room2.participants ?? [];
++ const humans = live.filter((p) => p.kind === "human" && p.leftSeq === void 0);
++ const remotes = live.filter((p) => p.remote && p.leftSeq === void 0);
++ const members = room2.members.map((id) => roster.byId[id]).filter(Boolean);
++ const { entries: entries2, now } = useMemberWork(room2, useWork);
++ const invited = room2.viewerRole === "invited-owner";
++ const title = roomTitle(room2);
++ const leaveMine = () => {
++ const mine = remotes.find((p) => p.ownerId === roster.self?.memberId) || members.find((member) => member.ownerId === roster.self?.memberId);
++ if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] });
++ };
++ const face = room2.origin === "face-to-face";
++ return [
++ (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }),
++ invited && room2.sharedFrom && !face ? (0, import_react44.createElement)(
++ "div",
++ { key: "from", className: "bt-shared-from" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, title),
++ (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })),
++ room2.sharedFrom.invitedAt ? (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null,
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot"))
++ ) : face ? null : room2.notice ? (0, import_react44.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react44.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")),
++ face ? null : (0, import_react44.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }),
++ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react44.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react44.createElement)(
++ "div",
++ { key: "members" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })),
++ (0, import_react44.createElement)("div", { className: "bt-members", role: "list" }, [
++ ...humans.map((person) => (0, import_react44.createElement)(
++ "div",
++ { key: person.id, role: "listitem" },
++ (0, import_react44.createElement)(
++ "div",
++ { className: "bt-member" },
++ (0, import_react44.createElement)("span", { className: "bt-member-name" }, (0, import_react44.createElement)("span", null, person.displayName || person.id)),
++ (0, import_react44.createElement)("span", { className: "bt-chip" }, t("Human")),
++ person.role === "admin" ? (0, import_react44.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
++ )
++ )),
++ ...remotes.map((person) => (0, import_react44.createElement)(
++ "div",
++ { key: person.handle || person.id, role: "listitem" },
++ (0, import_react44.createElement)(
++ "div",
++ { className: "bt-member" },
++ (0, import_react44.createElement)(
++ "span",
++ { className: "bt-member-name" },
++ (0, import_react44.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }),
++ (0, import_react44.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person))
++ ),
++ face ? null : (0, import_react44.createElement)("span", { className: "bt-chip" }, t("Friend's Bot"))
++ )
++ )),
++ ...members.map((member) => {
++ const entry = entries2.find((item) => item.id === member.id);
++ return (0, import_react44.createElement)(
++ "div",
++ { key: member.id, role: "listitem" },
++ (0, import_react44.createElement)(
++ "div",
++ { className: "bt-member" },
++ (0, import_react44.createElement)(
++ "button",
++ { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) },
++ (0, import_react44.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }),
++ (0, import_react44.createElement)("span", null, member.name)
++ ),
++ room2.admin === member.id ? (0, import_react44.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null
++ ),
++ entry ? (0, import_react44.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null
++ );
++ })
++ ])
++ ),
++ face ? null : (0, import_react44.createElement)(
++ "div",
++ { key: "mode" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Who answers")),
++ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone")))
++ ),
++ face || !usage ? null : (0, import_react44.createElement)(
++ "div",
++ { key: "usage" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")),
++ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, room2.name), (0, import_react44.createElement)("span", null, usage))
++ )
++ ];
++}
++function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
++ const owner = roster.byId[sessionId];
++ const part = owner ? currentPart(owner) : sessionId;
++ const isMainBot = isMainOf(roster, owner?.id);
++ const [tab, setTab] = (0, import_react44.useState)("details");
++ const room2 = roster.roomsById[sessionId];
++ const [view, setView] = (0, import_react44.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview");
++ const [editing, setEditing] = (0, import_react44.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId])));
++ const [draftName, setDraftName] = (0, import_react44.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : ""));
++ const [copied, setCopied] = (0, import_react44.useState)(false);
++ const [error, setError] = (0, import_react44.useState)("");
++ const glide = useGlide(tab);
++ const last = (0, import_react44.useRef)({ view, tab, move: void 0 });
++ if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" };
++ else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" };
++ const move = last.current.move;
++ const side = move === "tab" ? void 0 : move;
++ const close = () => actions.closeOverlay();
++ useEscape(close);
++ const bot = owner;
++ const running = useSessionStatus((map) => map.get(part)?.running === true);
++ const doing = useActivity((map) => map[bot?.id ?? sessionId]);
++ const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster)));
++ const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []]));
++ const run = (promise) => Promise.resolve(promise).then(() => {
++ setError("");
++ return true;
++ }).catch((failure) => {
++ setError(failure?.message ?? String(failure));
++ return false;
++ });
++ const share = () => {
++ const text2 = transcript;
++ if (!text2) {
++ actions.notify?.(t("Nothing to copy yet"));
+ return;
+ }
+- shown = tabId;
+- if (box) rect = box;
+- visible = tabId !== null && box !== null;
+- for (const entry2 of views.values()) place(entry2);
+- if (tabId === null) return;
+- const entry = await open(tabId);
+- if (entry === void 0 || shown !== tabId) return;
+- entry.usedAt = Date.now();
+- place(entry);
+- }
+- async function navigate(tabId, input) {
+- const url = browserAddress(input);
+- if (url === null || blockedAddress(url)) {
+- publish2(tabId, { error: url === null ? "Only http and https addresses open here." : BLOCKED });
+- return false;
+- }
+- const entry = await open(tabId);
+- if (entry === void 0) return false;
+- publish2(tabId, { url, loading: true, error: null });
+- persist(owner.get(tabId));
+- if (!entry.ready) entry.pending = url;
+- else void entry.element.loadURL(url).catch(() => {
++ void copiedToClipboard(text2).then((written) => {
++ if (!written) {
++ actions.reportError?.(new Error(t("Could not copy the conversation")), "share");
++ return;
++ }
++ setCopied(true);
++ setTimeout(() => setCopied(false), 1600);
++ actions.notify?.(t("Conversation copied as Markdown"));
+ });
+- return true;
++ };
++ const errorLine = error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null;
++ if ((bot || room2) && view === "settings" && !bot?.remote) {
++ return [
++ (0, import_react44.createElement)(
++ "div",
++ { key: "head", className: "bt-subhead", "data-move": move },
++ (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react44.createElement)(ChevronLeftIcon)),
++ (0, import_react44.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")),
++ hasStrip ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react44.createElement)(CloseIcon))
++ ),
++ (0, import_react44.createElement)(
++ "div",
++ { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move },
++ errorLine,
++ bot ? (0, import_react44.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react44.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close })
++ )
++ ];
+ }
+- async function forget(botId) {
+- ensureTabs(botId);
+- for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) {
+- await opening.get(tabId)?.catch(() => {
+- });
+- if (shown === tabId) await show(null);
+- await release(tabId);
+- owner.delete(tabId);
+- pages.set((snapshot) => {
+- if (snapshot[tabId] === void 0) return snapshot;
+- const { [tabId]: _, ...rest } = snapshot;
+- return rest;
+- });
+- }
+- tabs.set((snapshot) => {
+- if (snapshot[botId] === void 0) return snapshot;
+- const { [botId]: _, ...rest } = snapshot;
+- return rest;
+- });
+- if (saved[botId] !== void 0) {
+- const { [botId]: _, ...rest } = saved;
+- saved = rest;
+- writeSaved();
+- }
++ const top = (0, import_react44.createElement)(
++ "div",
++ { className: "bt-drawer-top", "data-move": side },
++ bot || room2 ? (0, import_react44.createElement)("button", {
++ type: "button",
++ className: "bt-round",
++ "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
++ title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"),
++ "data-info-row": "settings",
++ onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings")
++ }, (0, import_react44.createElement)(GearIcon)) : null,
++ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react44.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react44.createElement)(GlobeIcon)) : null,
++ bot || room2 ? (0, import_react44.createElement)("button", {
++ type: "button",
++ className: "bt-round",
++ // #1347: the accessible name says what the click does, which is copy — "Share conversation"
++ // promised a sharing step that never came. It still says "Copied" after a copy, so a screen
++ // reader hears the same confirmation the toast gives.
++ "aria-label": copied ? t("Copied conversation") : t("Copy conversation"),
++ title: copied ? t("Copied") : t("Copy conversation as Markdown"),
++ onClick: share
++ }, copied ? (0, import_react44.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react44.createElement)(ShareIcon)) : null,
++ hasStrip ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react44.createElement)(CloseIcon))
++ );
++ if (!bot && !room2) {
++ return [
++ (0, import_react44.createElement)(import_react44.Fragment, { key: "top" }, top),
++ (0, import_react44.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react44.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team.")))
++ ];
+ }
+- return {
+- tabs,
+- pages,
+- /** Restore a Bot's saved strip (or an empty one) so the panel can render it. */
+- ensureTabs,
+- openTab,
+- closeTab,
+- activate,
+- preview,
+- show,
+- navigate,
+- command(tabId, name) {
+- const entry = views.get(tabId);
+- if (!entry?.ready) return;
+- if (name === "back" && entry.element.canGoBack()) entry.element.goBack();
+- else if (name === "forward" && entry.element.canGoForward()) entry.element.goForward();
+- else if (name === "reload") entry.element.reload();
+- else if (name === "stop") entry.element.stop();
+- },
+- focus(tabId) {
+- views.get(tabId)?.element.focus();
+- },
+- /** Pages a read can reach: each Bot's active tab, live and showing a document. */
+- openPages() {
+- const open2 = {};
+- const snapshot = pages.getSnapshot();
+- for (const [botId, state] of Object.entries(tabs.getSnapshot())) {
+- const tabId = state.active;
+- const entry = tabId ? views.get(tabId) : void 0;
+- const page = tabId ? snapshot[tabId] : void 0;
+- if (entry?.ready && page?.url) open2[botId] = { url: page.url, title: page.title };
+- }
+- return open2;
+- },
+- /** A Bot's read reaches its active tab. */
+- async read(botId) {
+- const tabId = activeTab(botId);
+- const entry = tabId ? views.get(tabId) : void 0;
+- if (!entry?.ready) throw new Error("This Bot has no browser page open.");
+- let timer;
+- const timeout = new Promise((_, reject) => {
+- timer = setTimeout(() => reject(new Error("The page took too long to read.")), READ_TIMEOUT_MS);
+- });
+- try {
+- return await Promise.race([entry.element.executeJavaScript(READ_PAGE_SCRIPT), timeout]);
+- } finally {
+- clearTimeout(timer);
+- }
+- },
+- noteRead(botId) {
+- const tabId = activeTab(botId);
+- if (tabId) publish2(tabId, { readAt: Date.now() });
+- },
+- release,
+- forget,
+- /** Forget every Bot outside `botIds`, as when Bots were deleted from another window. */
+- retain(botIds) {
+- const keep = new Set(botIds);
+- const known = /* @__PURE__ */ new Set([...Object.keys(tabs.getSnapshot()), ...Object.keys(saved), ...owner.values()]);
+- for (const entry of views.values()) known.add(entry.botId);
+- return Promise.all([...known].filter((botId) => !keep.has(botId)).map(forget));
+- },
+- dispose() {
+- disposed = true;
+- clearTimeout(resizeLater);
+- for (const entry of views.values()) void release(entry.tabId);
+- layer?.remove();
+- }
++ const current = bot?.name ?? room2.name;
++ const saveName = async () => {
++ setEditing(false);
++ const next = draftName.trim();
++ if (next === current || room2 && !room2.named && next === "") return;
++ if (bot && next) await run(actions.updateBot(bot.id, { name: next }));
++ if (room2) await run(actions.updateRoom(room2.id, { name: next }));
+ };
++ const subtitle = bot ? bot.role : roomSubtitle(room2, roster);
++ const tabs = bot ? ["details", "browser"] : ["details"];
++ return [
++ (0, import_react44.createElement)(import_react44.Fragment, { key: "top" }, top),
++ (0, import_react44.createElement)(
++ "div",
++ { key: "id", className: "bt-drawer-id", "data-move": side },
++ bot ? (0, import_react44.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react44.createElement)(RoomAvatar, { room: room2, roster, size: 64 }),
++ bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react44.createElement)("input", {
++ className: "bt-drawer-name-input",
++ autoFocus: true,
++ value: draftName,
++ "aria-label": bot ? t("Bot name") : t("Group name"),
++ placeholder: room2 ? t("Name after members") : void 0,
++ onChange: (event) => setDraftName(event.target.value),
++ onBlur: saveName,
++ onKeyDown: (event) => {
++ if (event.key === "Enter") void saveName();
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ setEditing(false);
++ }
++ }
++ }) : (0, import_react44.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => {
++ setDraftName(room2 && !room2.named ? "" : current);
++ setEditing(true);
++ } }, current),
++ subtitle ? (0, import_react44.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react44.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null
++ ),
++ bot?.remote ? null : (0, import_react44.createElement)(
++ "div",
++ { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side },
++ (0, import_react44.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }),
++ tabs.map((key) => (0, import_react44.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key])))
++ ),
++ (0, import_react44.createElement)(
++ "div",
++ { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move },
++ errorLine,
++ room2 && tab === "details" ? (0, import_react44.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null,
++ bot && bot.remote && tab === "details" ? [
++ bot.remote.handle ? (0, import_react44.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null,
++ (0, import_react44.createElement)(FlyBotDetails, { key: "social", bot, roster, actions })
++ ] : null,
++ bot && !bot.remote && tab === "details" ? [
++ (0, import_react44.createElement)(
++ "div",
++ { key: "model", className: "bt-drawer-model" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Model")),
++ (0, import_react44.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }),
++ modelOf(bot, roster) ? null : (0, import_react44.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name }))
++ ),
++ (0, import_react44.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }),
++ (0, import_react44.createElement)(
++ "div",
++ { key: "folder", className: "bt-drawer-model" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Working folder")),
++ (0, import_react44.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) })
++ ),
++ bot.instructions ? (0, import_react44.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react44.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")),
++ roster.memory === false ? null : (0, import_react44.createElement)(MemoryRow, { key: "memory", bot, actions }),
++ usage ? (0, import_react44.createElement)(
++ "div",
++ { key: "usage" },
++ (0, import_react44.createElement)("div", { className: "bt-section-title" }, t("Usage")),
++ (0, import_react44.createElement)("div", { className: "bt-kv" }, (0, import_react44.createElement)("span", null, bot.name), (0, import_react44.createElement)("span", null, usage))
++ ) : null,
++ (0, import_react44.createElement)(ScheduleSummary, { key: "routines", bot, actions })
++ ] : null,
++ bot && !bot.remote && tab === "browser" ? (0, import_react44.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null
++ )
++ ];
++}
++
++// src/client/browser-pane.js
++var import_react45 = require("react");
++var import_react_dom10 = require("react-dom");
++var PANEL_LINGER_MS = 300;
++var WIDTH_KEY = STORAGE.browserWidth;
++var READ_FLASH_MS = 2400;
++var EMPTY_PAGE = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, excerpt: "" };
++var EMPTY_TABS2 = { ids: [], active: null };
++var SPRING_LEAD2 = springEasing(576, 39.4);
++var SPRING_TRAIL2 = springEasing(144, 19.7);
++var icon2 = (paths) => function BrowserIcon() {
++ return (0, import_react45.createElement)(
++ "svg",
++ { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true },
++ paths.map((d, index) => (0, import_react45.createElement)("path", { key: index, d }))
++ );
++};
++var GlobeIcon = icon2(["M10 3a7 7 0 1 0 0 14 7 7 0 0 0 0-14z", "M3 10h14", "M10 3c2 2.2 2.9 4.5 2.9 7S12 14.8 10 17c-2-2.2-2.9-4.5-2.9-7S8 5.2 10 3z"]);
++var BackIcon = icon2(["M12.5 4.5L7 10l5.5 5.5"]);
++var ForwardIcon = icon2(["M7.5 4.5L13 10l-5.5 5.5"]);
++var ReloadIcon = icon2(["M15.5 10a5.5 5.5 0 1 1-1.6-3.9", "M15.5 3.5v3.5H12"]);
++var StopIcon = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
++var CloseIcon2 = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]);
++var PlusTabIcon = icon2(["M10 5v10M5 10h10"]);
++var LockIcon2 = icon2(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]);
++function Favicon({ src, className }) {
++ const [failed, setFailed] = (0, import_react45.useState)(null);
++ if (!src || failed === src) return (0, import_react45.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react45.createElement)(GlobeIcon));
++ return (0, import_react45.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) });
+ }
+-var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+-function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.randomUUID(), retryMs = 1e3 }) {
+- let stopped = false;
+- let poll;
+- let reported = "";
+- const answered = /* @__PURE__ */ new Map();
+- const answer = async ({ id, botId }) => {
+- let job = answered.get(id);
+- if (job === void 0) {
+- job = (async () => {
+- try {
+- const result = { id, ok: true, page: await browsers.read(botId) };
+- browsers.noteRead(botId);
+- return result;
+- } catch (error) {
+- return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
+- }
+- })();
+- answered.set(id, job);
+- if (answered.size > 200) answered.delete(answered.keys().next().value);
++var savedWidth = () => {
++ try {
++ const value = Number(localStorage.getItem(WIDTH_KEY));
++ return Number.isFinite(value) && value >= MIN_DETAILS ? value : null;
++ } catch {
++ return null;
++ }
++};
++var fitWidth = (width, [low, high]) => Math.round(Math.max(low, Math.min(width, high)));
++var widthRange = (view, viewport) => panelWidthRange(view, viewport);
++var room = { rule: null, selector: "" };
++var roomRule = () => {
++ const live = room.rule?.parentStyleSheet?.ownerNode?.isConnected;
++ if (live && room.selector) return room.rule;
++ const column = document.querySelector('[class*="_centerCol"]');
++ const selector = column ? [...column.classList].filter((name) => name.includes("_centerCol")).map((name) => `.${CSS.escape(name)}`).join(",") : "";
++ if (live && !selector) return room.rule;
++ const declarations2 = room.rule?.style.cssText ?? "";
++ room.rule?.parentStyleSheet?.ownerNode?.remove();
++ const style = document.createElement("style");
++ style.dataset.dshBot = "chat-room";
++ style.textContent = `${selector || '[class*="_centerCol"]'}{${declarations2}}`;
++ document.head.append(style);
++ room.rule = style.sheet.cssRules[0];
++ room.selector = selector;
++ return room.rule;
++};
++var roomHold = 0;
++var chatRoom = {
++ /** The column's right margin in px, or null for the shell's own. */
++ set(width) {
++ const { style } = roomRule();
++ if (width === null) style.removeProperty("margin-right");
++ else style.setProperty("margin-right", `${width}px`, "important");
++ },
++ /** While on, a margin change lands at once instead of gliding. */
++ hold(on) {
++ cancelAnimationFrame(roomHold);
++ if (on) {
++ roomRule().style.setProperty("transition", "none", "important");
++ return;
+ }
+- await post("browser-result", await job).catch((error) => console.warn("[ds-bot] browser result", error));
+- };
+- const loop = async () => {
+- let failures = 0;
+- while (!stopped) {
+- poll = new AbortController();
+- const open = browsers.openPages();
+- reported = JSON.stringify(open);
+- try {
+- const requests = await post("browser-wait", { clientId, open }, poll.signal);
+- failures = 0;
+- for (const request of Array.isArray(requests) ? requests : []) void answer(request);
+- } catch {
+- if (stopped) return;
+- if (poll.signal.aborted) continue;
+- failures += 1;
+- await sleep(Math.min(3e4, retryMs * 2 ** Math.min(failures, 5)));
+- }
++ roomHold = requestAnimationFrame(() => {
++ roomHold = requestAnimationFrame(() => roomRule().style.removeProperty("transition"));
++ });
++ },
++ /** The columns themselves, for a drag's per-frame writes. */
++ columns() {
++ roomRule();
++ return room.selector ? [...document.querySelectorAll(room.selector)] : [];
++ }
++};
++var shortAddress = (url) => String(url ?? "").replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
++var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
++var tabLabel = (page) => page.title || shortAddress(page.url) || t("New tab");
++function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, useBrowserPages }) {
++ const pages = useBrowserPages((map) => map);
++ const wrap = (0, import_react45.useRef)(null);
++ const ind = (0, import_react45.useRef)(null);
++ const spot = (0, import_react45.useRef)(null);
++ const under = (0, import_react45.useRef)(null);
++ (0, import_react45.useLayoutEffect)(() => {
++ const box = wrap.current;
++ const capsule = ind.current;
++ if (!box || !capsule) return void 0;
++ const node = view === "details" ? box.querySelector(".bt-ptab-bot") : box.querySelector(`[data-tab="${active}"]`);
++ under.current = node;
++ if (!node) {
++ capsule.style.opacity = "0";
++ spot.current = null;
++ return void 0;
+ }
+- };
+- const offPages = browsers.pages.subscribe(() => {
+- if (JSON.stringify(browsers.openPages()) !== reported) poll?.abort();
++ if (view === "details") box.scrollLeft = 0;
++ else node.scrollIntoView?.({ block: "nearest", inline: "nearest" });
++ const to = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
++ const from = spot.current;
++ spot.current = to;
++ capsule.style.opacity = "1";
++ capsule.style.left = `${to.left}px`;
++ capsule.style.right = `${to.right}px`;
++ if (from === null || from.left === to.left && from.right === to.right || reducedMotion2()) return void 0;
++ const ahead = to.left > from.left ? "right" : "left";
++ const behind = ahead === "right" ? "left" : "right";
++ const lead = capsule.animate([{ [ahead]: `${from[ahead]}px` }, { [ahead]: `${to[ahead]}px` }], { duration: SPRING_LEAD2.duration, easing: SPRING_LEAD2.easing });
++ const trail = capsule.animate([{ [behind]: `${from[behind]}px` }, { [behind]: `${to[behind]}px` }], { duration: SPRING_TRAIL2.duration, easing: SPRING_TRAIL2.easing });
++ return () => {
++ lead.cancel();
++ trail.cancel();
++ };
+ });
+- void loop();
+- return () => {
+- stopped = true;
+- offPages();
+- poll?.abort();
+- };
+-}
+-
+-// src/client/mobile.js
+-var MOBILE_MAX_WIDTH = 640;
+-var MOBILE_CSS = `
+-@media (max-width:${MOBILE_MAX_WIDTH}px){
+-.bt-held-actions{max-width:100%}
+-.bt-held-card{padding:10px 12px}
+-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px}
+-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer}
+-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
+-:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default}
+-.bt-held-retry{min-height:32px}
+-.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))}
+-.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)}
+-.bt-flybot-pointer{max-width:100%;min-width:0}
+-.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none}
+-.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
+-.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px}
+-.bt-inbox-card{flex-wrap:wrap;row-gap:8px}
+-.bt-inbox-copy{flex-basis:calc(100% - 38px)}
+-/* In progress and Done only. A Needs you ask is the whole text: inbox.js unsets this clamp on those five statuses (#1177). */
+-.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
+-.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px}
+-.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px}
+-}
+-`;
+-
+-// src/client/panes.js
+-var PANE_MIN_WIDTH_CSS = `
+-.bt-panel{min-width:0}
+-.bt-panel-view{min-width:0}
+-.bt-drawer-body{min-width:0;overflow-wrap:anywhere}
+-.bt-drawer-body :is(.bt-muted,.bt-note,.bt-section-title,.bt-drawer-role,.bt-drawer-permission){min-width:0;overflow-wrap:anywhere}
+-.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
+-.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
+-[class*="_centerCol"]{min-width:0}
+-html,body,#root{overflow-x:clip}
+-[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
+-[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
+-`;
+-var COMPOSER = "[data-composer-input], [data-composer-card]";
+-function clipScroll(el) {
+- if (el && el.scrollLeft > 0) el.scrollLeft = 0;
+-}
+-function appFrameOf(doc) {
+- if (typeof doc.querySelector !== "function") return null;
+- const col = doc.querySelector('[class*="_centerCol"]');
+- const frame = col?.parentElement;
+- if (!frame) return null;
+- const name = frame.className;
+- const text2 = typeof name === "string" ? name : name?.baseVal ?? "";
+- return text2.includes("_frame") ? frame : null;
+-}
+-function levelShell(doc, from) {
+- const page = doc.scrollingElement || doc.documentElement;
+- clipScroll(page);
+- clipScroll(doc.documentElement);
+- clipScroll(doc.body);
+- clipScroll(appFrameOf(doc));
+- for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at);
+-}
+-function composerOf(node) {
+- let el = node;
+- if (el && el.nodeType !== 1) el = el.parentElement;
+- if (!el || typeof el.closest !== "function") return null;
+- return el.closest(COMPOSER);
++ (0, import_react45.useEffect)(() => {
++ const box = wrap.current;
++ if (!box) return void 0;
++ const observer = new ResizeObserver(() => {
++ const node = under.current;
++ const capsule = ind.current;
++ if (!node?.isConnected || !capsule || spot.current === null) return;
++ spot.current = { left: node.offsetLeft, right: box.offsetWidth - node.offsetLeft - node.offsetWidth };
++ capsule.style.left = `${spot.current.left}px`;
++ capsule.style.right = `${spot.current.right}px`;
++ });
++ observer.observe(box);
++ return () => observer.disconnect();
++ }, []);
++ return (0, import_react45.createElement)(
++ "div",
++ { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") },
++ (0, import_react45.createElement)(
++ "div",
++ { className: "bt-ptabs-in", ref: wrap },
++ (0, import_react45.createElement)(
++ "button",
++ {
++ type: "button",
++ role: "tab",
++ "aria-selected": view === "details",
++ className: "bt-ptab bt-ptab-bot",
++ title: bot.name,
++ onClick: () => onView("details")
++ },
++ (0, import_react45.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }),
++ view === "details" ? (0, import_react45.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null
++ ),
++ ids.map((tabId) => {
++ const page = pages[tabId] ?? EMPTY_PAGE;
++ const on = view === "browser" && tabId === active;
++ return (0, import_react45.createElement)(
++ "button",
++ {
++ key: tabId,
++ type: "button",
++ role: "tab",
++ "aria-selected": on,
++ "data-tab": tabId,
++ className: "bt-ptab",
++ title: page.url || t("New tab"),
++ onClick: () => {
++ actions.browserActivate(bot.id, tabId);
++ onView("browser");
++ },
++ onAuxClick: (event) => {
++ if (event.button === 1) {
++ event.preventDefault();
++ void actions.browserCloseTab(bot.id, tabId);
++ }
++ }
++ },
++ (0, import_react45.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }),
++ (0, import_react45.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)),
++ (0, import_react45.createElement)("span", {
++ className: "bt-ptab-x",
++ role: "button",
++ "aria-label": t("Close tab"),
++ title: t("Close tab"),
++ onClick: (event) => {
++ event.stopPropagation();
++ void actions.browserCloseTab(bot.id, tabId);
++ }
++ }, "×")
++ );
++ }),
++ (0, import_react45.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true })
++ ),
++ // New tab and Close stay outside the scrolling tabs, so a narrow panel keeps both.
++ (0, import_react45.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react45.createElement)(PlusTabIcon)),
++ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn bt-ptab-close", "aria-label": t("Close panel"), title: t("Close panel"), onClick: () => actions.closePanel() }, (0, import_react45.createElement)(CloseIcon2))
++ );
+ }
+-function watchShellLevel(doc) {
+- const later = (from) => {
+- const reset = () => levelShell(doc, from);
+- reset();
+- if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
+- };
+- const onFocus = (event) => later(composerOf(event.target) || void 0);
+- const onInput = (event) => {
+- const composer = composerOf(event.target);
+- if (composer) later(composer);
+- };
+- const onScroll = (event) => {
+- const target = event.target;
+- if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc);
+- };
+- doc.addEventListener("focusin", onFocus);
+- doc.addEventListener("input", onInput);
+- doc.addEventListener("scroll", onScroll, true);
+- return () => {
+- doc.removeEventListener("focusin", onFocus);
+- doc.removeEventListener("input", onInput);
+- doc.removeEventListener("scroll", onScroll, true);
++function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) {
++ const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE;
++ const frame = (0, import_react45.useRef)(null);
++ const input = (0, import_react45.useRef)(null);
++ const [draft, setDraft] = (0, import_react45.useState)(null);
++ const showGuest = landed && !leaving && Boolean(page.url) && !page.error;
++ (0, import_react45.useLayoutEffect)(() => {
++ const element = frame.current;
++ if (!element || leaving || !tabId) {
++ void actions.browserShow(null);
++ return void 0;
++ }
++ let raf = 0;
++ const measure = () => {
++ cancelAnimationFrame(raf);
++ raf = 0;
++ const box = element.getBoundingClientRect();
++ void actions.browserShow(tabId, showGuest ? { left: box.left, top: box.top, width: box.width, height: box.height } : null);
++ };
++ const schedule = () => {
++ if (!raf) raf = requestAnimationFrame(measure);
++ };
++ measure();
++ const observer = new ResizeObserver(measure);
++ observer.observe(element);
++ window.addEventListener("resize", schedule);
++ return () => {
++ cancelAnimationFrame(raf);
++ observer.disconnect();
++ window.removeEventListener("resize", schedule);
++ };
++ }, [tabId, showGuest, leaving]);
++ (0, import_react45.useEffect)(() => {
++ setDraft(null);
++ if (landed && !page.url) input.current?.focus();
++ }, [tabId, landed]);
++ const go = (event) => {
++ event.preventDefault();
++ const text2 = draft ?? "";
++ if (text2.trim() === "") return;
++ setDraft(null);
++ input.current?.blur();
++ void actions.browserNavigate(tabId, text2).then((opened) => {
++ if (opened) actions.browserFocus(tabId);
++ });
+ };
+-}
+-
+-// src/client/project-board.js
+-var import_react47 = require("react");
+-var PROJECT_BOARD_CSS = `
+-/* #1750: the board was a short dialog in the page flow — no stylesheet defines 'bt-overlay', so the
+- bot tree and workflows stayed legible under its rows. It is a modal now, on the same tier as the
+- Board and Settings layers, with kanban's own 'bt-scrim' sibling and 'bt-mem' surface. */
+-.bt-project-overlay{position:fixed;inset:0;z-index:121;isolation:isolate;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif}
+-.bt-mem.bt-project-dialog{width:min(1080px,calc(100vw - 32px));height:min(760px,calc(100vh - 48px))}
+-.bt-project-body{flex:1 1 auto;min-height:0;overflow:auto}
+-.bt-project-board{padding:16px 18px}
+-.bt-project-group{margin:0 0 14px}
+-.bt-project-group h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+-.bt-project-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px;cursor:default}
+-.bt-project-row[data-column="done"]{opacity:.7}
+-.bt-project-title-wrap{display:flex;flex-direction:column;gap:2px;min-width:0}
+-.bt-project-audit{font-size:11px;line-height:1.45;white-space:pre-wrap;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-row .bt-project-ref{margin-left:auto;font-size:11px;opacity:.6}
+-.bt-project-empty{padding:22px 4px;opacity:.7}
+-/* #1767: the record beside the cards. The same surface as a row, one step quieter, and no new
+- component language: a heading, a list, and a monospace hash the reader can compare. */
+-.bt-project-record{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
+-.bt-project-record h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+-.bt-project-record h5{margin:10px 0 4px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;opacity:.55}
+-.bt-project-decision,.bt-project-meeting{display:flex;gap:10px;align-items:baseline;padding:6px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
+-.bt-project-decision .bt-project-date,.bt-project-meeting .bt-project-date{font-size:11px;opacity:.6;min-width:76px;font-variant-numeric:tabular-nums}
+-.bt-project-decision .bt-project-hash{margin-left:auto;font-size:11px;opacity:.55;font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-decision .bt-project-title,.bt-project-meeting .bt-project-title{min-width:0}
+-.bt-project-path{font-size:11px;opacity:.6;font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-missing{font-size:11px;opacity:.6}
+-.bt-project-status{font-size:11px;text-transform:uppercase;letter-spacing:.04em;opacity:.6;margin-left:auto}
+-/* #1831: the card's level, the people it is addressed to, and the identity a row or a comment
+- carries. A severe card is the only one painted louder, because it is the only one that waits. */
+-.bt-project-level{font-size:10px;text-transform:uppercase;letter-spacing:.06em;padding:1px 6px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));opacity:.75}
+-.bt-project-level[data-level="severe"]{opacity:1;border-color:#c2410c;color:#c2410c}
+-.bt-project-recipients{font-size:11px;opacity:.65}
+-.bt-project-paused{font-size:11px;opacity:.75;color:#c2410c}
+-.bt-project-holder{font-size:11px;opacity:.7}
+-.bt-project-speaks-for{font-size:11px;opacity:.7;font-style:italic}
+-.bt-project-actions{display:flex;gap:8px;margin:0 0 14px}
+-.bt-project-metrics{margin:0 0 14px}
+-.bt-project-metric-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:8px}
+-.bt-project-metric{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid rgba(127,127,127,.24);border-radius:8px}
+-.bt-project-metric-value{font-size:18px;font-weight:600}
+-.bt-project-metric-label{font-size:12px;font-weight:600}
+-.bt-project-metric-caption{font-size:10px;opacity:.6;line-height:1.3}
+-.bt-project-metrics-note{margin:0 0 6px;font-size:11px;opacity:.7}
+-.bt-project-running{margin:0 0 14px}
+-.bt-project-running-head,.bt-project-running-row{display:grid;grid-template-columns:2fr 1fr 1fr .8fr .8fr 1.2fr;gap:8px;font-size:12px;padding:3px 0}
+-.bt-project-running-head{opacity:.6;font-size:10px;letter-spacing:.03em}
+-.bt-project-running-row[role="button"]{cursor:pointer}
+-.bt-project-running-row[data-overdue="true"]{color:#c2410c}
+-.bt-project-counts{display:flex;gap:14px;align-items:center;margin:0 0 12px;font-size:12px;letter-spacing:.03em;opacity:.8}
+-.bt-project-counts b{font-weight:600}
+-.bt-project-group[data-queue="waiting"] .bt-project-row{cursor:default}
+-.bt-project-role{font-size:11px;opacity:.65;font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-wait{font-size:11px;opacity:.65;margin-left:auto}
+-.bt-project-state{font-size:11px;margin-left:auto}
+-.bt-project-row[data-state="stalled"]{border-left:2px solid #d9822b}
+-.bt-project-reason{font-size:11px;opacity:.7}
+-
+-/* #1855: the finished cards are one group of their own, closed until the reader opens it — a
+- Project with months of history reads as the work in flight, not as its archive. */
+-.bt-project-done{margin:18px 0 0;border-top:1px solid var(--bt-line,rgba(127,127,127,.22));padding:12px 0 0}
+-.bt-project-done>summary{cursor:pointer;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7;list-style:none}
+-.bt-project-done>summary::-webkit-details-marker{display:none}
+-.bt-project-done>summary::before{content:'\\25B8';display:inline-block;width:12px;opacity:.6}
+-.bt-project-done[open]>summary::before{content:'\\25BE'}
+-.bt-project-done[open]>summary{margin:0 0 8px}
+-
+-/* #1749: management panels. Same surface as the board, one form row per control, and a status
+- line that quotes the host's own answer — a refused save reads where the reader pressed it. */
+-.bt-project-panel{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
+-.bt-project-panel h3{margin:0}
+-.bt-project-field{display:flex;flex-direction:column;gap:4px}
+-.bt-project-field label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+-.bt-project-field input,.bt-project-field select,.bt-project-field textarea{font:inherit;padding:6px 8px;border-radius:8px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:var(--bt-card,rgba(127,127,127,.08));color:inherit}
+-.bt-project-field textarea{min-height:88px;resize:vertical}
+-.bt-project-row-actions{display:flex;gap:8px;align-items:center}
+-.bt-project-members{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
+-.bt-project-member{display:flex;gap:10px;align-items:baseline;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08))}
+-.bt-project-member .bt-project-member-kind{margin-left:auto;font-size:11px;opacity:.6}
+-.bt-project-status-line{font-size:12px;opacity:.75;min-height:16px}
+-.bt-project-note{font-size:12px;opacity:.65;margin:0}
+-
+-/* #1860: the Project team — who runs the Project, who speaks for the reader, and the role pool.
+- One quiet block above the cards, and a Bot row is a button because a reader is looking a
+- person up. The pool row keeps the rank it plays and the ranks the host said it may switch to. */
+-.bt-project-team{margin:0 0 14px}
+-.bt-project-team h4{margin:0 0 6px;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+-.bt-project-team-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border-radius:8px;background:var(--bt-card,rgba(127,127,127,.08));margin:0 0 6px}
+-.bt-project-team-row[data-open-bot]{cursor:pointer}
+-.bt-project-team-row[data-open-bot]:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+-.bt-project-team-label{font-size:12px;opacity:.7;min-width:96px}
+-.bt-project-team-bot{font-size:12px;font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
+-.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
+-.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
+-`;
+-var PROJECT_SOURCES = ["bot", "group", "workflow"];
+-var SOURCE_LABELS = { bot: "Bots", group: "Groups", workflow: "Workflows" };
+-var text = (value) => typeof value === "string" ? value.trim() : "";
+-var DONE_COLUMN = "done";
+-var boardOfRow = (row, task) => {
+- if (task === void 0) return null;
+- if (row.source === "group") {
+- const id = text(task.origin?.roomId);
+- return id === "" ? null : { kind: "group", id };
+- }
+- if (row.source === "bot") {
+- const id = text(task.ownerBotId);
+- return id === "" ? null : { kind: "bot", id };
++ const editing = draft !== null;
++ const secure = /^https:/i.test(page.url);
++ return (0, import_react45.createElement)(
++ "div",
++ { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) },
++ (0, import_react45.createElement)(
++ "div",
++ { className: "bt-browser-bar" },
++ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back"), title: t("Back"), disabled: !page.canGoBack, onClick: () => actions.browserCommand(tabId, "back") }, (0, import_react45.createElement)(BackIcon)),
++ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Forward"), title: t("Forward"), disabled: !page.canGoForward, onClick: () => actions.browserCommand(tabId, "forward") }, (0, import_react45.createElement)(ForwardIcon)),
++ (0, import_react45.createElement)("button", {
++ type: "button",
++ className: "bt-icon-btn",
++ disabled: !page.url,
++ "aria-label": page.loading ? t("Stop") : t("Reload"),
++ title: page.loading ? t("Stop") : t("Reload"),
++ onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload")
++ }, (0, import_react45.createElement)(page.loading ? StopIcon : ReloadIcon)),
++ (0, import_react45.createElement)(
++ "form",
++ { className: "bt-browser-address", onSubmit: go, "data-editing": editing || void 0 },
++ (0, import_react45.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react45.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)),
++ (0, import_react45.createElement)("input", {
++ ref: input,
++ type: "text",
++ spellCheck: false,
++ autoComplete: "off",
++ "aria-label": t("Address"),
++ placeholder: t("Search or enter address"),
++ value: editing ? draft : shortAddress(page.url),
++ onFocus: (event) => {
++ setDraft(page.url);
++ const target = event.target;
++ requestAnimationFrame(() => target.select());
++ },
++ onBlur: () => setDraft(null),
++ onChange: (event) => setDraft(event.target.value),
++ onKeyDown: (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ setDraft(null);
++ event.currentTarget.blur();
++ }
++ }
++ })
++ ),
++ page.loading ? (0, import_react45.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null
++ ),
++ (0, import_react45.createElement)(
++ "div",
++ { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 },
++ page.error ? (0, import_react45.createElement)(
++ "div",
++ { className: "bt-browser-empty" },
++ (0, import_react45.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")),
++ (0, import_react45.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)),
++ page.url ? (0, import_react45.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => {
++ void actions.browserNavigate(tabId, page.url);
++ } }, t("Try again")) : null
++ ) : !page.url ? (0, import_react45.createElement)(
++ "div",
++ { className: "bt-browser-empty" },
++ (0, import_react45.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }),
++ (0, import_react45.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })),
++ (0, import_react45.createElement)("div", { className: "bt-browser-empty-text" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name }))
++ ) : null
++ )
++ );
++}
++function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }) {
++ const bot = roster.byId[pane.id];
++ const canBrowse = Boolean(bot && roster.browser === true && actions.browserShow);
++ const view = pane.view === "browser" && canBrowse ? "browser" : "details";
++ const tabState = useBrowserTabs((map) => bot ? map[bot.id] ?? EMPTY_TABS2 : EMPTY_TABS2);
++ const active = tabState.active;
++ const page = useBrowserPages((pages) => (active ? pages[active] : null) ?? EMPTY_PAGE);
++ const root = (0, import_react45.useRef)(null);
++ const fly = (0, import_react45.useRef)(null);
++ const [landed, setLanded] = (0, import_react45.useState)(false);
++ const [settling, setSettling] = (0, import_react45.useState)(false);
++ const [width, setWidth] = (0, import_react45.useState)(savedWidth);
++ const [viewport, setViewport] = (0, import_react45.useState)(() => window.innerWidth);
++ const [dragging, setDragging] = (0, import_react45.useState)(false);
++ const [resizing, setResizing] = (0, import_react45.useState)(false);
++ const instant = dragging || resizing;
++ const [reading, setReading] = (0, import_react45.useState)(false);
++ const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport));
++ (0, import_react45.useLayoutEffect)(() => {
++ if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH);
++ }, [view, width]);
++ (0, import_react45.useLayoutEffect)(() => {
++ chatRoom.hold(instant);
++ }, [instant]);
++ const [last, setLast] = (0, import_react45.useState)({ view, width: panelWidth });
++ if (last.view !== view || last.width !== panelWidth) {
++ setLast({ view, width: panelWidth });
++ if (last.view !== view) setSettling(last.width !== panelWidth && !instant);
+ }
+- return null;
+-};
+-var decisionOf = (file) => {
+- if (file === null || typeof file !== "object") return null;
+- const path = text(file.path);
+- if (path === "") return null;
+- return {
+- key: `decision:${path}`,
+- path,
+- name: text(file.name) || path.slice(path.lastIndexOf("/") + 1),
+- date: text(file.date),
+- title: text(file.title),
+- sha256: text(file.sha256),
+- shortHash: text(file.sha256).slice(0, 12)
+- };
+-};
+-var meetingOf = (meeting, status) => {
+- if (meeting === null || typeof meeting !== "object") return null;
+- const id = text(meeting.id);
+- if (id === "") return null;
+- return {
+- key: `meeting:${id}`,
+- id,
+- date: text(meeting.date),
+- question: text(meeting.question) || text(meeting.title),
+- owner: text(meeting.owner) || null,
+- status,
+- path: text(meeting.path),
+- blockingQuestion: text(meeting.blockingQuestion) || null,
+- // #1831: the level and recipients the host answered, and the refusal to advance a severe card.
+- // Nothing is inferred here: a record written before the slice carries an ordinary card and no
+- // recipients, exactly as the host reads it.
+- level: text(meeting.level) || "ordinary",
+- recipients: Array.isArray(meeting.recipients) ? meeting.recipients.map((item) => text(item)).filter((name) => name !== "") : [],
+- advance: meeting.advance !== null && typeof meeting.advance === "object" ? { allowed: meeting.advance.allowed === true, reason: text(meeting.advance.reason) || null } : { allowed: true, reason: null },
+- // #1861: the host's participants with who each position speaks for, so the row can say a
+- // Project Chief or an agent held it for someone rather than for themselves.
+- participants: Array.isArray(meeting.participants) ? meeting.participants.map((participant) => ({ name: text(participant?.name), speaksFor: text(participant?.speaksFor) || null })).filter((participant) => participant.name !== "") : []
+- };
+-};
+-var listOf = (value) => Array.isArray(value) ? value.map((item) => decisionOf(item)).filter((item) => item !== null) : [];
+-function projectBoardRecord({ decisions, meetings } = {}) {
+- const dir = text(decisions?.dir) || null;
+- const files = listOf(decisions?.files);
+- const decided = Array.isArray(meetings?.decided) ? meetings.decided.map((item) => meetingOf(item, "decided")).filter((item) => item !== null) : [];
+- const pending = Array.isArray(meetings?.pending) ? meetings.pending.map((item) => meetingOf(item, item?.status ?? "deliberating")).filter((item) => item !== null) : [];
+- return {
+- decisions: {
+- dir,
+- files,
+- count: files.length,
+- empty: files.length === 0,
+- emptyText: text(decisions?.reason) || (dir === null ? "This Project names no working folder." : `No decision file in ${dir}/decisions.`)
+- },
+- meetings: {
+- decided,
+- pending,
+- count: decided.length + pending.length,
+- empty: decided.length === 0 && pending.length === 0,
+- emptyText: text(meetings?.reason) || "No meeting of this Project is recorded."
++ const shownView = (0, import_react45.useRef)(view);
++ (0, import_react45.useLayoutEffect)(() => {
++ const switched = settling && shownView.current !== view;
++ shownView.current = view;
++ if (switched) chatRoom.hold(true);
++ chatRoom.set(leaving ? null : panelWidth);
++ if (switched) chatRoom.hold(false);
++ }, [view, panelWidth, leaving]);
++ (0, import_react45.useEffect)(() => () => {
++ chatRoom.set(null);
++ chatRoom.hold(false);
++ }, []);
++ (0, import_react45.useEffect)(() => {
++ let timer;
++ const onResize = () => {
++ setResizing(true);
++ setViewport(window.innerWidth);
++ clearTimeout(timer);
++ timer = setTimeout(() => setResizing(false), 160);
++ };
++ window.addEventListener("resize", onResize);
++ return () => {
++ clearTimeout(timer);
++ window.removeEventListener("resize", onResize);
++ };
++ }, []);
++ (0, import_react45.useEffect)(() => {
++ if (!settling) return void 0;
++ const timer = setTimeout(() => setSettling(false), SPRING_SHAPE.duration + 40);
++ return () => clearTimeout(timer);
++ }, [settling, view]);
++ (0, import_react45.useEffect)(() => {
++ const timer = setTimeout(() => setLanded(true), 360);
++ return () => clearTimeout(timer);
++ }, []);
++ (0, import_react45.useEffect)(() => {
++ if (canBrowse) actions.browserEnsure(bot.id);
++ }, [bot?.id, canBrowse]);
++ (0, import_react45.useEffect)(() => {
++ if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details");
++ }, [view, active, tabState.ids.length]);
++ (0, import_react45.useEffect)(() => {
++ if (view !== "browser" || leaving) void actions.browserShow?.(null);
++ }, [view, leaving]);
++ (0, import_react45.useEffect)(() => () => {
++ void actions.browserShow?.(null);
++ }, []);
++ (0, import_react45.useEffect)(() => {
++ if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0;
++ setReading(true);
++ const timer = setTimeout(() => setReading(false), READ_FLASH_MS);
++ return () => clearTimeout(timer);
++ }, [page.readAt]);
++ const beginSwitch = (next) => {
++ if (reducedMotion2()) return;
++ const panel = root.current;
++ const outgoing = panel?.querySelector(".bt-panel-view");
++ if (outgoing) {
++ const ghostEl = document.createElement("div");
++ ghostEl.className = "bt-panel-ghost";
++ ghostEl.style.width = `${outgoing.offsetWidth}px`;
++ ghostEl.append(outgoing.cloneNode(true));
++ outgoing.parentElement.append(ghostEl);
++ void ghostEl.animate([
++ { opacity: 1, transform: "scale(1)" },
++ { opacity: 0, transform: "scale(0.985)" }
++ ], { duration: 160, easing: "ease-out", fill: "forwards" }).finished.catch(() => {
++ }).finally(() => ghostEl.remove());
+ }
++ const source = next === "browser" ? panel?.querySelector(".bt-drawer-id .bt-mark") : panel?.querySelector(".bt-ptab-bot .bt-mark");
++ if (source) fly.current = { node: source.cloneNode(true), from: source.getBoundingClientRect() };
+ };
+-}
+-function projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics } = {}) {
+- const isRow = (row) => row !== null && typeof row === "object" && text(row.id) !== "";
+- const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
+- const list = [];
+- const archived = [];
+- const seen = /* @__PURE__ */ new Set();
+- for (const row of [...Array.isArray(done) ? done : [], ...Array.isArray(rows) ? rows : []]) {
+- if (!isRow(row)) continue;
+- const id = text(row.id);
+- if (seen.has(id)) continue;
+- seen.add(id);
+- const column = text(row.column) || "not-started";
+- (column === DONE_COLUMN ? archived : list).push(row);
++ const switchView = (next) => {
++ if (next === view || leaving) return;
++ beginSwitch(next);
++ actions.setPanelView(next);
++ };
++ const panelActions = { ...actions };
++ for (const name of ["openBrowser", "browserNewTab", "browserPick"]) {
++ if (actions[name]) panelActions[name] = (...args) => {
++ if (view !== "browser") beginSwitch("browser");
++ return actions[name](...args);
++ };
+ }
+- const projectRow = (row) => {
+- const task = byId.get(text(row.id));
+- return {
+- id: text(row.id),
+- key: `task:${text(row.id)}`,
+- column: text(row.column) || "not-started",
+- assignee: text(row.assignee) || null,
+- runId: text(row.runId) || null,
+- nodeId: text(row.nodeId) || null,
+- title: text(task?.title) || text(task?.note) || null,
+- priority: text(task?.priority) || null,
+- audit: text(row.audit) || null,
+- // #1831: who is doing the card, and who is speaking for whom. Both are host truth;
+- // a card carrying neither renders exactly as before.
+- holder: text(row.holder) || null,
+- speaksFor: text(row.speaksFor) || null,
+- board: boardOfRow(row, task)
++ (0, import_react45.useLayoutEffect)(() => {
++ const move = fly.current;
++ if (!move) return;
++ fly.current = null;
++ const target = view === "browser" ? root.current?.querySelector(".bt-ptab-bot .bt-mark") : root.current?.querySelector(".bt-drawer-id .bt-mark");
++ if (!target || move.from.width === 0) return;
++ const growing = root.current.getAnimations({ subtree: true }).filter((animation) => animation.animationName === "bt-strip-in");
++ const at = growing.map((animation) => animation.currentTime);
++ for (const animation of growing) animation.currentTime = animation.effect.getComputedTiming().endTime;
++ const to = target.getBoundingClientRect();
++ growing.forEach((animation, index) => {
++ animation.currentTime = at[index];
++ });
++ const clone = move.node;
++ Object.assign(clone.style, {
++ position: "fixed",
++ left: `${move.from.left}px`,
++ top: `${move.from.top}px`,
++ width: `${move.from.width}px`,
++ height: `${move.from.height}px`,
++ margin: "0",
++ transformOrigin: "0 0",
++ zIndex: "80",
++ pointerEvents: "none",
++ visibility: ""
++ });
++ document.body.append(clone);
++ target.style.visibility = "hidden";
++ const scale = to.width / move.from.width;
++ const anim = clone.animate([
++ { transform: "translate(0px, 0px) scale(1)" },
++ { transform: `translate(${to.left - move.from.left}px, ${to.top - move.from.top}px) scale(${scale})` }
++ ], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, fill: "forwards" });
++ void anim.finished.catch(() => {
++ }).finally(() => {
++ clone.remove();
++ target.style.visibility = "";
++ });
++ }, [view, pane.id]);
++ const startResize = (event) => {
++ if (event.button !== 0 || leaving) return;
++ event.preventDefault();
++ const panel = root.current;
++ const grip = event.currentTarget;
++ grip.setPointerCapture?.(event.pointerId);
++ const range = widthRange(view, window.innerWidth);
++ const from = { x: event.clientX, width: panelWidth };
++ let next = panelWidth;
++ setDragging(true);
++ setSettling(false);
++ chatRoom.hold(true);
++ const columns = chatRoom.columns();
++ for (const column of columns) column.style.setProperty("transition", "none", "important");
++ const onMove = (move) => {
++ next = fitWidth(from.width + from.x - move.clientX, range);
++ panel.style.width = `${next}px`;
++ if (columns.length === 0) chatRoom.set(next);
++ for (const column of columns) column.style.setProperty("margin-right", `${next}px`, "important");
++ };
++ const ends = ["pointerup", "pointercancel", "lostpointercapture"];
++ const onEnd = (end) => {
++ if (end.type === "pointerup") onMove(end);
++ grip.removeEventListener("pointermove", onMove);
++ for (const name of ends) grip.removeEventListener(name, onEnd);
++ chatRoom.set(next);
++ for (const column of columns) {
++ column.style.removeProperty("margin-right");
++ column.style.removeProperty("transition");
++ }
++ panel.style.setProperty("--bt-panel-w", `${next}px`);
++ panel.style.removeProperty("width");
++ setDragging(false);
++ if (next === from.width) return;
++ setWidth(next);
++ try {
++ localStorage.setItem(WIDTH_KEY, String(next));
++ } catch {
++ }
+ };
++ grip.addEventListener("pointermove", onMove);
++ for (const name of ends) grip.addEventListener(name, onEnd);
+ };
+- const groups = PROJECT_SOURCES.map((source) => ({
+- source,
+- label: SOURCE_LABELS[source],
+- rows: list.filter((row) => row.source === source).map(projectRow)
+- })).filter((group) => group.rows.length > 0);
+- const countOf = (value) => value === void 0 || value === null ? null : value;
+- const queueItems = (items, annotate) => (Array.isArray(items) ? items : []).filter((item) => item !== null && typeof item === "object" && text(item.id) !== "").map((item) => {
+- const id = text(item.id);
+- const source = text([...list, ...archived].find((row) => text(row.id) === id)?.source);
+- const task = byId.get(id);
+- return { id, key: `task:${id}`, title: text(task?.title) || text(task?.note) || null, priority: text(task?.priority) || null, board: boardOfRow({ source }, task), ...annotate(item) };
+- });
+- const waiting = queueItems(queues?.waiting, (item) => ({ role: text(item.role) || null, waitedMs: Number.isFinite(item.waitedMs) ? item.waitedMs : null }));
+- const inflight = queueItems(queues?.inflight, (item) => ({ holder: text(item.holder) || null, state: item.stalled === true ? "stalled" : "running" }));
+- const blocked = queueItems(queues?.blocked, (item) => ({ reason: text(item.reason) || null }));
+- const queueGroups = [
+- { group: "waiting", label: "Waiting", items: waiting },
+- { group: "inflight", label: "In flight", items: inflight },
+- { group: "blocked", label: "Blocked", items: blocked }
+- ].filter((entry) => entry.items.length > 0);
+- const rawCounts = queues?.counts ?? null;
+- return {
+- projectId: text(project?.id) || null,
+- title: text(project?.name) || "Project",
+- canManage: canManage === true,
+- groups,
+- // #1855: one group, closed until the reader opens it. Rows keep the row shape above, so a
+- // finished card is still a card — only its place on the board changed.
+- done: { group: "done", label: "Done cards", collapsed: true, rows: archived.map(projectRow) },
+- queues: queueGroups,
+- counts: rawCounts === null ? null : { waiting: countOf(rawCounts.waiting), inflight: countOf(rawCounts.inflight), blocked: countOf(rawCounts.blocked), done: countOf(rawCounts.done) },
+- count: list.length,
+- empty: list.length === 0 && queueGroups.length === 0 && archived.length === 0,
+- emptyText: "No cards yet.",
+- record: projectBoardRecord({ decisions, meetings }),
+- metrics: metricsView(metrics, { tasks })
+- };
+-}
+-function projectRowNode(row, onOpen) {
+- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+- return (0, import_react47.createElement)(
+- "div",
++ const hasStrip = canBrowse && tabState.ids.length > 0;
++ return (0, import_react45.createElement)(
++ "aside",
+ {
+- key: row.key,
+- className: "bt-project-row",
+- "data-task-id": row.id,
+- "data-column": row.column,
+- "data-priority": row.priority ?? void 0,
+- role: open === void 0 ? void 0 : "button",
+- tabIndex: open === void 0 ? void 0 : 0,
+- onClick: open,
+- onKeyDown: open === void 0 ? void 0 : (event) => {
+- if (event.key === "Enter" || event.key === " ") open();
++ ref: root,
++ className: "bt-panel",
++ "data-view": view,
++ "aria-label": view === "browser" && bot ? t("{name}'s browser", { name: bot.name }) : t("Conversation details"),
++ "data-leaving": leaving || void 0,
++ "aria-hidden": leaving || void 0,
++ "data-settling": settling || void 0,
++ "data-instant": instant || void 0,
++ "data-dragging": dragging || void 0,
++ style: { "--bt-panel-w": `${panelWidth}px` },
++ onAnimationEnd: (event) => {
++ if (event.target === event.currentTarget) setLanded(true);
+ }
+ },
+- (0, import_react47.createElement)("span", { className: "bt-project-column" }, t(row.column)),
+- (0, import_react47.createElement)(
+- "span",
+- { className: "bt-project-title-wrap" },
+- (0, import_react47.createElement)("span", { className: "bt-project-title" }, row.title ?? (row.assignee ? `@${row.assignee}` : "—")),
+- // The audit text the row already carries (#1730): shown inside the card detail, never a
+- // second read. A row without one renders exactly as before.
+- row.audit === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-audit" }, row.audit)
+- ),
+- // "Who is doing" is the claim, never the assignee label; a card the Assistant speaks for
+- // says so, so the three identities (Bot, Assistant, user) never read as one.
+- row.holder === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-holder", "data-holder": row.holder }, `@${row.holder}`),
+- row.speaksFor === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": row.speaksFor }, t("for {user}", { user: row.speaksFor })),
+- row.priority === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-priority" }, row.priority),
+- row.runId === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-ref" }, row.nodeId === null ? row.runId : `${row.runId} · ${row.nodeId}`)
++ leaving ? null : (0, import_react45.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }),
++ // Over the whole window while dragging: the cursor stays a resize cursor and the
++ // pages underneath (the chat, the guest) see no pointer.
++ dragging ? (0, import_react_dom10.createPortal)((0, import_react45.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null,
++ hasStrip ? (0, import_react45.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null,
++ view === "browser" && bot ? (0, import_react45.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react45.createElement)(
++ "div",
++ { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" },
++ (0, import_react45.createElement)(DetailsDrawer, {
++ sessionId: pane.id,
++ startRenaming: pane.renaming,
++ startTab: pane.tab,
++ hasStrip,
++ roster,
++ actions: panelActions,
++ useSessions,
++ useSessionStatus,
++ useActivity,
++ useWork,
++ useBrowserTabs,
++ useBrowserPages
++ })
++ )
+ );
+ }
+-function ProjectBoardRows({ view, onOpen }) {
+- if (view.empty) return (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t(view.emptyText));
+- return view.groups.map((group) => (0, import_react47.createElement)(
+- "section",
+- { key: group.source, className: "bt-project-group", "data-source": group.source },
+- (0, import_react47.createElement)("h4", null, t(group.label)),
+- group.rows.map((row) => projectRowNode(row, onOpen))
+- ));
++var BROWSER_CSS = `
++.bt-panel{--bt-panel-view-fade:${SPRING_SHAPE.duration}ms}
++.bt-ptabs{position:relative;display:flex;align-items:center;height:44px;flex:none;border-bottom:.5px solid var(--bt-line);padding:0 6px;gap:0;animation:bt-strip-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}}
++@keyframes bt-strip-in{from{height:0;opacity:0}}
++.bt-ptabs-in{position:relative;display:flex;align-items:center;gap:2px;flex:1;min-width:0;height:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;padding:4px 2px;scroll-padding-left:48px;mask-image:linear-gradient(to right,#000,#000 calc(100% - 10px),transparent)}
++.bt-ptabs-in>.bt-ptab-bot{position:sticky;left:0;z-index:2;background:var(--bt-main)}
++.bt-ptabs-in>.bt-ptab-bot[aria-selected=true]{background:transparent}
++.bt-ptabs-in::-webkit-scrollbar{display:none}
++.bt-ptab{position:relative;z-index:1;display:inline-flex;align-items:center;gap:6px;height:32px;max-width:180px;padding:0 8px;flex:none;border:0;border-radius:9px;background:none;color:var(--bt-ink-2);font:inherit;font-size:12.5px;cursor:pointer;white-space:nowrap;transition:color .12s ease}
++.bt-ptab:hover{color:var(--bt-ink)}
++.bt-ptab[aria-selected=true]{color:var(--bt-ink)}
++.bt-ptab-bot{padding:0 7px}
++.bt-ptab-bot .bt-mark{flex:none}
++.bt-ptab-name{max-width:96px;overflow:hidden;text-overflow:ellipsis;font-weight:500;animation:bt-ptab-label .18s ease}
++@keyframes bt-ptab-label{from{opacity:0;filter:blur(4px);max-width:0}}
++.bt-ptab-fav{width:14px;height:14px;flex:none;border-radius:3px;object-fit:cover;color:var(--bt-ink-3)}
++.bt-ptab-globe{display:inline-flex}
++.bt-ptab-globe svg{width:14px;height:14px}
++.bt-ptab-title{max-width:140px;overflow:hidden;text-overflow:ellipsis}
++.bt-ptab-x{flex:none;width:16px;height:16px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--bt-ink-3);opacity:0;transition:opacity .12s ease,background-color .12s ease}
++.bt-ptab:hover .bt-ptab-x,.bt-ptab[aria-selected=true] .bt-ptab-x{opacity:1}
++.bt-ptab-x:hover{background:var(--bt-active);color:var(--bt-ink)}
++.bt-ptab-new{position:relative;z-index:1;flex:none;width:28px;height:32px;border:0;border-radius:9px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer}
++.bt-ptab-new:hover{color:var(--bt-ink)}
++.bt-ptab-new svg{width:15px;height:15px}
++.bt-ptab-ind{position:absolute;top:4px;bottom:4px;border-radius:9px;background:var(--bt-hover);opacity:0;pointer-events:none;z-index:0;transition:opacity .15s ease}
++.bt-ptab-close{flex:none;margin-left:2px}
++.bt-panel-ghost{position:absolute;top:44px;right:0;bottom:0;overflow:hidden;pointer-events:none;z-index:2}
++.bt-panel-ghost>.bt-panel-view{position:absolute;inset:0}
++.bt-panel-view{flex:1;min-height:0;display:flex;flex-direction:column;animation:bt-panel-view-in .22s cubic-bezier(.16,1,.3,1)}
++@keyframes bt-panel-view-in{from{opacity:0}}
++/* While the panel changes width its views keep their final width on the right edge, so
++ only the panel edge moves and nothing inside reflows frame by frame. */
++.bt-panel[data-settling]{overflow:hidden}
++.bt-panel[data-settling]>.bt-ptabs,.bt-panel[data-settling]>.bt-panel-view{flex-shrink:0;align-self:flex-end;box-sizing:border-box;width:calc(var(--bt-panel-w,320px) - .5px)}
++.bt-panel-browser{position:relative}
++.bt-browser-bar{position:relative;display:flex;align-items:center;gap:2px;height:52px;padding:0 10px 0 12px;flex:none;border-bottom:.5px solid var(--bt-line)}
++.bt-browser-bar .bt-icon-btn:disabled{opacity:.35;cursor:default;background:none}
++.bt-browser-address{flex:1;min-width:0;display:flex;align-items:center;gap:6px;height:32px;margin:0 4px;padding:0 12px;border-radius:999px;background:var(--bt-hover);border:.5px solid transparent;transition:background-color .12s ease,border-color .12s ease,box-shadow .12s ease}
++.bt-browser-address:hover{background:var(--bt-active)}
++.bt-browser-address[data-editing]{background:var(--bt-main);border-color:var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04)}
++.bt-browser-scheme{display:inline-flex;color:var(--bt-ink-3);flex:none}
++.bt-browser-scheme svg{width:14px;height:14px}
++.bt-browser-address input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:13px;line-height:18px;padding:0;text-overflow:ellipsis}
++.bt-browser-address input::placeholder{color:var(--bt-ink-3)}
++.bt-browser-progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;overflow:hidden;pointer-events:none}
++.bt-browser-progress::after{content:"";position:absolute;inset:0;width:40%;background:var(--bt-accent,#4D6BFE);border-radius:2px;animation:bt-browser-load 1.1s cubic-bezier(.4,0,.2,1) infinite}
++@keyframes bt-browser-load{from{transform:translateX(-100%)}to{transform:translateX(250%)}}
++.bt-browser-frame{position:relative;flex:1;min-height:0;background:var(--bt-main)}
++.bt-browser-frame[data-guest]{background:#fff}
++.bt-browser-empty{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:32px;text-align:center;animation:bt-foot-in .3s ease}
++.bt-browser-empty-title{font-size:15px;line-height:22px;font-weight:500;margin-top:6px}
++.bt-browser-empty-text{max-width:300px;font-size:13px;line-height:19px;color:var(--bt-ink-2);overflow-wrap:anywhere}
++.bt-browser-retry{margin-top:6px;height:32px;padding:0 14px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer}
++.bt-browser-retry:hover{background:var(--bt-hover)}
++.bt-bcards{display:flex;flex-direction:column;gap:12px}
++.bt-bcard{position:relative;border:.5px solid var(--bt-line-2);border-radius:12px;overflow:hidden;background:var(--bt-main);animation:bt-bcard-in .24s cubic-bezier(.16,1,.3,1) both;transition:border-color .12s ease,box-shadow .12s ease}
++@keyframes bt-bcard-in{from{opacity:0;filter:blur(4px);transform:translateY(4px)}}
++.bt-bcard[data-active]{border-color:var(--bt-accent,#4D6BFE);box-shadow:0 0 0 1px var(--bt-accent,#4D6BFE)}
++.bt-bcard-shot{position:relative;display:block;width:100%;aspect-ratio:16/10;border:0;background:var(--bt-hover);padding:0;cursor:pointer}
++.bt-bcard-blank{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--bt-ink-3);font-size:12px}
++.bt-bcard-blank svg{width:20px;height:20px}
++.bt-bcard-preview{position:absolute;inset:0;display:flex;flex-direction:column;gap:6px;padding:14px 16px;text-align:left;background:linear-gradient(to bottom,var(--bt-main),var(--bt-hover));overflow:hidden}
++.bt-bcard-ptitle{font-size:13px;line-height:18px;font-weight:600;color:var(--bt-ink);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
++.bt-bcard-excerpt{font-size:12px;line-height:17px;color:var(--bt-ink-2);display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden}
++.bt-bcard-row{display:flex;align-items:center;gap:7px;padding:8px 10px;min-width:0}
++.bt-bcard-fav{width:14px;height:14px;flex:none;border-radius:3px;color:var(--bt-ink-3)}
++.bt-bcard-row svg.bt-bcard-fav{width:14px;height:14px}
++.bt-bcard-title{flex:1;min-width:0;font-size:12.5px;line-height:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink)}
++.bt-bcard-host{flex:none;font-size:11px;line-height:15px;color:var(--bt-ink-3);max-width:38%;overflow:hidden;text-overflow:ellipsis}
++.bt-bcard-x{position:absolute;top:6px;right:6px;width:22px;height:22px;border:0;border-radius:7px;background:color-mix(in srgb,var(--bt-main) 88%,transparent);color:var(--bt-ink-2);display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;opacity:0;transition:opacity .12s ease;backdrop-filter:blur(4px)}
++.bt-bcard:hover .bt-bcard-x{opacity:1}
++.bt-bcard-new{display:flex;align-items:center;justify-content:center;gap:6px;height:40px;border:.5px dashed var(--bt-line-2);border-radius:12px;background:none;color:var(--bt-ink-3);font:inherit;font-size:12.5px;cursor:pointer}
++.bt-bcard-new:hover{color:var(--bt-ink);border-color:var(--bt-ink-3)}
++.bt-bcard-new svg{width:14px;height:14px}
++.bt-browser-empty-mark{display:flex;flex-direction:column;align-items:center;gap:10px;text-align:center}
++@media (prefers-reduced-motion:reduce){.bt-ptabs,.bt-panel-view,.bt-bcard,.bt-ptab-name{animation:none}}
++`;
++
++// src/client/remote-bot-settings.js
++var import_react46 = require("react");
++var REMOTE_BOT_SETTINGS_CSS = `
++.bt-remote-sheet-layer{position:fixed;inset:0;z-index:120;isolation:isolate;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none}
++.bt-remote-sheet-layer[data-leaving]{pointer-events:none}
++.bt-remote-sheet-layer .bt-scrim{position:absolute;inset:0;z-index:0;pointer-events:auto}
++.bt-remote-sheet-layer[data-leaving] .bt-scrim{opacity:0;transition:opacity .16s ease-in}
++.bt-remote-sheet:focus{outline:none}
++.bt-remote-sheet{position:relative;z-index:1;pointer-events:auto;width:min(320px,calc(100vw - 32px));max-height:min(70vh,420px);margin:0 auto 0;border-radius:14px;background:var(--bt-card,#fff);border:.5px solid var(--bt-line-2);box-shadow:0 12px 40px rgba(20,20,20,.16);display:flex;flex-direction:column;overflow:hidden;animation:bt-remote-sheet-in .2s ease-out}
++.bt-remote-sheet-layer[data-leaving] .bt-remote-sheet{animation:bt-remote-sheet-out .16s ease-in forwards}
++.bt-remote-sheet-head{display:flex;align-items:center;gap:10px;padding:12px 12px 8px}
++.bt-remote-sheet-who{display:flex;flex-direction:column;min-width:0;gap:1px;flex:1 1 auto}
++.bt-remote-sheet-name{font-size:16px;line-height:22px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-remote-sheet-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
++.bt-remote-sheet-body{padding:0 12px 14px;overflow:auto}
++@keyframes bt-remote-sheet-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
++@keyframes bt-remote-sheet-out{from{opacity:1;transform:none}to{opacity:0;transform:translateY(8px)}}
++@media (min-width:520px){
++.bt-remote-sheet-layer{align-items:center;padding:24px}
++.bt-remote-sheet{margin:0}
+ }
+-function ProjectDoneGroup({ view, onOpen }) {
+- if (view.done.rows.length === 0) return null;
++`;
++function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) {
++ const close = () => actions.closeSettings();
++ useEscape(close);
++ const dialog = (0, import_react46.useRef)(null);
++ (0, import_react46.useEffect)(() => {
++ const before = document.activeElement;
++ dialog.current?.focus?.({ preventScroll: true });
++ return () => {
++ if (before && typeof before.focus === "function" && before.isConnected) before.focus({ preventScroll: true });
++ };
++ }, []);
++ const onKeyDown = (event) => {
++ if (event.key === "Escape") {
++ event.stopPropagation();
++ close();
++ }
++ };
++ if (!bot?.remote) return null;
++ const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot");
++ return (0, import_react46.createElement)(
++ "div",
++ { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" },
++ (0, import_react46.createElement)("div", { className: "bt-scrim", onMouseDown: close }),
++ (0, import_react46.createElement)(
++ "div",
++ { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown },
++ (0, import_react46.createElement)(
++ "div",
++ { className: "bt-remote-sheet-head" },
++ (0, import_react46.createElement)(BotMark, { bot, size: 40, live: true }),
++ (0, import_react46.createElement)(
++ "div",
++ { className: "bt-remote-sheet-who" },
++ (0, import_react46.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name),
++ (0, import_react46.createElement)("span", { className: "bt-remote-sheet-sub" }, who)
++ ),
++ (0, import_react46.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react46.createElement)(CloseIcon))
++ ),
++ (0, import_react46.createElement)(
++ "div",
++ { className: "bt-remote-sheet-body" },
++ bot.remote.handle ? (0, import_react46.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null,
++ (0, import_react46.createElement)(FlyBotDetails, { bot, roster, actions })
++ )
++ )
++ );
++}
++
++// src/client/overlays.js
++var import_react47 = require("react");
++var import_react_dom11 = require("react-dom");
++var DIALOG_EXIT_MS = 160;
++var MENU_EXIT_MS3 = 120;
++function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity, useWork, useRegistry, useBrowserPages, useBrowserTabs, useUpdate, actions }) {
++ const roster = useRoster((value) => value);
++ const ui2 = useUi((value) => value);
++ useRegistry((value) => value);
++ const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null);
++ const previous = (0, import_react47.useRef)(current);
++ (0, import_react47.useEffect)(() => {
++ if (previous.current === current) return;
++ previous.current = current;
++ if (current) actions.followPanel?.(current);
++ }, [current]);
++ const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null;
++ const [kept, setKept] = (0, import_react47.useState)(open);
++ (0, import_react47.useEffect)(() => {
++ if (open) {
++ setKept(open);
++ return void 0;
++ }
++ const timer = setTimeout(() => setKept(null), PANEL_LINGER_MS);
++ return () => clearTimeout(timer);
++ }, [ui2.details, ui2.view, ui2.renaming, ui2.tab]);
++ const pane = open ?? kept;
++ const [settings, settingsLeaving] = useLinger(ui2.settings, DIALOG_EXIT_MS);
++ const remoteBotId = ui2.remoteBotSettings && roster.byId[ui2.remoteBotSettings]?.remote ? ui2.remoteBotSettings : null;
++ const [remoteBotSettings, remoteLeaving] = useLinger(remoteBotId, DIALOG_EXIT_MS);
++ const [memory, memoryLeaving] = useLinger(ui2.memory && roster.byId[ui2.memory] ? ui2.memory : null, DIALOG_EXIT_MS);
++ const [menu, menuLeaving] = useLinger(ui2.menu, MENU_EXIT_MS3);
++ const [inbox, inboxLeaving] = useLinger(ui2.inbox ?? null, DIALOG_EXIT_MS);
++ const [board, boardLeaving] = useLinger(ui2.board ?? null, DIALOG_EXIT_MS);
+ return (0, import_react47.createElement)(
+- "details",
+- { className: "bt-project-group bt-project-done", "data-group": "done" },
+- (0, import_react47.createElement)("summary", { className: "bt-project-done-summary" }, `${t(view.done.label)} · ${view.done.rows.length}`),
+- view.done.rows.map((row) => projectRowNode(row, onOpen))
++ import_react47.Fragment,
++ null,
++ pane ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null,
++ ui2.newChat ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null,
++ ui2.palette ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(CommandPalette, { key: ui2.palette.token, start: ui2.palette.query, token: ui2.palette.token, roster, actions, useSessions }), document.body) : null,
++ ui2.exchange ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null,
++ ui2.voice ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null,
++ menu ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null,
++ settings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null,
++ remoteBotSettings ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null,
++ memory && roster.byId[memory] ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null,
++ inbox ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null,
++ board ? (0, import_react_dom11.createPortal)((0, import_react47.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null
+ );
+ }
+-function waitedLabel(ms) {
+- if (!Number.isFinite(ms) || ms < 0) return null;
+- const minutes = Math.floor(ms / 6e4);
+- if (minutes < 60) return `${minutes}m`;
+- const hours = Math.floor(minutes / 60);
+- if (hours < 24) return `${hours}h`;
+- return `${Math.floor(hours / 24)}d`;
+-}
+-var METRIC_CARDS = [
+- { key: "prsOpenedToday", label: "PRs opened today", caption: "Natural day in your time zone, by created_at." },
+- { key: "prsMergedToday", label: "PRs merged today", caption: "Natural day in your time zone, by merged_at." },
+- { key: "issuesOpenedToday", label: "Issues opened today", caption: "Natural day in your time zone, by created_at." },
+- { key: "issuesClosedToday", label: "Issues closed today", caption: "Natural day in your time zone, by closed_at." },
+- { key: "prMergeMedianMs", label: "PR merge time", caption: "Median of the last 7 days, opened to merged." },
+- { key: "issueCloseMedianMs", label: "Issue close time", caption: "Median of the last 7 days, opened to closed." },
+- { key: "taskDoneMedianMs", label: "Task completion", caption: "Median of the last 7 days, created to done." }
+-];
+-var METRIC_DASH = "—";
+-var DURATION_KEYS = /* @__PURE__ */ new Set(["prMergeMedianMs", "issueCloseMedianMs", "taskDoneMedianMs"]);
+-var RUNNING_OVERDUE_MS = 2 * 60 * 60 * 1e3;
+-var metricText = (key, value) => {
+- if (!Number.isFinite(value)) return METRIC_DASH;
+- if (DURATION_KEYS.has(key)) return waitedLabel(value) ?? METRIC_DASH;
+- return String(value);
++
++// src/client/read-page.js
++var READ_LIMITS = {
++ chars: 4e5,
++ nodes: 15e4,
++ depth: 400,
++ ms: 1500,
++ items: 3e3,
++ tableRows: 5e3,
++ tableCols: 30,
++ frameDepth: 3
+ };
+-function metricsView(metrics, { tasks } = {}) {
+- const source = metrics !== null && typeof metrics === "object" ? metrics : null;
+- const values = source?.metrics !== null && typeof source?.metrics === "object" ? source.metrics : {};
+- const byId = new Map((Array.isArray(tasks) ? tasks : []).filter((task) => task !== null && typeof task === "object" && text(task.id) !== "").map((task) => [text(task.id), task]));
+- const running = (Array.isArray(source?.running) ? source.running : []).filter((row) => row !== null && typeof row === "object" && text(row.id) !== "").map((row) => {
+- const id = text(row.id);
+- const task = byId.get(id);
+- return {
+- id,
+- key: "task:" + id,
+- title: text(row.title) || text(task?.title) || text(task?.note) || id,
+- status: text(row.status),
+- who: text(row.who) || null,
+- sinceMs: Number.isFinite(row.sinceMs) ? row.sinceMs : null,
+- source: text(row.source) || null,
+- lastActivityAt: Number.isFinite(row.lastActivityAt) ? row.lastActivityAt : null,
+- overdue: Number.isFinite(row.sinceMs) && row.sinceMs > RUNNING_OVERDUE_MS,
+- board: boardOfRow({ source: text(row.source) }, task)
+- };
+- });
+- return {
+- available: source?.ok === true,
+- reason: text(source?.reason) || null,
+- cards: METRIC_CARDS.map((card) => ({ ...card, value: metricText(card.key, values[card.key]) })),
+- running,
+- empty: source === null,
+- emptyText: "No metrics read yet."
++function readPage(limits) {
++ const started = performance.now();
++ const SKIP = /* @__PURE__ */ new Set(["script", "style", "noscript", "template", "head", "title", "meta", "link", "svg", "object", "embed", "audio", "video", "source", "track", "map", "datalist", "option", "optgroup"]);
++ const ITEM = 'a[href], button, input:not([type=hidden]), textarea, select, summary, [role=button], [role=link], [role=tab], [role=menuitem], [role=checkbox], [role=switch], [role=radio], [contenteditable=""], [contenteditable=true]';
++ const ROLES = { a: "link", button: "button", select: "select", textarea: "textbox", summary: "button" };
++ const INPUT_ROLES = { checkbox: "checkbox", radio: "radio", submit: "button", button: "button", reset: "button", image: "button", search: "searchbox", range: "slider", file: "button", color: "button" };
++ const NO_VALUE_TYPES = /* @__PURE__ */ new Set(["checkbox", "radio", "submit", "button", "reset", "image", "file", "color"]);
++ const SECRET_HINT = /(^|[-_\s])(cvv|cvc|csc|cc-?(num|number|csc|exp)|card-?(num|number)|otp|one-?time-?code|pin)([-_\s]|$)/i;
++ const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|flow-root|table-caption|table-row-group|table-header-group|table-footer-group|table-row|-webkit-box)$/;
++ const INLINE_BLOCK = /^(inline-block|inline-flex|inline-grid|inline-table|table-cell|-webkit-inline-box)$/;
++ const SEMANTIC = /^(ul|ol|table|pre|blockquote|h[1-6]|code|details)$/;
++ const items = [];
++ const stash = [];
++ const stats = { nodes: 0, frames: 0 };
++ let chars = 0;
++ let stop = "";
++ const collapse = (value) => String(value || "").replace(/\s+/g, " ").trim();
++ const isSecret = (el) => {
++ if (el.localName !== "input" && el.localName !== "textarea") return false;
++ const auto = String(el.getAttribute("autocomplete") || "").toLowerCase();
++ return el.type === "password" || /(^|\s)(cc-|one-time-code)/.test(auto) || SECRET_HINT.test(`${el.getAttribute("name") || ""} ${el.id || ""}`);
++ };
++ const keep = (text3, end = "") => {
++ if (text3 === "") return "";
++ stash.push(text3);
++ return `
++
++${stash.length - 1}${end}
++
++`;
++ };
++ const expand = (text3) => text3.replace(/\u0001(\d+)[\u0002\u0003]/g, (_, index) => expand(stash[Number(index)]));
++ const flow = (text3) => text3.split("\n").map((line2) => line2.replace(/[ \t\f\v\u00a0\u2028\u2029]+/g, " ").trim().replace(/^· /, "")).join("\n").replace(/\n{3,}/g, "\n\n").replace(/\u0003\n\n(?=\u0001\d+\u0003)/g, "\n").trim();
++ const block = (text3) => expand(flow(text3));
++ const oneLine = (text3) => block(text3).replace(/\s*\n+\s*/g, " ").trim();
++ const styleOf = (el) => (el.ownerDocument.defaultView || window).getComputedStyle(el);
++ const childrenOf = (el) => {
++ if (el.shadowRoot) return el.shadowRoot.childNodes;
++ if (el.localName === "slot" && typeof el.assignedNodes === "function") {
++ const assigned = el.assignedNodes();
++ if (assigned.length > 0) return assigned;
++ }
++ return el.childNodes;
++ };
++ const over = () => {
++ if (stop) return true;
++ if (chars > limits.chars) stop = `the text passed ${limits.chars} characters`;
++ else if (stats.nodes > limits.nodes) stop = `the page has more than ${limits.nodes} nodes`;
++ else if (stats.nodes % 512 === 0 && performance.now() - started > limits.ms) stop = `reading took longer than ${limits.ms} ms`;
++ return stop !== "";
++ };
++ const roleOf = (el) => el.getAttribute("role") || ROLES[el.localName] || (el.localName === "input" ? INPUT_ROLES[el.type] || "textbox" : el.isContentEditable ? "textbox" : el.localName);
++ const sized = (el) => {
++ const box = el.getBoundingClientRect();
++ return box.width > 0 && box.height > 0;
++ };
++ const itemFor = (el, style) => {
++ if (items.length >= limits.items || style.visibility !== "visible" || !el.matches(ITEM) || !sized(el)) return null;
++ const item = { role: roleOf(el) };
++ const secret = isSecret(el);
++ if (el.href) item.href = String(el.href);
++ if ((el.localName === "input" || el.localName === "textarea") && !secret && !NO_VALUE_TYPES.has(el.type) && el.value) item.value = String(el.value).slice(0, 120);
++ if (el.localName === "select" && el.selectedOptions?.length) item.value = collapse([...el.selectedOptions].map((option) => option.label || option.text).join(", ")).slice(0, 120);
++ if (el.checked) item.checked = true;
++ if (el.disabled) item.disabled = true;
++ items.push(item);
++ return { item, number: items.length, secret };
++ };
++ const nameOf = (el, text3, secret) => collapse(
++ el.getAttribute("aria-label") || text3 || (el.localName === "input" && !secret && NO_VALUE_TYPES.has(el.type) ? el.value : "") || el.getAttribute("placeholder") || el.title || el.getAttribute("alt") || el.querySelector?.("img[alt]")?.alt || el.getAttribute("name") || ""
++ ).slice(0, 80);
++ const fenced = (code, lang) => {
++ const longest = Math.max(2, ...(code.match(/`{3,}/g) || []).map((run) => run.length));
++ const fence = "`".repeat(longest + 1);
++ return `${fence}${lang}
++${code.replace(/\n+$/, "")}
++${fence}`;
++ };
++ const langOf = (el) => {
++ const names = `${el.className || ""} ${el.querySelector?.("code")?.className || ""}`;
++ return /(?:^|\s)(?:lang|language)-([\w+#.-]+)/.exec(names)?.[1] || "";
++ };
++ const list = (el, ctx) => {
++ const ordered = el.localName === "ol";
++ let number = ordered ? Number(el.getAttribute("start") || 1) || 1 : 1;
++ const lines = [];
++ for (const child of childrenOf(el)) {
++ if (over()) break;
++ if (child.nodeType !== 1 || child.localName !== "li") {
++ const loose = block(render(child, ctx));
++ if (loose) lines.push(loose);
++ continue;
++ }
++ const style = styleOf(child);
++ if (style.display === "none") continue;
++ let body2 = block(children(child, { ...ctx, depth: ctx.depth + 1, shown: style.visibility === "visible" }));
++ if (!body2.includes("```")) body2 = body2.replace(/\n{2,}/g, "\n");
++ const marker = ordered ? `${number++}. ` : "- ";
++ if (body2 === "") continue;
++ const pad2 = " ".repeat(marker.length);
++ lines.push(body2.split("\n").map((line2, index) => (index === 0 ? marker : line2 ? pad2 : "") + line2).join("\n"));
++ }
++ return keep(lines.join("\n"));
++ };
++ const cellText = (cell, ctx) => block(children(cell, ctx)).replace(/\n+/g, "
").replace(/\|/g, "\\|");
++ const layoutTable = (table, rows) => {
++ const role = table.getAttribute("role");
++ if (role === "presentation" || role === "none") return true;
++ if (table.querySelector("table")) return true;
++ return !rows.some((row) => row.cells.length > 1);
++ };
++ const tableMarkdown = (table, ctx) => {
++ const rows = [...table.rows].filter((row) => styleOf(row).display !== "none");
++ const caption = table.caption ? oneLine(children(table.caption, ctx)) : "";
++ if (layoutTable(table, rows)) {
++ return rows.map((row) => [...row.cells].map((cell) => `
++
++${children(cell, ctx)}
++
++`).join("")).join("");
++ }
++ const grid = [];
++ const carry = [];
++ let width = 0;
++ for (const row of rows.slice(0, limits.tableRows + 1)) {
++ if (over()) break;
++ const line2 = [];
++ let column = 0;
++ const place = () => {
++ while (carry[column]?.left > 0) {
++ line2[column] = carry[column].text;
++ carry[column].left -= 1;
++ column += 1;
++ }
++ };
++ for (const cell of row.cells) {
++ place();
++ if (styleOf(cell).display === "none") continue;
++ const text3 = cellText(cell, ctx);
++ const span = Math.max(1, Math.min(limits.tableCols, Number(cell.colSpan) || 1));
++ const down = Math.max(1, Math.min(limits.tableRows, Number(cell.rowSpan) || 1));
++ for (let offset = 0; offset < span; offset += 1) {
++ line2[column] = offset === 0 ? text3 : "";
++ if (down > 1) carry[column] = { text: offset === 0 ? text3 : "", left: down - 1 };
++ column += 1;
++ }
++ }
++ place();
++ width = Math.max(width, line2.length);
++ grid.push(line2);
++ }
++ width = Math.min(width, limits.tableCols);
++ if (grid.length === 0 || width === 0) return caption ? `
++
++${caption}
++
++` : "";
++ const format = (cells) => `| ${Array.from({ length: width }, (_, index) => cells[index] ?? "").join(" | ")} |`;
++ const out = [format(grid[0]), `|${" --- |".repeat(width)}`, ...grid.slice(1).map(format)];
++ if (rows.length > limits.tableRows + 1) out.push(`… (table cut: ${rows.length - limits.tableRows - 1} more rows)`);
++ return `${caption ? `
++
++Table: ${caption}` : ""}${keep(out.join("\n"))}`;
++ };
++ const frame = (el, ctx) => {
++ let doc = null;
++ try {
++ doc = el.contentDocument;
++ } catch {
++ doc = null;
++ }
++ const address = (() => {
++ try {
++ return doc?.location?.href || el.src || el.getAttribute("src") || "about:blank";
++ } catch {
++ return el.src || "about:blank";
++ }
++ })();
++ if (!doc) return `
++
++[iframe (cross-origin, not readable): ${address}]
++
++`;
++ if (ctx.frames >= limits.frameDepth) return `
++
++[iframe (nested too deep, not read): ${address}]
++
++`;
++ stats.frames += 1;
++ const title = collapse(el.title || doc.title);
++ const body2 = doc.body ? block(children(doc.body, { ...ctx, frames: ctx.frames + 1, depth: ctx.depth + 1, shown: true, pre: false })) : "";
++ return keep(`[iframe: ${title ? `${title} (${address})` : address}]
++${body2 || "(no text)"}
++[iframe end]`);
+ };
++ function children(el, ctx) {
++ let out = "";
++ for (const child of childrenOf(el)) {
++ if (over()) break;
++ out += render(child, ctx);
++ }
++ return out;
++ }
++ function render(node, ctx) {
++ stats.nodes += 1;
++ if (node.nodeType === 3) {
++ if (!ctx.shown) return "";
++ const data = node.data.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "");
++ const text3 = ctx.pre ? data : data.replace(/[\t\n\r ]+/g, " ");
++ chars += text3.length;
++ return text3;
++ }
++ if (node.nodeType !== 1) return "";
++ const el = node;
++ const tag = el.localName;
++ if (SKIP.has(tag) || ctx.depth > limits.depth || over()) return "";
++ const style = styleOf(el);
++ const display = style.display;
++ if (display === "none" || style.contentVisibility === "hidden") return "";
++ const shown = style.visibility === "visible";
++ const keepsSpace = String(style.whiteSpace || "").startsWith("pre") || style.whiteSpace === "break-spaces";
++ const next = { ...ctx, depth: ctx.depth + 1, shown };
++ if (display === "contents" && !SEMANTIC.test(tag) && !el.matches(ITEM)) return children(el, next);
++ if (style.overflowX !== "visible" || style.overflowY !== "visible") {
++ const box = el.getBoundingClientRect();
++ if (box.width === 0 || box.height === 0) return "";
++ }
++ if (tag === "br") return "\n";
++ if (tag === "hr") return "\n\n---\n\n";
++ if (tag === "img") {
++ const alt = collapse(el.getAttribute("alt"));
++ return shown && alt ? `[image: ${alt}]` : "";
++ }
++ if (tag === "canvas") {
++ const box = el.getBoundingClientRect();
++ return box.width >= 200 && box.height >= 100 ? "\n\n[canvas: drawn content, not readable]\n\n" : "";
++ }
++ if (tag === "iframe" || tag === "frame") {
++ const box = el.getBoundingClientRect();
++ return tag === "iframe" && (box.width < 2 || box.height < 2) ? "" : frame(el, next);
++ }
++ const found = itemFor(el, style);
++ const mark = found ? `[#${found.number}]` : "";
++ if (tag === "input" || tag === "textarea" || tag === "select") {
++ if (found) found.item.name = nameOf(el, tag === "select" ? "" : el.labels?.[0] ? collapse(el.labels[0].textContent) : "", found.secret);
++ return mark ? ` ${mark} ` : "";
++ }
++ let body2;
++ if (tag === "pre") {
++ const code = shown ? String(el.innerText || el.textContent || "") : "";
++ chars += code.length;
++ body2 = code.trim() ? keep(fenced(code, langOf(el))) : "";
++ } else if (tag === "table") {
++ body2 = tableMarkdown(el, next);
++ } else if (tag === "ul" || tag === "ol") {
++ body2 = list(el, next);
++ } else if (tag === "details" && !el.open) {
++ const summary = [...el.children].find((child) => child.localName === "summary");
++ body2 = summary ? render(summary, next) : "";
++ } else {
++ body2 = children(el, { ...next, pre: keepsSpace });
++ }
++ if (found) found.item.name = nameOf(el, oneLine(body2), found.secret);
++ const level = /^h([1-6])$/.exec(tag)?.[1] || (el.getAttribute("role") === "heading" ? el.getAttribute("aria-level") || "2" : "");
++ if (level) {
++ const title = oneLine(body2);
++ return title ? `
++
++${"#".repeat(Math.min(6, Number(level) || 2))} ${title}${mark}
++
++` : mark;
++ }
++ if (tag === "code" && !ctx.pre) {
++ const code = oneLine(body2);
++ if (!code) return mark;
++ const ticks = code.includes("`") ? "``" : "`";
++ return `${ticks}${code}${ticks}${mark}`;
++ }
++ if (tag === "blockquote") {
++ const quoted = block(body2);
++ return quoted ? keep(quoted.split("\n").map((line2) => line2 ? `> ${line2}` : ">").join("\n")) : "";
++ }
++ const end = body2.trimEnd().length;
++ const marked = mark ? `${body2.slice(0, end)}${mark}${body2.slice(end)}` : body2;
++ if (ctx.pre && !BLOCK_DISPLAY.test(display)) return marked;
++ if (keepsSpace && BLOCK_DISPLAY.test(display) && tag !== "pre") {
++ return keep(expand(marked).replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n\n").replace(/^\n+|\s+$/g, ""), "");
++ }
++ if (BLOCK_DISPLAY.test(display)) return `
++
++${marked}
++
++`;
++ if (INLINE_BLOCK.test(display)) return ` ${marked} `;
++ if (tag === "li") return ` · ${marked}`;
++ return marked;
++ }
++ const body = document.body;
++ let text2 = "";
++ if (body) {
++ text2 = block(render(body, { depth: 0, frames: 0, shown: true, pre: false })).replace(/\u00a0/g, " ");
++ }
++ if (text2.length > limits.chars) {
++ text2 = text2.slice(0, limits.chars);
++ stop ||= `the text passed ${limits.chars} characters`;
++ }
++ const page = {
++ url: location.href,
++ title: document.title,
++ lang: document.documentElement.lang || "",
++ description: document.querySelector('meta[name="description"]')?.content || "",
++ selection: String(getSelection() || "").slice(0, 4e3),
++ format: "markdown",
++ text: text2,
++ items,
++ stats: { ...stats, ms: Math.round(performance.now() - started) }
++ };
++ if (stop) page.truncated = stop;
++ return page;
+ }
+-function ProjectMetrics({ view }) {
+- return (0, import_react47.createElement)(
+- "section",
+- { className: "bt-project-metrics", "data-available": view.available ? "true" : "false" },
+- view.available || view.reason === null ? null : (0, import_react47.createElement)("p", { className: "bt-project-metrics-note" }, t("Metrics unavailable") + ": " + view.reason),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-metric-cards" },
+- view.cards.map((card) => (0, import_react47.createElement)(
+- "div",
+- { key: card.key, className: "bt-project-metric", "data-metric": card.key },
+- (0, import_react47.createElement)("span", { className: "bt-project-metric-value" }, card.value),
+- (0, import_react47.createElement)("span", { className: "bt-project-metric-label" }, t(card.label)),
+- (0, import_react47.createElement)("span", { className: "bt-project-metric-caption" }, t(card.caption))
+- ))
+- )
+- );
+-}
+-function ProjectRunningTable({ view, onOpen }) {
+- if (view.running.length === 0) return (0, import_react47.createElement)("section", { className: "bt-project-running", "data-running": "0" }, (0, import_react47.createElement)("h4", null, t("Running tasks")), (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("Nothing is running.")));
+- return (0, import_react47.createElement)(
+- "section",
+- { className: "bt-project-running", "data-running": String(view.running.length) },
+- (0, import_react47.createElement)("h4", null, t("Running tasks")),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-running-head" },
+- (0, import_react47.createElement)("span", null, t("Task")),
+- (0, import_react47.createElement)("span", null, t("Status")),
+- (0, import_react47.createElement)("span", null, t("Who")),
+- (0, import_react47.createElement)("span", null, t("Claimed for")),
+- (0, import_react47.createElement)("span", null, t("Source")),
+- (0, import_react47.createElement)("span", null, t("Last activity"))
+- ),
+- view.running.map((row) => {
+- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+- return (0, import_react47.createElement)(
+- "div",
+- {
+- key: row.key,
+- className: "bt-project-running-row",
+- "data-task-id": row.id,
+- "data-overdue": row.overdue ? "true" : "false",
+- role: open === void 0 ? void 0 : "button",
+- tabIndex: open === void 0 ? void 0 : 0,
+- onClick: open
+- },
+- (0, import_react47.createElement)("span", { className: "bt-project-running-title" }, row.title),
+- (0, import_react47.createElement)("span", { className: "bt-project-running-status" }, t(row.status)),
+- (0, import_react47.createElement)("span", null, row.who ?? METRIC_DASH),
+- (0, import_react47.createElement)("span", null, metricText("since", row.sinceMs)),
+- (0, import_react47.createElement)("span", null, row.source === null ? METRIC_DASH : t(SOURCE_LABELS[row.source] ?? row.source)),
+- (0, import_react47.createElement)("span", null, row.lastActivityAt === null ? METRIC_DASH : new Date(row.lastActivityAt).toLocaleString(dateLocale()))
+- );
+- })
+- );
+-}
+-function ProjectQueueGroups({ view, onOpen }) {
+- return view.queues.map((entry) => (0, import_react47.createElement)(
+- "section",
+- { key: `queue:${entry.group}`, className: "bt-project-group", "data-queue": entry.group },
+- (0, import_react47.createElement)("h4", null, t(entry.label)),
+- entry.items.map((row) => {
+- const open = row.board === null ? void 0 : () => onOpen?.(row.board);
+- const note = entry.group === "waiting" ? (0, import_react47.createElement)("span", { className: "bt-project-wait" }, [row.role === null ? null : (0, import_react47.createElement)("span", { key: "role", className: "bt-project-role" }, row.role), waitedLabel(row.waitedMs) === null ? null : ` · ${waitedLabel(row.waitedMs)}`]) : entry.group === "inflight" ? (0, import_react47.createElement)("span", { className: "bt-project-state" }, row.holder === null ? t(row.state) : `${row.holder} · ${t(row.state)}`) : (0, import_react47.createElement)("span", { className: "bt-project-reason" }, row.reason);
+- return (0, import_react47.createElement)(
+- "div",
+- {
+- key: row.key,
+- className: "bt-project-row",
+- "data-task-id": row.id,
+- "data-state": row.state ?? void 0,
+- role: open === void 0 ? void 0 : "button",
+- tabIndex: open === void 0 ? void 0 : 0,
+- onClick: open,
+- onKeyDown: open === void 0 ? void 0 : (event) => {
+- if (event.key === "Enter" || event.key === " ") open();
+- }
+- },
+- (0, import_react47.createElement)(
+- "span",
+- { className: "bt-project-title-wrap" },
+- (0, import_react47.createElement)("span", { className: "bt-project-title" }, row.title ?? `#${row.id}`)
+- ),
+- note
+- );
+- })
+- ));
+-}
+-var countText = (value) => value === null ? t("unknown") : String(value);
+-function ProjectBoardRecord({ record }) {
+- const { decisions, meetings } = record;
+- const decisionRows = decisions.files.map((file) => (0, import_react47.createElement)(
+- "div",
+- { key: file.key, className: "bt-project-decision", "data-path": file.path, "data-sha256": file.sha256 },
+- (0, import_react47.createElement)("span", { className: "bt-project-date" }, file.date),
+- (0, import_react47.createElement)("span", { className: "bt-project-title" }, file.title === "" ? file.name : file.title),
+- (0, import_react47.createElement)("span", { className: "bt-project-hash", title: file.sha256 }, file.shortHash)
+- ));
+- const recipientText = (list) => list.length === 0 ? null : list.map((name) => `@${name}`).join(", ");
+- const proxyText = (meeting) => meeting.participants.filter((participant) => participant.speaksFor !== null).map((participant) => `${participant.name} ${t("for {user}", { user: participant.speaksFor })}`).join(", ");
+- const proxyRow = (meeting) => proxyText(meeting) === "" ? null : (0, import_react47.createElement)("span", { className: "bt-project-speaks-for", "data-speaks-for": meeting.participants.filter((p) => p.speaksFor !== null).map((p) => p.speaksFor).join(", ") }, proxyText(meeting));
+- const levelOf = (meeting) => (0, import_react47.createElement)("span", { className: "bt-project-level", "data-level": meeting.level }, t(meeting.level));
+- const decidedRows = meetings.decided.map((meeting) => (0, import_react47.createElement)(
+- "div",
+- { key: meeting.key, className: "bt-project-meeting", "data-status": "decided" },
+- (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date),
+- (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.question),
+- meeting.owner === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-owner" }, `@${meeting.owner}`),
+- proxyRow(meeting),
+- // A decided meeting whose file is gone still reads as decided; the missing path says so
+- // instead of the row disappearing with the file.
+- meeting.path === "" ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, "decision file gone") : (0, import_react47.createElement)("span", { className: "bt-project-path" }, meeting.path)
+- ));
+- const pendingRows = meetings.pending.map((meeting) => (0, import_react47.createElement)(
+- "div",
+- { key: meeting.key, className: "bt-project-meeting", "data-status": meeting.status, "data-level": meeting.level },
+- (0, import_react47.createElement)("span", { className: "bt-project-date" }, meeting.date),
+- (0, import_react47.createElement)("span", { className: "bt-project-title" }, meeting.blockingQuestion === null ? meeting.question : meeting.blockingQuestion),
+- levelOf(meeting),
+- recipientText(meeting.recipients) === null ? null : (0, import_react47.createElement)("span", { className: "bt-project-recipients" }, recipientText(meeting.recipients)),
+- meeting.advance.allowed ? null : (0, import_react47.createElement)("span", { className: "bt-project-paused" }, meeting.advance.reason),
+- proxyRow(meeting),
+- (0, import_react47.createElement)("span", { className: "bt-project-status" }, meeting.status)
+- ));
+- return (0, import_react47.createElement)(
+- "section",
+- { className: "bt-project-record", "data-decisions": String(decisions.count), "data-meetings": String(meetings.count) },
+- (0, import_react47.createElement)("h4", null, t("Decisions")),
+- decisions.empty ? (0, import_react47.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(decisions.emptyText)) : decisionRows,
+- (0, import_react47.createElement)("h4", null, t("Meetings")),
+- meetings.empty ? (0, import_react47.createElement)("div", { className: "bt-project-empty bt-project-record-empty" }, t(meetings.emptyText)) : decidedRows,
+- meetings.pending.length === 0 ? null : (0, import_react47.createElement)("h5", null, t("Not decided")),
+- meetings.pending.length === 0 ? null : pendingRows
+- );
++var readPageScript = (limits = READ_LIMITS) => `(${readPage})(${JSON.stringify(limits)})`;
++var READ_PAGE_SCRIPT = readPageScript();
++
++// src/client/bot-browsers.js
++var STORE_KEY = STORAGE.browsers;
++var MAX_LIVE = 6;
++var READ_TIMEOUT_MS = 1e4;
++var PREVIEW_TIMEOUT_MS = 3e3;
++var RESIZE_LATER_MS = 200;
++var SEARCH_URL = "https://www.bing.com/search?q=";
++var PREVIEW_SCRIPT = `(() => {
++ const meta = key => document.querySelector('meta[name="' + key + '"],meta[property="' + key + '"]')?.content ?? ''
++ const paragraph = [...document.querySelectorAll('main p, article p, p')].map(node => node.innerText ?? '').find(text => text.trim().length > 40) ?? ''
++ const text = meta('description') || meta('og:description') || paragraph || document.body?.innerText || ''
++ return text.replace(/\\s+/g, ' ').trim().slice(0, 200)
++})()`;
++var EMPTY = { url: "", title: "", favicon: null, loading: false, canGoBack: false, canGoForward: false, error: null, live: false, excerpt: "" };
++var METADATA_HOST = /^(169\.254\.\d+\.\d+|\[fd00:ec2::254\]|metadata\.google\.internal)$/i;
++var BLOCKED = "Cloud metadata addresses do not open here.";
++var blockedAddress = (url) => URL.canParse(url) && METADATA_HOST.test(new URL(url).hostname);
++function browserAddress(input) {
++ const text2 = String(input ?? "").trim();
++ if (text2 === "") return null;
++ if (/^[a-z][a-z\d+.-]*:\/\//i.test(text2)) return /^https?:\/\//i.test(text2) && URL.canParse(text2) ? new URL(text2).href : null;
++ if (/^(about|javascript|data|file|blob|chrome|devtools|view-source):/i.test(text2)) return null;
++ const host = text2.split(/[/?#]/)[0];
++ const local = /^(localhost|127(\.\d+){3}|10(\.\d+){3}|192\.168(\.\d+){2}|172\.(1[6-9]|2\d|3[01])(\.\d+){2}|\[[\da-f:]+\])(:\d+)?$/i.test(host);
++ const looksLikeHost = !/\s/.test(text2) && (local || /^[^\s.]+(\.[^\s.]+)+(:\d+)?$/.test(host));
++ if (looksLikeHost && URL.canParse(`http://${text2}`)) return new URL(`${local ? "http" : "https"}://${text2}`).href;
++ return `${SEARCH_URL}${encodeURIComponent(text2)}`;
+ }
+-var botIdOf = (entry) => entry !== null && typeof entry === "object" ? text(entry.botId) : "";
+-var idList = (value) => Array.isArray(value) ? value.map((id) => text(id)).filter((id) => id !== "") : [];
+-function projectTeamView(project) {
+- const stored = project?.roles !== null && typeof project?.roles === "object" ? project.roles : {};
+- const owner = [];
+- const addOwner = (id) => {
+- const clean = text(id);
+- if (clean !== "" && !owner.includes(clean)) owner.push(clean);
++function createBotBrowsers({ bridge, createSource: createSource2, storage = globalThis.localStorage, document: doc = globalThis.document }) {
++ const views = /* @__PURE__ */ new Map();
++ const owner = /* @__PURE__ */ new Map();
++ const tabs = createSource2({});
++ const pages = createSource2({});
++ let saved = {};
++ try {
++ saved = JSON.parse(storage?.getItem(STORE_KEY) ?? "{}") ?? {};
++ } catch {
++ saved = {};
++ }
++ let layer;
++ let shown = null;
++ let rect = null;
++ let visible = false;
++ let resizeLater = 0;
++ let disposed = false;
++ let serial = 0;
++ const savedTab = (botId, tabId) => saved[botId]?.tabs?.find((entry) => entry.id === tabId);
++ const publish2 = (tabId, patch) => pages.set((state) => {
++ const botId = owner.get(tabId);
++ return { ...state, [tabId]: { ...EMPTY, ...savedTab(botId, tabId), ...state[tabId], ...patch } };
++ });
++ const writeSaved = () => {
++ try {
++ storage?.setItem(STORE_KEY, JSON.stringify(saved));
++ } catch {
++ }
+ };
+- addOwner(project?.ownerUserId);
+- for (const id of idList(project?.ownerUserIds)) addOwner(id);
+- const maintainers = idList(stored.maintainers).filter((id) => !owner.includes(id));
+- const members = idList(stored.members).filter((id) => !owner.includes(id) && !maintainers.includes(id));
++ const persist = (botId) => {
++ const state = tabs.getSnapshot()[botId];
++ if (state === void 0) {
++ if (saved[botId] === void 0) return;
++ const { [botId]: _, ...rest } = saved;
++ saved = rest;
++ } else {
++ const snapshot = pages.getSnapshot();
++ saved = {
++ ...saved,
++ [botId]: {
++ tabs: state.ids.map((id) => ({ id, url: snapshot[id]?.url ?? "", title: snapshot[id]?.title ?? "" })),
++ active: state.active
++ }
++ };
++ }
++ writeSaved();
++ };
++ const ensureTabs = (botId) => {
++ if (tabs.getSnapshot()[botId] !== void 0) return;
++ const entries2 = saved[botId]?.tabs ?? [];
++ const ids = entries2.map((entry) => entry.id);
++ const active = ids.includes(saved[botId]?.active) ? saved[botId].active : ids.at(-1) ?? null;
++ for (const entry of entries2) owner.set(entry.id, botId);
++ tabs.set((state) => ({ ...state, [botId]: { ids, active } }));
++ for (const entry of entries2) publish2(entry.id, { url: entry.url ?? "", title: entry.title ?? "", live: false });
++ };
++ const ensureLayer = () => {
++ if (layer?.isConnected) return layer;
++ layer = doc.createElement("div");
++ layer.className = "bt-browser-layer";
++ Object.assign(layer.style, { position: "fixed", inset: "0", zIndex: "41", pointerEvents: "none" });
++ doc.body.append(layer);
++ return layer;
++ };
++ const place = (entry) => {
++ const box = rect ?? { left: 0, top: 0, width: 800, height: 600 };
++ Object.assign(entry.element.style, {
++ left: `${box.left}px`,
++ top: `${box.top}px`,
++ width: `${box.width}px`,
++ height: `${box.height}px`,
++ visibility: shown === entry.tabId && visible && rect ? "visible" : "hidden"
++ });
++ };
++ async function preview(tabId) {
++ const entry = views.get(tabId);
++ if (entry === void 0 || !entry.ready || !/^https?:/i.test(entry.element.getURL())) return false;
++ let timer;
++ const timeout = new Promise((resolve) => {
++ timer = setTimeout(() => resolve(""), PREVIEW_TIMEOUT_MS);
++ });
++ try {
++ const excerpt = await Promise.race([entry.element.executeJavaScript(PREVIEW_SCRIPT), timeout]);
++ if (typeof excerpt !== "string" || excerpt === "" || views.get(tabId) !== entry) return false;
++ publish2(tabId, { excerpt });
++ return true;
++ } catch {
++ return false;
++ } finally {
++ clearTimeout(timer);
++ }
++ }
++ const observe = (entry) => {
++ const { element } = entry;
++ if (!entry.ready) return;
++ const url = element.getURL();
++ if (url.startsWith("about:blank#")) return;
++ if (entry.fresh) {
++ element.clearHistory();
++ entry.fresh = false;
++ }
++ const title = element.getTitle();
++ publish2(entry.tabId, { url, title, loading: element.isLoading(), canGoBack: element.canGoBack(), canGoForward: element.canGoForward(), live: true });
++ persist(entry.botId);
++ };
++ async function create(tabId) {
++ const botId = owner.get(tabId);
++ const reservation = await bridge.acquire(`bot:${botId}`);
++ if (disposed) {
++ await bridge.release(reservation.lease).catch(() => {
++ });
++ return void 0;
++ }
++ const element = doc.createElement("webview");
++ element.dataset.sidebarBrowserFrame = "webview";
++ Object.assign(element.style, { position: "fixed", display: "flex", border: "0", pointerEvents: "auto", background: "#fff" });
++ element.dataset.btBrowser = tabId;
++ element.setAttribute("name", reservation.lease);
++ element.setAttribute("partition", reservation.partition);
++ element.setAttribute("allowpopups", "");
++ element.setAttribute("src", `about:blank#${reservation.lease}`);
++ const entry = { botId, tabId, element, lease: reservation.lease, ready: false, fresh: true, pending: pages.getSnapshot()[tabId]?.url || savedTab(botId, tabId)?.url, usedAt: Date.now() };
++ const lifetime = new AbortController();
++ entry.stop = () => lifetime.abort();
++ const { signal } = lifetime;
++ const unsubscribe = bridge.onOpenRequested(reservation.lease, (url) => void openTab(botId, url));
++ signal.addEventListener("abort", unsubscribe, { once: true });
++ element.addEventListener("dom-ready", () => {
++ entry.ready = true;
++ observe(entry);
++ if (entry.pending) {
++ const url = entry.pending;
++ entry.pending = void 0;
++ void element.loadURL(url).catch(() => {
++ });
++ }
++ }, { signal });
++ for (const name of ["did-navigate", "did-navigate-in-page", "did-start-loading", "did-stop-loading", "page-title-updated"]) {
++ element.addEventListener(name, () => observe(entry), { signal });
++ }
++ element.addEventListener("did-stop-loading", () => {
++ void preview(tabId);
++ }, { signal });
++ element.addEventListener("page-favicon-updated", (event) => {
++ const favicon = event.favicons?.[0];
++ if (typeof favicon === "string" && favicon.startsWith("data:")) publish2(tabId, { favicon });
++ }, { signal });
++ element.addEventListener("did-start-navigation", (event) => {
++ if (!event.isMainFrame) return;
++ if (blockedAddress(event.url)) {
++ element.stop();
++ publish2(tabId, { loading: false, error: BLOCKED });
++ return;
++ }
++ publish2(tabId, { loading: true, error: null });
++ }, { signal });
++ element.addEventListener("did-fail-load", (event) => {
++ if (event.isMainFrame && event.errorCode !== -3) publish2(tabId, { loading: false, error: event.errorDescription || `Error ${event.errorCode}` });
++ }, { signal });
++ element.addEventListener("render-process-gone", () => {
++ if (views.get(tabId) === void 0) return;
++ void release(tabId);
++ publish2(tabId, { loading: false, error: "The page crashed." });
++ }, { signal });
++ views.set(tabId, entry);
++ ensureLayer().append(element);
++ place(entry);
++ publish2(tabId, { live: true, loading: Boolean(entry.pending) });
++ return entry;
++ }
++ const opening = /* @__PURE__ */ new Map();
++ function open(tabId) {
++ const entry = views.get(tabId);
++ if (entry) return Promise.resolve(entry);
++ let job = opening.get(tabId);
++ if (job === void 0) {
++ job = create(tabId).catch((error) => {
++ console.warn("[ds-bot] browser acquire", error);
++ publish2(tabId, { loading: false, error: String(error?.message ?? error) });
++ return void 0;
++ }).finally(() => opening.delete(tabId));
++ opening.set(tabId, job);
++ void job.then(() => trim());
++ }
++ return job;
++ }
++ function trim() {
++ const idle = [...views.values()].filter((entry) => entry.tabId !== shown).sort((a, b) => a.usedAt - b.usedAt);
++ while (views.size > MAX_LIVE && idle.length > 0) void release(idle.shift().tabId);
++ }
++ async function release(tabId) {
++ const entry = views.get(tabId);
++ if (entry === void 0) return;
++ views.delete(tabId);
++ entry.stop();
++ entry.element.remove();
++ publish2(tabId, { live: false, loading: false });
++ await bridge.release(entry.lease).catch((error) => console.warn("[ds-bot] browser release", error));
++ }
++ function openTab(botId, url) {
++ ensureTabs(botId);
++ const tabId = `t${Date.now().toString(36)}-${++serial}`;
++ owner.set(tabId, botId);
++ tabs.set((state) => {
++ const own = state[botId] ?? { ids: [], active: null };
++ return { ...state, [botId]: { ids: [...own.ids, tabId], active: tabId } };
++ });
++ publish2(tabId, { url: "", title: "", live: false });
++ if (typeof url === "string" && url !== "") void navigate(tabId, url);
++ persist(botId);
++ return tabId;
++ }
++ async function closeTab(botId, tabId) {
++ const state = tabs.getSnapshot()[botId];
++ if (state === void 0 || !state.ids.includes(tabId)) return;
++ await opening.get(tabId)?.catch(() => {
++ });
++ if (shown === tabId) await show(null);
++ await release(tabId);
++ tabs.set((snapshot) => {
++ const current = snapshot[botId];
++ if (current === void 0 || !current.ids.includes(tabId)) return snapshot;
++ const at = current.ids.indexOf(tabId);
++ const ids = current.ids.filter((id) => id !== tabId);
++ const active = current.active === tabId ? ids[Math.min(at, ids.length - 1)] ?? null : current.active;
++ const next = { ...snapshot };
++ if (ids.length === 0) delete next[botId];
++ else next[botId] = { ids, active };
++ return next;
++ });
++ owner.delete(tabId);
++ pages.set((snapshot) => {
++ if (snapshot[tabId] === void 0) return snapshot;
++ const { [tabId]: _, ...rest } = snapshot;
++ return rest;
++ });
++ persist(botId);
++ }
++ function activate(botId, tabId) {
++ ensureTabs(botId);
++ const state = tabs.getSnapshot()[botId];
++ if (state === void 0 || !state.ids.includes(tabId) || state.active === tabId) return;
++ tabs.set((snapshot) => ({ ...snapshot, [botId]: { ...state, active: tabId } }));
++ persist(botId);
++ }
++ const activeTab = (botId) => tabs.getSnapshot()[botId]?.active ?? null;
++ async function show(tabId, box = null) {
++ if (tabId !== null && tabId === shown && visible && box !== null) {
++ rect = box;
++ const entry2 = views.get(tabId);
++ if (entry2) place(entry2);
++ clearTimeout(resizeLater);
++ resizeLater = setTimeout(() => {
++ for (const other of views.values()) place(other);
++ }, RESIZE_LATER_MS);
++ return;
++ }
++ shown = tabId;
++ if (box) rect = box;
++ visible = tabId !== null && box !== null;
++ for (const entry2 of views.values()) place(entry2);
++ if (tabId === null) return;
++ const entry = await open(tabId);
++ if (entry === void 0 || shown !== tabId) return;
++ entry.usedAt = Date.now();
++ place(entry);
++ }
++ async function navigate(tabId, input) {
++ const url = browserAddress(input);
++ if (url === null || blockedAddress(url)) {
++ publish2(tabId, { error: url === null ? "Only http and https addresses open here." : BLOCKED });
++ return false;
++ }
++ const entry = await open(tabId);
++ if (entry === void 0) return false;
++ publish2(tabId, { url, loading: true, error: null });
++ persist(owner.get(tabId));
++ if (!entry.ready) entry.pending = url;
++ else void entry.element.loadURL(url).catch(() => {
++ });
++ return true;
++ }
++ async function forget(botId) {
++ ensureTabs(botId);
++ for (const tabId of tabs.getSnapshot()[botId]?.ids ?? []) {
++ await opening.get(tabId)?.catch(() => {
++ });
++ if (shown === tabId) await show(null);
++ await release(tabId);
++ owner.delete(tabId);
++ pages.set((snapshot) => {
++ if (snapshot[tabId] === void 0) return snapshot;
++ const { [tabId]: _, ...rest } = snapshot;
++ return rest;
++ });
++ }
++ tabs.set((snapshot) => {
++ if (snapshot[botId] === void 0) return snapshot;
++ const { [botId]: _, ...rest } = snapshot;
++ return rest;
++ });
++ if (saved[botId] !== void 0) {
++ const { [botId]: _, ...rest } = saved;
++ saved = rest;
++ writeSaved();
++ }
++ }
+ return {
+- chief: botIdOf(project?.chief),
+- agent: botIdOf(project?.assistant),
+- roles: { owner, maintainers, members },
+- empty: owner.length === 0 && maintainers.length === 0 && members.length === 0,
+- // Both come from the `projects` listing (#1828), which the overlay keeps on the record: the
+- // `project-read` record answers no rank, and the panel never re-derives the host's policy.
+- role: text(project?.role),
+- choices: idList(project?.choices)
+- };
+-}
+-var teamRow = (role, label, botId, onOpenBot) => (0, import_react47.createElement)(
+- "div",
+- {
+- key: role,
+- className: "bt-project-team-row",
+- "data-team-role": role,
+- // A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
+- ...botId === "" ? { "data-team-state": "not-set" } : {
+- "data-open-bot": botId,
+- role: "button",
+- tabIndex: 0,
+- "aria-label": t("Open {name}'s chat", { name: botId }),
+- onClick: () => onOpenBot?.(botId),
+- onKeyDown: (event) => {
+- if (event.key === "Enter" || event.key === " ") {
+- event.preventDefault();
+- onOpenBot?.(botId);
+- }
++ tabs,
++ pages,
++ /** Restore a Bot's saved strip (or an empty one) so the panel can render it. */
++ ensureTabs,
++ openTab,
++ closeTab,
++ activate,
++ preview,
++ show,
++ navigate,
++ command(tabId, name) {
++ const entry = views.get(tabId);
++ if (!entry?.ready) return;
++ if (name === "back" && entry.element.canGoBack()) entry.element.goBack();
++ else if (name === "forward" && entry.element.canGoForward()) entry.element.goForward();
++ else if (name === "reload") entry.element.reload();
++ else if (name === "stop") entry.element.stop();
++ },
++ focus(tabId) {
++ views.get(tabId)?.element.focus();
++ },
++ /** Pages a read can reach: each Bot's active tab, live and showing a document. */
++ openPages() {
++ const open2 = {};
++ const snapshot = pages.getSnapshot();
++ for (const [botId, state] of Object.entries(tabs.getSnapshot())) {
++ const tabId = state.active;
++ const entry = tabId ? views.get(tabId) : void 0;
++ const page = tabId ? snapshot[tabId] : void 0;
++ if (entry?.ready && page?.url) open2[botId] = { url: page.url, title: page.title };
+ }
++ return open2;
++ },
++ /** A Bot's read reaches its active tab. */
++ async read(botId) {
++ const tabId = activeTab(botId);
++ const entry = tabId ? views.get(tabId) : void 0;
++ if (!entry?.ready) throw new Error("This Bot has no browser page open.");
++ let timer;
++ const timeout = new Promise((_, reject) => {
++ timer = setTimeout(() => reject(new Error("The page took too long to read.")), READ_TIMEOUT_MS);
++ });
++ try {
++ return await Promise.race([entry.element.executeJavaScript(READ_PAGE_SCRIPT), timeout]);
++ } finally {
++ clearTimeout(timer);
++ }
++ },
++ noteRead(botId) {
++ const tabId = activeTab(botId);
++ if (tabId) publish2(tabId, { readAt: Date.now() });
++ },
++ release,
++ forget,
++ /** Forget every Bot outside `botIds`, as when Bots were deleted from another window. */
++ retain(botIds) {
++ const keep = new Set(botIds);
++ const known = /* @__PURE__ */ new Set([...Object.keys(tabs.getSnapshot()), ...Object.keys(saved), ...owner.values()]);
++ for (const entry of views.values()) known.add(entry.botId);
++ return Promise.all([...known].filter((botId) => !keep.has(botId)).map(forget));
++ },
++ dispose() {
++ disposed = true;
++ clearTimeout(resizeLater);
++ for (const entry of views.values()) void release(entry.tabId);
++ layer?.remove();
+ }
+- },
+- (0, import_react47.createElement)("span", { key: "label", className: "bt-project-team-label" }, label),
+- botId === "" ? (0, import_react47.createElement)("span", { key: "value", className: "bt-project-missing" }, t("Not set")) : (0, import_react47.createElement)("span", { key: "value", className: "bt-project-team-bot" }, `@${botId}`)
+-);
+-var POOL_RANKS = [["owner", "owner"], ["maintainer", "maintainers"], ["member", "members"]];
+-function ProjectTeam({ project, onOpenBot }) {
+- const view = projectTeamView(project);
+- const ranks = view.empty ? (0, import_react47.createElement)("span", { className: "bt-project-missing" }, t("No one in this pool yet.")) : (0, import_react47.createElement)(
+- "span",
+- { className: "bt-project-team-roles" },
+- POOL_RANKS.map(([role, key]) => view.roles[key].length === 0 ? null : (0, import_react47.createElement)(
+- "span",
+- { key: role, className: "bt-project-team-role", "data-pool-role": role },
+- `${roleText(role)}: ${view.roles[key].map((id) => `@${id}`).join(", ")}`
+- ))
+- );
+- return (0, import_react47.createElement)(
+- "section",
+- { className: "bt-project-team", "data-team": "chief,agent,pool" },
+- (0, import_react47.createElement)("h4", null, t("Project team")),
+- teamRow("chief", t("Project Chief"), view.chief, onOpenBot),
+- teamRow("agent", t("My agent"), view.agent, onOpenBot),
+- (0, import_react47.createElement)(
+- "div",
+- { key: "pool", className: "bt-project-team-row", "data-team-role": "pool" },
+- (0, import_react47.createElement)("span", { key: "label", className: "bt-project-team-label" }, t("Role pool")),
+- ranks,
+- // The caller's own rank and what the host said they may switch to: the same answer the
+- // sidebar menu reads, printed where the pool is.
+- view.role === "" ? null : (0, import_react47.createElement)(
+- "span",
+- { key: "mine", className: "bt-project-team-myrole", "data-team-my-role": view.role },
+- [
+- t("My role"),
+- ": ",
+- roleText(view.role),
+- view.choices.length === 0 ? null : ` · ${t("Can switch to {roles}", { roles: view.choices.map(roleText).join(", ") })}`
+- ]
+- )
+- )
+- );
+-}
+-function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot }) {
+- const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics });
+- return (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-board", "data-can-manage": view.canManage ? "true" : "false" },
+- (0, import_react47.createElement)("h3", null, view.title),
+- (0, import_react47.createElement)(ProjectTeam, { project, onOpenBot }),
+- // Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
+- view.counts === null ? null : (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-counts", "data-counts": "waiting,inflight,blocked,done" },
+- (0, import_react47.createElement)("span", null, [t("Waiting"), ": ", (0, import_react47.createElement)("b", { key: "w" }, countText(view.counts.waiting))]),
+- (0, import_react47.createElement)("span", null, [t("In flight"), ": ", (0, import_react47.createElement)("b", { key: "f" }, countText(view.counts.inflight))]),
+- (0, import_react47.createElement)("span", null, [t("Blocked"), ": ", (0, import_react47.createElement)("b", { key: "b" }, countText(view.counts.blocked))]),
+- (0, import_react47.createElement)("span", null, [t("Done"), ": ", (0, import_react47.createElement)("b", { key: "d" }, countText(view.counts.done))])
+- ),
+- // #1749: both buttons open a panel and neither is hidden from a non-owner. The host refuses
+- // every write a non-owner attempts, so the refusal is an answer the reader sees, not a
+- // control that silently disappeared.
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-actions" },
+- (0, import_react47.createElement)("button", { type: "button", "data-action": "project-settings", onClick: () => onSettings?.() }, t("Project settings")),
+- (0, import_react47.createElement)("button", { type: "button", "data-action": "project-members", onClick: () => onMembers?.() }, t("Members"))
+- ),
+- (0, import_react47.createElement)(ProjectMetrics, { view: view.metrics }),
+- (0, import_react47.createElement)(ProjectRunningTable, { view: view.metrics, onOpen }),
+- (0, import_react47.createElement)(ProjectBoardRows, { view, onOpen }),
+- (0, import_react47.createElement)(ProjectQueueGroups, { view, onOpen }),
+- (0, import_react47.createElement)(ProjectDoneGroup, { view, onOpen }),
+- (0, import_react47.createElement)(ProjectBoardRecord, { record: view.record })
+- );
+-}
+-function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved } = {}) {
+- const save = request ?? call;
+- const [kind, setKind] = (0, import_react47.useState)("bot");
+- const [memberId, setMemberId] = (0, import_react47.useState)("");
+- const [note, setNote] = (0, import_react47.useState)("");
+- const [assistantOff, setAssistantOff] = (0, import_react47.useState)(assistantMine === void 0 ? project?.assistant?.off === true : assistantMine?.off === true);
+- const [status, setStatus] = (0, import_react47.useState)("");
+- const id = text(project?.id);
+- const run = async (endpoint, payload, done) => {
+- try {
+- const result = await save(endpoint, payload);
+- setStatus(done);
+- onSaved?.(result);
+- return result;
+- } catch (error) {
+- setStatus(hostText(error?.message ?? String(error)));
+- return null;
+- }
+- };
+- return (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-panel", "data-panel": "project-settings" },
+- (0, import_react47.createElement)("h3", null, t("Project settings")),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-field" },
+- (0, import_react47.createElement)("label", { htmlFor: "bt-project-member-id" }, t("Bind a member to this Project")),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-row-actions" },
+- (0, import_react47.createElement)(
+- "select",
+- { "aria-label": t("Member kind"), value: kind, onChange: (event) => setKind(event.target.value) },
+- (0, import_react47.createElement)("option", { value: "bot" }, t("Bot")),
+- (0, import_react47.createElement)("option", { value: "group" }, t("Group"))
+- ),
+- (0, import_react47.createElement)("input", { id: "bt-project-member-id", value: memberId, placeholder: t("Member id"), onChange: (event) => setMemberId(event.target.value) }),
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-bind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: id }, t("Member bound.")) }, t("Bind")),
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-unbind", onClick: () => run("project-bind", { kind, id: text(memberId), projectId: null }, t("Member unbound.")) }, t("Unbind"))
+- )
+- ),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-field" },
+- (0, import_react47.createElement)("label", null, t("Project Assistant")),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-row-actions" },
+- (0, import_react47.createElement)("label", null, (0, import_react47.createElement)("input", { type: "checkbox", "data-action": "assistant-off", checked: assistantOff, onChange: (event) => setAssistantOff(event.target.checked) }), " ", t("Pause the daily digest")),
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-assistant-save", onClick: () => run("project-assistant-owner", { id, settings: { off: assistantOff === true } }, t("Assistant settings saved.")) }, t("Save"))
+- )
+- ),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-field" },
+- // The host knows two memory kinds; the free text the panel takes is recorded as a decision,
+- // never as a third kind the host would refuse (#1820).
+- (0, import_react47.createElement)("label", { htmlFor: "bt-project-note" }, t("Add to the assistant memory")),
+- (0, import_react47.createElement)("textarea", { id: "bt-project-note", value: note, onChange: (event) => setNote(event.target.value) }),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-row-actions" },
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "data-action": "project-memory-save", onClick: () => run("project-memory-write", { id, kind: "decision", text: text(note) }, t("Memory saved.")) }, t("Save memory"))
+- )
+- ),
+- (0, import_react47.createElement)("div", { className: "bt-project-status-line", role: "status" }, status),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-row-actions" },
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+- )
+- );
+-}
+-function ProjectMembersPanel({ project, rows, onClose } = {}) {
+- const owners = [];
+- const add = (id) => {
+- const clean = text(id);
+- if (clean !== "" && !owners.includes(clean)) owners.push(clean);
+ };
+- add(project?.ownerUserId);
+- for (const id of Array.isArray(project?.ownerUserIds) ? project.ownerUserIds : []) add(id);
+- const boardMembers = [];
+- for (const row of Array.isArray(rows) ? rows : []) {
+- const name = text(row?.assignee);
+- if (name !== "" && !boardMembers.includes(name)) boardMembers.push(name);
+- }
+- const chief = text(project?.chief?.botId);
+- return (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-panel", "data-panel": "project-members" },
+- (0, import_react47.createElement)("h3", null, t("Members")),
+- (0, import_react47.createElement)(
+- "ul",
+- { className: "bt-project-members" },
+- owners.map((owner) => (0, import_react47.createElement)(
+- "li",
+- { key: `owner:${owner}`, className: "bt-project-member", "data-member-role": "owner", "data-member-id": owner },
+- (0, import_react47.createElement)("span", null, `@${owner}`),
+- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Owner"))
+- )),
+- chief === "" ? null : (0, import_react47.createElement)(
+- "li",
+- { key: `chief:${chief}`, className: "bt-project-member", "data-member-role": "chief", "data-member-id": chief },
+- (0, import_react47.createElement)("span", null, t("Project Chief")),
+- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("Chief of this Project"))
+- ),
+- boardMembers.map((name) => (0, import_react47.createElement)(
+- "li",
+- { key: `board:${name}`, className: "bt-project-member", "data-member-role": "board", "data-member-id": name },
+- (0, import_react47.createElement)("span", null, `@${name}`),
+- (0, import_react47.createElement)("span", { className: "bt-project-member-kind" }, t("On the board"))
+- ))
+- ),
+- owners.length === 0 && boardMembers.length === 0 && chief === "" ? (0, import_react47.createElement)("div", { className: "bt-project-empty" }, t("No member is named yet.")) : null,
+- (0, import_react47.createElement)("p", { className: "bt-project-note" }, t("Member management is not part of this slice; owners and the members their cards name are listed here.")),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-row-actions" },
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => onClose?.() }, t("Back to board"))
+- )
+- );
+ }
+-var PROJECT_BOARD_POLL_MS = 5e3;
+-var boardPollPaused = () => typeof document !== "undefined" && document.visibilityState === "hidden" || typeof window !== "undefined" && !window.location.hash.startsWith("#project");
+-function ProjectBoardOverlay({ actions, call: request } = {}) {
+- const read = request ?? call;
+- const [state, setState] = (0, import_react47.useState)(null);
+- const [panel, setPanel] = (0, import_react47.useState)(null);
+- const [hash, setHash] = (0, import_react47.useState)(() => typeof window === "undefined" ? "" : window.location.hash);
+- const close = (0, import_react47.useCallback)(() => {
+- if (typeof window !== "undefined") window.location.hash = "";
+- setPanel(null);
+- setState(null);
+- }, []);
+- (0, import_react47.useEffect)(() => {
+- if (typeof window === "undefined") return void 0;
+- const onKey = (event) => {
+- if (event.key !== "Escape") return;
+- if (panel !== null) setPanel(null);
+- else close();
+- };
+- window.addEventListener("keydown", onKey);
+- return () => window.removeEventListener("keydown", onKey);
+- }, [panel, close]);
+- (0, import_react47.useEffect)(() => {
+- if (typeof window === "undefined") return void 0;
+- const sync = () => setHash(window.location.hash);
+- window.addEventListener("hashchange", sync);
+- sync();
+- return () => window.removeEventListener("hashchange", sync);
+- }, []);
+- (0, import_react47.useEffect)(() => {
+- if (typeof window === "undefined") return void 0;
+- if (!hash.startsWith("#project")) {
+- setState(null);
+- return void 0;
++var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++function serveBrowserReads({ browsers, post, clientId = globalThis.crypto.randomUUID(), retryMs = 1e3 }) {
++ let stopped = false;
++ let poll;
++ let reported = "";
++ const answered = /* @__PURE__ */ new Map();
++ const answer = async ({ id, botId }) => {
++ let job = answered.get(id);
++ if (job === void 0) {
++ job = (async () => {
++ try {
++ const result = { id, ok: true, page: await browsers.read(botId) };
++ browsers.noteRead(botId);
++ return result;
++ } catch (error) {
++ return { id, ok: false, error: error instanceof Error ? error.message : String(error) };
++ }
++ })();
++ answered.set(id, job);
++ if (answered.size > 200) answered.delete(answered.keys().next().value);
+ }
+- const wanted = hash.slice("#project".length).replace(/^=/, "");
+- let gone = false;
+- const loadBoard = async () => {
+- const listing = await read("projects");
+- const projects = listing?.projects ?? [];
+- const project = wanted === "" ? projects[0] : projects.find((item) => item.id === wanted);
+- if (project === void 0) return { project: null, rows: [], canManage: false, tasks: [] };
+- const [record, board, taskList] = await Promise.all([
+- read("project-read", { id: project.id }),
+- read("project-board", { id: project.id, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
+- read("tasks", {})
+- ]);
+- const tasks = Array.isArray(taskList) ? taskList : taskList?.tasks ?? [];
+- const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null };
+- const base = record?.project ?? project;
+- const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] };
+- return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null };
+- };
+- const refresh = async (first) => {
++ await post("browser-result", await job).catch((error) => console.warn("[ds-bot] browser result", error));
++ };
++ const loop = async () => {
++ let failures = 0;
++ while (!stopped) {
++ poll = new AbortController();
++ const open = browsers.openPages();
++ reported = JSON.stringify(open);
+ try {
+- const next = await loadBoard();
+- if (!gone) setState(next);
++ const requests = await post("browser-wait", { clientId, open }, poll.signal);
++ failures = 0;
++ for (const request of Array.isArray(requests) ? requests : []) void answer(request);
+ } catch {
+- if (!gone && first) setState({ project: null, rows: [], canManage: false, tasks: [] });
++ if (stopped) return;
++ if (poll.signal.aborted) continue;
++ failures += 1;
++ await sleep(Math.min(3e4, retryMs * 2 ** Math.min(failures, 5)));
+ }
+- };
+- void refresh(true);
+- const timer = setInterval(() => {
+- if (!boardPollPaused()) void refresh(false);
+- }, PROJECT_BOARD_POLL_MS);
+- return () => {
+- gone = true;
+- clearInterval(timer);
+- };
+- }, [hash]);
+- if (state === null) return null;
+- return (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-overlay" },
+- (0, import_react47.createElement)("div", { className: "bt-scrim", onClick: close }),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-mem bt-project-dialog", role: "dialog", "aria-modal": true, "aria-label": t("Project board") },
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-mem-head" },
+- (0, import_react47.createElement)("h2", {}, t("Project board")),
+- (0, import_react47.createElement)("span", { className: "bt-mem-head-gap" }),
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-project-close", autoFocus: true, onClick: close }, t("Close"))
+- ),
+- (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-body" },
+- state.project === null ? (0, import_react47.createElement)(
+- "div",
+- { className: "bt-project-empty" },
+- (0, import_react47.createElement)("p", null, t("No Project yet — create one.")),
+- (0, import_react47.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+- close();
+- actions?.openSettings?.("projects");
+- } }, t("Open Projects in Settings"))
+- ) : panel === "settings" ? (0, import_react47.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react47.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react47.createElement)(ProjectBoardPane, {
+- project: state.project,
+- rows: state.rows,
+- done: state.done,
+- canManage: state.canManage,
+- tasks: state.tasks,
+- decisions: state.decisions,
+- meetings: state.meetings,
+- queues: state.queues,
+- metrics: state.metrics,
+- onSettings: () => setPanel("settings"),
+- onMembers: () => setPanel("members"),
+- // #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
+- // so it closes first — otherwise the chat opens behind it.
+- onOpenBot: (id) => {
+- close();
+- actions?.openSession?.(id);
+- },
+- // The task's own board is where a row leads: the Tasks dialog reads `#task=` on mount and
+- // lands on that Bot or group. Nothing here re-decides which board that is.
+- onOpen: (board) => {
+- if (typeof window !== "undefined") window.location.hash = `#task=${board.kind}:${board.id}`;
+- actions?.openBoard?.();
+- setState(null);
+- }
+- })
+- )
+- )
+- );
++ }
++ };
++ const offPages = browsers.pages.subscribe(() => {
++ if (JSON.stringify(browsers.openPages()) !== reported) poll?.abort();
++ });
++ void loop();
++ return () => {
++ stopped = true;
++ offPages();
++ poll?.abort();
++ };
++}
++
++// src/client/mobile.js
++var MOBILE_MAX_WIDTH = 640;
++var MOBILE_CSS = `
++@media (max-width:${MOBILE_MAX_WIDTH}px){
++.bt-held-actions{max-width:100%}
++.bt-held-card{padding:10px 12px}
++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions{gap:8px;margin-top:8px}
++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button{flex:1 1 0;min-width:0;height:40px;padding:0 16px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:14px;font-weight:500;cursor:pointer}
++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button.bt-flybot-approve{background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-weight:600}
++:is(.bt-held-card,.bt-flybot-approval) .bt-flybot-approval-actions>button:disabled{opacity:.5;cursor:default}
++.bt-held-retry{min-height:32px}
++.bt-flybot-approvals{left:64px;right:8px;bottom:calc(8px + env(safe-area-inset-bottom,0px));max-width:none;max-height:min(60vh,calc(100vh - 16px))}
++.bt-flybot-pointers{top:8px;right:8px;max-width:calc(100vw - 72px)}
++.bt-flybot-pointer{max-width:100%;min-width:0}
++.bt-flybot-pointer-text{flex:0 1 auto;min-width:0;max-width:none}
++.bt-flybot-pointer-note{flex:0 100 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
++.bt-flybot-pointer button{flex:none;min-height:28px;padding:4px 12px}
++.bt-inbox-card{flex-wrap:wrap;row-gap:8px}
++.bt-inbox-copy{flex-basis:calc(100% - 38px)}
++/* In progress and Done only. A Needs you ask is the whole text: inbox.js unsets this clamp on those five statuses (#1177). */
++.bt-inbox-title,.bt-inbox-reason{white-space:normal;overflow-wrap:anywhere;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}
++.bt-inbox-actions{flex:1 1 100%;justify-content:flex-end;flex-wrap:wrap;padding-left:38px}
++.bt-inbox-actions :is(.bt-soft,.bt-send){height:36px;padding:0 16px;font-size:13px}
++}
++`;
++
++// src/client/panes.js
++var PANE_MIN_WIDTH_CSS = `
++.bt-panel{min-width:0}
++.bt-panel-view{min-width:0}
++.bt-drawer-body{min-width:0;overflow-wrap:anywhere}
++.bt-drawer-body :is(.bt-muted,.bt-note,.bt-section-title,.bt-drawer-role,.bt-drawer-permission){min-width:0;overflow-wrap:anywhere}
++.bt-panel :is(.bt-member,.bt-members,.bt-bcard-row,.bt-drawer-model,.bt-usage-row){min-width:0}
++.bt-panel :is(.bt-muted,.bt-note){white-space:normal}
++[class*="_centerCol"]{min-width:0}
++html,body,#root{overflow-x:clip}
++[class*="_frame"]>[class*="_centerCol"]{overflow-x:clip}
++[class*="_frame"]:has(> [class*="_centerCol"]){overflow-x:clip}
++`;
++var COMPOSER = "[data-composer-input], [data-composer-card]";
++function clipScroll(el) {
++ if (el && el.scrollLeft > 0) el.scrollLeft = 0;
++}
++function appFrameOf(doc) {
++ if (typeof doc.querySelector !== "function") return null;
++ const col = doc.querySelector('[class*="_centerCol"]');
++ const frame = col?.parentElement;
++ if (!frame) return null;
++ const name = frame.className;
++ const text2 = typeof name === "string" ? name : name?.baseVal ?? "";
++ return text2.includes("_frame") ? frame : null;
++}
++function levelShell(doc, from) {
++ const page = doc.scrollingElement || doc.documentElement;
++ clipScroll(page);
++ clipScroll(doc.documentElement);
++ clipScroll(doc.body);
++ clipScroll(appFrameOf(doc));
++ for (let at = from?.parentElement || null; at; at = at.parentElement) clipScroll(at);
++}
++function composerOf(node) {
++ let el = node;
++ if (el && el.nodeType !== 1) el = el.parentElement;
++ if (!el || typeof el.closest !== "function") return null;
++ return el.closest(COMPOSER);
++}
++function watchShellLevel(doc) {
++ const later = (from) => {
++ const reset = () => levelShell(doc, from);
++ reset();
++ if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset));
++ };
++ const onFocus = (event) => later(composerOf(event.target) || void 0);
++ const onInput = (event) => {
++ const composer = composerOf(event.target);
++ if (composer) later(composer);
++ };
++ const onScroll = (event) => {
++ const target = event.target;
++ if (target === doc || target === doc.documentElement || target === doc.body) levelShell(doc);
++ };
++ doc.addEventListener("focusin", onFocus);
++ doc.addEventListener("input", onInput);
++ doc.addEventListener("scroll", onScroll, true);
++ return () => {
++ doc.removeEventListener("focusin", onFocus);
++ doc.removeEventListener("input", onInput);
++ doc.removeEventListener("scroll", onScroll, true);
++ };
+ }
+
+ // src/client/friend-lines.js
+diff --git a/index.js b/index.js
+--- a/index.js
++++ b/index.js
+@@ -42,6 +42,7 @@ import * as capabilities from './src/host/capabilities.js'
+ import * as schedules from './src/host/schedules.js'
+ import * as projectAssistant from './src/host/project-assistant.js'
+ import * as projectChief from './src/host/project-chief.js'
++import * as projectTeam from './src/host/project-team.js'
+ import * as automationDefault from './src/host/automation-default.js'
+ import * as usage from './src/host/usage.js'
+ import * as update from './src/host/update.js'
+@@ -60,7 +60,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
+ // the functions other modules call; they call them through `rt` at run time, never during
+ // install. Listeners run and tools are listed in registration order, so this order holds:
+ // usage meters model requests after every listener that rewrites them.
+-const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]
++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, busyAck, steeringInterrupt, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, projectState, meetings, chatIndex, groups, roomMembership, reach, tasks, projectBoard, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, projectAssistant, projectChief, projectTeam, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, workflowStore, channel]
+
+ export function apply(ctx, config = {}) {
+ const rt = createRuntime(ctx, config)
+diff --git a/src/client/header.js b/src/client/header.js
+--- a/src/client/header.js
++++ b/src/client/header.js
+@@ -7,6 +7,8 @@ import { clockLabel, movedOnSince, openingTurn, unseenSince } from './cells.js'
+ import { ActivityIndicator } from './activity-row.js'
+ import { BusyAckBubble } from './busy-ack.js'
+ import { t } from './i18n.js'
++import { call } from './api.js'
++import { ProjectAgentEntry, projectAgentFor } from './project-board.js'
+ import { friendBotTitle, roomTitle } from '../shared/remote-label.js'
+
+ // -------------------------------------------------------------------------
+@@ -69,6 +69,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus
+ const owner = roster.byId[sessionId]?.id ?? sessionId
+ const doing = useActivity(map => map[owner])
+ const bot = roster.byId[sessionId]
++ // #1864: an owner's Bot that belongs to a Project gets the entry to that Project's assistant in
++ // its own chat header. The roster already carries the binding, so nothing else is read here.
++ const projectAgent = projectAgentFor(bot, roster.self?.memberId)
+ const room = roster.roomsById[sessionId]
+ const detailsOpen = useUi(state => state.details === sessionId)
+ const full = bot?.remote
+@@ -96,6 +96,9 @@ export function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus
+ h(MemoryNotice, { shown: memoryNotice }),
+ h('span', { className: 'bt-pill-go', 'aria-hidden': true }, h(GoArrow))),
+ h('span', { className: 'bt-sr', role: 'status' }, memoryNotice ? t('Memory updated') : ''),
++ projectAgent === null ? null : h(ProjectAgentEntry, {
++ projectId: projectAgent.projectId, read: call, onOpen: id => actions?.openSession?.(id),
++ }),
+ h(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }),
+ h(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }),
+ h(BusyAckBubble, { key: `busy-ack:${sessionId}`, sessionId, useRoster, useBusyAcks, useSessionStatus, useSession, useLive }))
+diff --git a/src/client/i18n.js b/src/client/i18n.js
+--- a/src/client/i18n.js
++++ b/src/client/i18n.js
+@@ -1403,6 +1403,15 @@ const ZH = {
+ 'Role pool': '角色池',
+ 'Can switch to {roles}': '可切换为 {roles}',
+ 'No one in this pool yet.': '角色池里还没有人。',
++ // #1864: the Project team's dossier — persona, memory and skills are read-only in this slice.
++ 'Dossier': '档案',
++ 'Persona': '人设',
++ 'Skills': '技能',
++ 'No memory yet.': '还没有记忆。',
++ 'No skills attached.': '还没有附加技能。',
++ 'Open chat': '打开对话',
++ 'Open my agent': '打开我的项目助手',
++ "Open {name}'s dossier": '打开 {name} 的档案',
+ 'No cards yet.': '还没有卡片。',
+ 'Open Projects in Settings': '在设置中打开项目',
+ }
+diff --git a/src/host/channel.js b/src/host/channel.js
+--- a/src/host/channel.js
++++ b/src/host/channel.js
+@@ -675,6 +675,7 @@ export function install(rt) {
+ case 'project-bind':
+ case 'project-of':
+ case 'project-board':
++ case 'project-team':
+ case 'project-role':
+ case 'project-delete':
+ case 'project-memory':
+diff --git a/src/host/memory.js b/src/host/memory.js
+--- a/src/host/memory.js
++++ b/src/host/memory.js
+@@ -411,6 +411,7 @@ export function install(rt) {
+ memoryEndpoint: async () => { throw new Error('Memory is turned off on this host') },
+ memoryNews: () => ({}),
+ memoryReviews: () => 0,
++ memoryDigest: () => ({ head: '', topics: [] }),
+ })
+ return
+ }
+@@ -1469,9 +1469,22 @@ export function install(rt) {
+ })
+ }
+
++ // The Project board's dossier (#1864) reads the head and the topic names only: never an entry
++ // body from a topic, and never another scope. It is a read seam, so the panel adds no second
++ // memory file reader beside this one and cannot drift from where `remember` writes.
++ function memoryDigest(botId, limits = {}) {
++ const scope = ownScope(String(botId ?? ''))
++ const head = Number.isFinite(limits.head) ? limits.head : 4000
++ const topics = Number.isFinite(limits.topics) ? limits.topics : 64
++ return {
++ head: readText(join(dirOf(scope), MAIN)).slice(0, head),
++ topics: topicNames(scope).slice(0, topics),
++ }
++ }
++
+ Object.assign(rt, {
+ memoryEnabled: true, renewMemory, archiveBotMemory, memoryEndpoint, memoryReviews,
+ memoryNews: () => Object.fromEntries(news),
+- botMemoryRead, botMemoryAppend,
++ botMemoryRead, botMemoryAppend, memoryDigest,
+ })
+ }
+diff --git a/src/host/store.js b/src/host/store.js
+--- a/src/host/store.js
++++ b/src/host/store.js
+@@ -719,6 +719,10 @@ export function install(rt) {
+ return { project: readProject(project.id), ...(result ?? {}) }
+ }
+ case 'project-of': return { project: projectOfMember(projectText(body.kind, 20), projectText(body.id, 200)) }
++ // The read-only team dossier (#1864): the Project's Chief, its assistant and its pool, each
++ // with persona, memory head/topics and skills. The read lives in project-team.js; a missing
++ // Bot is a `null` row there, never an error for the whole answer.
++ case 'project-team': return rt.projectTeam?.(projectText(body.id, 200)) ?? { rows: [] }
+ // The read-only board (#1696): Project id in, its tasks out. The aggregate lives in
+ // project-board.js; it reads and returns, and writes nothing. The actor goes with the id
+ // so the board applies the same visibility rule `tasks` does (#1723).
diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js
index 4e8b9c090..73fdd5649 100644
--- a/patches/ds-bot/files/src/client/project-board.js
+++ b/patches/ds-bot/files/src/client/project-board.js
@@ -101,6 +101,20 @@ export const PROJECT_BOARD_CSS = `
.bt-project-team-roles{display:flex;gap:10px;flex-wrap:wrap;font-size:11px;opacity:.75}
.bt-project-team-role{font-family:var(--bt-mono,ui-monospace,monospace)}
.bt-project-team-myrole{margin-left:auto;font-size:11px;opacity:.65}
+
+/* #1864: the team dossier. A Dossier control on each team row opens one read-only panel; the
+ owner's own chat header carries the same entry to the Project assistant's chat. */
+.bt-project-team-dossier{margin-left:auto;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-team-dossier:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
+.bt-project-dossier{padding:16px 18px;display:flex;flex-direction:column;gap:12px}
+.bt-project-dossier-section{display:flex;flex-direction:column;gap:4px}
+.bt-project-dossier-section h4{margin:0;font-size:12px;letter-spacing:.04em;text-transform:uppercase;opacity:.7}
+.bt-project-dossier-text{margin:0;font-size:13px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}
+.bt-project-dossier-meta{display:flex;gap:12px;flex-wrap:wrap;font-size:11px;opacity:.7}
+.bt-project-dossier-topics{display:flex;gap:6px;flex-wrap:wrap}
+.bt-project-dossier-topic{font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bt-card,rgba(127,127,127,.08))}
+.bt-project-agent-entry{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--bt-line,rgba(127,127,127,.32));background:transparent;color:inherit;cursor:pointer}
+.bt-project-agent-entry:hover{background:var(--bt-hover,rgba(127,127,127,.14))}
`
// Sources in the order the board reads them; a source with no rows is left out so three empty
@@ -558,7 +572,7 @@ export function projectTeamView(project) {
}
}
-const teamRow = (role, label, botId, onOpenBot) => h('div', {
+const teamRow = (role, label, botId, onOpenBot, onOpenDossier) => h('div', {
key: role, className: 'bt-project-team-row', 'data-team-role': role,
// A row with no Bot is the fallback the slice specifies: still drawn, with nothing to open.
...(botId === ''
@@ -573,23 +587,38 @@ const teamRow = (role, label, botId, onOpenBot) => h('div', {
h('span', { key: 'label', className: 'bt-project-team-label' }, label),
botId === ''
? h('span', { key: 'value', className: 'bt-project-missing' }, t('Not set'))
- : h('span', { key: 'value', className: 'bt-project-team-bot' }, `@${botId}`))
+ : h('span', { key: 'value', className: 'bt-project-team-bot' }, `@${botId}`),
+// #1864: the dossier control sits inside the row, so the row still opens the chat (#1860) and a
+// reader can look a Bot up first. A row with no Bot keeps the control: its panel reads "not set".
+h('button', {
+ key: 'dossier', type: 'button', className: 'bt-project-team-dossier',
+ 'data-dossier-role': role, ...(botId === '' ? {} : { 'data-dossier-bot': botId }),
+ 'aria-label': t('Open {name}\'s dossier', { name: botId === '' ? label : botId }),
+ onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role, label, botId }) },
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event?.stopPropagation(); onOpenDossier?.({ role, label, botId }) } },
+}, t('Dossier')))
const POOL_RANKS = [['owner', 'owner'], ['maintainer', 'maintainers'], ['member', 'members']]
-export function ProjectTeam({ project, onOpenBot }) {
+export function ProjectTeam({ project, onOpenBot, onOpenDossier }) {
const view = projectTeamView(project)
+ const poolId = (id) => h('button', {
+ key: id, type: 'button', className: 'bt-project-team-dossier',
+ 'data-dossier-role': 'pool', 'data-dossier-bot': id,
+ 'aria-label': t('Open {name}\'s dossier', { name: id }),
+ onClick: (event) => { event?.stopPropagation(); onOpenDossier?.({ role: 'pool', label: t('Role pool'), botId: id }) },
+ }, `@${id}`)
const ranks = view.empty
? h('span', { className: 'bt-project-missing' }, t('No one in this pool yet.'))
: h('span', { className: 'bt-project-team-roles' },
POOL_RANKS.map(([role, key]) => (view.roles[key].length === 0
? null
: h('span', { key: role, className: 'bt-project-team-role', 'data-pool-role': role },
- `${roleText(role)}: ${view.roles[key].map(id => `@${id}`).join(', ')}`))))
+ `${roleText(role)}: `, view.roles[key].map(id => poolId(id))))))
return h('section', { className: 'bt-project-team', 'data-team': 'chief,agent,pool' },
h('h4', null, t('Project team')),
- teamRow('chief', t('Project Chief'), view.chief, onOpenBot),
- teamRow('agent', t('My agent'), view.agent, onOpenBot),
+ teamRow('chief', t('Project Chief'), view.chief, onOpenBot, onOpenDossier),
+ teamRow('agent', t('My agent'), view.agent, onOpenBot, onOpenDossier),
h('div', { key: 'pool', className: 'bt-project-team-row', 'data-team-role': 'pool' },
h('span', { key: 'label', className: 'bt-project-team-label' }, t('Role pool')),
ranks,
@@ -602,11 +631,99 @@ export function ProjectTeam({ project, onOpenBot }) {
view.choices.length === 0 ? null : ` · ${t('Can switch to {roles}', { roles: view.choices.map(roleText).join(', ') })}`])))
}
-export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot }) {
+// #1864: the dossier panel. It draws only the answer it was handed — persona, the memory head and
+// topic names, and the skill names — so it cannot re-fetch per section. A `null` row is the host's
+// "this Bot is gone" answer: the panel says "not set" and draws no invented persona.
+export function dossierRowOf(team, botId) {
+ const id = text(botId)
+ if (id === '') return null
+ const rows = Array.isArray(team?.rows) ? team.rows : []
+ return rows.find(row => row !== null && typeof row === 'object' && text(row.botId) === id) ?? null
+}
+
+const dossierSection = (title, children) => h('div', { key: title, className: 'bt-project-dossier-section' },
+ h('h4', null, title), children)
+
+export function ProjectDossierPanel({ row, label, onClose, onOpenBot } = {}) {
+ const known = row !== null && typeof row === 'object'
+ const botId = known ? text(row.botId) : ''
+ const topics = known && Array.isArray(row.memory?.topics) ? row.memory.topics.map(name => text(name)).filter(name => name !== '') : []
+ const skills = known && Array.isArray(row.skills) ? row.skills.map(name => text(name)).filter(name => name !== '') : []
+ const meta = known
+ ? [text(row.role), text(row.model)].filter(value => value !== '')
+ : []
+ return h('div', { className: 'bt-project-panel bt-project-dossier', 'data-panel': 'project-dossier', 'data-dossier-bot': botId === '' ? undefined : botId },
+ h('h3', null, label === '' || label === undefined ? t('Dossier') : `${label} · ${t('Dossier')}`),
+ known
+ ? [
+ dossierSection(t('Persona'), [
+ meta.length === 0 ? null : h('div', { key: 'meta', className: 'bt-project-dossier-meta' }, meta.map(value => h('span', { key: value }, value))),
+ h('p', { key: 'persona', className: 'bt-project-dossier-text' }, text(row.persona) || t('Not set')),
+ ]),
+ dossierSection(t('Memory'), [
+ h('p', { key: 'head', className: 'bt-project-dossier-text' }, text(row.memory?.head) || t('No memory yet.')),
+ topics.length === 0
+ ? null
+ : h('div', { key: 'topics', className: 'bt-project-dossier-topics' }, topics.map(topic => h('span', { key: topic, className: 'bt-project-dossier-topic' }, topic))),
+ ]),
+ dossierSection(t('Skills'), h('p', { key: 'skills', className: 'bt-project-dossier-text' }, skills.length === 0 ? t('No skills attached.') : skills.join(', '))),
+ ]
+ : h('p', { className: 'bt-project-missing' }, t('Not set')),
+ botId === '' || !known
+ ? null
+ : h('div', { key: 'actions', className: 'bt-project-row-actions' },
+ h('button', {
+ type: 'button', className: 'bt-soft bt-soft-sm', 'data-open-bot': botId,
+ onClick: () => onOpenBot?.(botId),
+ }, t('Open chat'))),
+ h('div', { className: 'bt-project-row-actions' },
+ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
+}
+
+// #1864: the entry in the owner's own Bot chat. The roster already carries a Bot's `projectId`
+// (the binding writes it on the record), so the header needs no second list read; the assistant id
+// comes from the Project record when the reader presses the entry.
+export function projectAgentFor(bot, selfId) {
+ if (bot === null || typeof bot !== 'object' || bot.remote !== undefined) return null
+ const projectId = text(bot.projectId)
+ if (projectId === '') return null
+ const owner = text(bot.ownerId)
+ const self = text(selfId)
+ if (self !== '' && owner !== '' && owner !== self) return null
+ return { projectId }
+}
+
+export function ProjectAgentEntry({ projectId, read, onOpen } = {}) {
+ const [agentId, setAgentId] = useState('')
+ const id = text(projectId)
+ const open = async () => {
+ let target = agentId
+ if (target === '') {
+ try {
+ const record = await (read ?? call)('project-read', { id })
+ target = botIdOf(record?.project?.assistant)
+ } catch {
+ // A Project that cannot be read offers no agent; the chat is unchanged.
+ target = ''
+ }
+ if (target !== '') setAgentId(target)
+ }
+ if (target !== '') onOpen?.(target)
+ }
+ if (id === '') return null
+ return h('button', {
+ type: 'button', className: 'bt-project-agent-entry',
+ 'data-project-agent': id, 'data-open-bot': agentId === '' ? undefined : agentId,
+ title: t('Open my agent'), 'aria-label': t('Open my agent'),
+ onClick: open,
+ }, t('My agent'))
+}
+
+export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics, onOpen, onSettings, onMembers, onOpenBot, onOpenDossier }) {
const view = projectBoardView({ project, rows, done, canManage, tasks, decisions, meetings, queues, metrics })
return h('div', { className: 'bt-project-board', 'data-can-manage': view.canManage ? 'true' : 'false' },
h('h3', null, view.title),
- h(ProjectTeam, { project, onOpenBot }),
+ h(ProjectTeam, { project, onOpenBot, onOpenDossier }),
// Header counts (#1768): four numbers read from the host, an unreadable one shown as unknown.
view.counts === null ? null : h('div', { className: 'bt-project-counts', 'data-counts': 'waiting,inflight,blocked,done' },
h('span', null, [t('Waiting'), ': ', h('b', { key: 'w' }, countText(view.counts.waiting))]),
@@ -718,6 +835,37 @@ export function ProjectMembersPanel({ project, rows, onClose } = {}) {
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board'))))
}
+// #1864: one read for the whole board, exported so a test can prove the dossier comes from a single
+// `project-team` call. `read` is the channel seam (`call` in the app).
+export async function readProjectBoard(read, wanted) {
+ const listing = await read('projects')
+ const projects = Array.isArray(listing?.projects) ? listing.projects : []
+ const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted)
+ if (project === undefined) return { project: null, rows: [], done: [], canManage: false, tasks: [], team: { rows: [] } }
+ const zone = typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function'
+ ? undefined
+ : Intl.DateTimeFormat().resolvedOptions().timeZone
+ const [record, board, taskList, team] = await Promise.all([
+ read('project-read', { id: project.id }),
+ read('project-board', { id: project.id, timeZone: zone }),
+ read('tasks', {}),
+ read('project-team', { id: project.id }),
+ ])
+ const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? [])
+ // The groups are the same read as the rows, so the header cannot disagree with the cards.
+ const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }
+ // #1860: `project-read` answers the stored record, which carries no caller rank — `role` and
+ // `choices` are the `projects` listing's answer (#1828). They stay on the record the pane
+ // reads, so the team block prints the rank without a second listing read.
+ const base = record?.project ?? project
+ const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }
+ return {
+ project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true,
+ tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null,
+ team: team !== null && typeof team === 'object' ? team : { rows: [] },
+ }
+}
+
// #1814: what a card has waited (`waitedMs`) and whether the lease it holds is live are computed by
// the host at read time — a lease lasts 30 minutes (`tasks.js` `LEASE_TTL_SEC`) — so a board that
// reads once freezes: an expiry that happens while the page is open never turns the card `stalled`
@@ -740,6 +888,7 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
const read = request ?? call
const [state, setState] = useState(null)
const [panel, setPanel] = useState(null)
+ const [dossier, setDossier] = useState(null)
// The hash is the whole entry point, so it has to be read again every time it changes: an
// entry that writes `#project=` while this mounts opened nothing when the read was one-shot.
const [hash, setHash] = useState(() => (typeof window === 'undefined' ? '' : window.location.hash))
@@ -771,28 +920,9 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
if (!hash.startsWith('#project')) { setState(null); return undefined }
const wanted = hash.slice('#project'.length).replace(/^=/, '')
let gone = false
- // Four reads of one board: the Project, its record, its rows, and the task texts the rows are
- // joined against. Nothing here asks a Bot by Bot.
- const loadBoard = async () => {
- const listing = await read('projects')
- const projects = listing?.projects ?? []
- const project = wanted === '' ? projects[0] : projects.find(item => item.id === wanted)
- if (project === undefined) return { project: null, rows: [], canManage: false, tasks: [] }
- const [record, board, taskList] = await Promise.all([
- read('project-read', { id: project.id }),
- read('project-board', { id: project.id, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
- read('tasks', {}),
- ])
- const tasks = Array.isArray(taskList) ? taskList : (taskList?.tasks ?? [])
- // The groups are the same read as the rows, so the header cannot disagree with the cards.
- const queues = { waiting: board?.waiting, inflight: board?.inflight, blocked: board?.blocked, counts: board?.counts ?? null }
- // #1860: `project-read` answers the stored record, which carries no caller rank — `role` and
- // `choices` are the `projects` listing's answer (#1828). They stay on the record the pane
- // reads, so the team block prints the rank without a second listing read.
- const base = record?.project ?? project
- const withRole = { ...base, role: project.role ?? null, choices: Array.isArray(project.choices) ? project.choices : [] }
- return { project: withRole, assistantMine: record?.assistantMine ?? null, rows: board?.tasks ?? [], done: board?.done ?? [], canManage: record?.canManage === true, tasks, decisions: board?.decisions, meetings: board?.meetings, queues, metrics: board?.metrics ?? null }
- }
+ // #1864: the board, its record, its rows, the task texts and the team dossier are one read path,
+ // and the dossier comes from the single `project-team` call that read makes.
+ const loadBoard = () => readProjectBoard(read, wanted)
const refresh = async (first) => {
try {
const next = await loadBoard()
@@ -825,13 +955,21 @@ export function ProjectBoardOverlay({ actions, call: request } = {}) {
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { close(); actions?.openSettings?.('projects') } }, t('Open Projects in Settings')))
// A row opens the board its task lives on, carried by `#task=`: the Tasks board reads
// that hash and lands on the Bot or group the task belongs to.
+ : panel === 'dossier' ? h(ProjectDossierPanel, {
+ row: dossierRowOf(state.team, dossier?.botId),
+ label: dossier?.label ?? '',
+ onClose: () => setPanel(null),
+ onOpenBot: (id) => { close(); actions?.openSession?.(id) },
+ })
: panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, onClose: () => setPanel(null) })
: panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) })
: h(ProjectBoardPane, {
project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks,
- decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics,
+ decisions: state.decisions, meetings: state.meetings, queues: state.queues, metrics: state.metrics, team: state.team,
onSettings: () => setPanel('settings'),
onMembers: () => setPanel('members'),
+ // #1864: a team row's Dossier control opens the read-only panel, fed by the same read.
+ onOpenDossier: (value) => { setDossier(value); setPanel('dossier') },
// #1860: a team row opens that Bot's chat. The board is a modal over the conversation,
// so it closes first — otherwise the chat opens behind it.
onOpenBot: (id) => { close(); actions?.openSession?.(id) },
diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js
index a5782c2b4..44e1f546a 100644
--- a/patches/ds-bot/files/src/host/authz.js
+++ b/patches/ds-bot/files/src/host/authz.js
@@ -69,6 +69,10 @@ const TASK_ENDPOINTS = new Set([
// other wiring point was present, and a signed-in host answered `bot/not-found: Unknown
// endpoint project-board` while `project-read` and `project-of` answered ALLOW.
'projects', 'project-create', 'project-read', 'project-bind', 'project-of', 'project-board',
+ // The team dossier (#1864) is a read over the same Project and the same bindings: the owner
+ // decided on 2026-10-30 that persona and memory are shared by the Project's members, so it sits
+ // beside `project-read` rather than behind the owner gate.
+ 'project-team',
// The assistant (#1712): its memory is per Project, and deleting a Project takes it along. The
// owner controls (#1759) are a Project-wide write — the digest opt-out silences every member's
// 09:00 digest — so `project-assistant-owner` belongs here with them: dispatched but missing
diff --git a/patches/ds-bot/files/src/host/project-team.js b/patches/ds-bot/files/src/host/project-team.js
new file mode 100644
index 000000000..af6c59a99
--- /dev/null
+++ b/patches/ds-bot/files/src/host/project-team.js
@@ -0,0 +1,86 @@
+// The Project board's team dossier (#1864). One read-only answer for the people who serve a
+// Project: its own Chief (#1859), the assistant that speaks for the caller (#1712) and the role
+// pool the Project names (#1828). Nothing here writes persona, instructions or memory; editing stays
+// in the Project settings panel, and a later slice that wants an editor opens its own issue.
+//
+// Persona is the Bot's `instructions`, memory is the head of its own MEMORY.md plus the topic names
+// (never a topic body, never another Project's memory), and skills are the attached capabilities the
+// library records as `skill` — a pack contributes the skills it contains. The owner decided on
+// 2026-10-30 that persona and memory are shared by the Project's members and kept as files in the
+// repository; this slice reads what the Bot carries today, and adds no writer.
+//
+// Every string and list is capped, and a Bot that was deleted is a `null` row: one dead Chief must
+// not take the assistant and the pool down with it. The row shape is the contract the client's
+// dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working
+// folder or a stored secret.
+import { rolesOf } from './project-board.js'
+
+export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 }
+
+const text = value => (typeof value === 'string' ? value.trim() : '')
+const capped = (value, limit) => text(value).slice(0, limit)
+
+export function install(rt) {
+ // The attached names that are skills. A pack is expanded to its skill children; a capability the
+ // library no longer holds is kept by name so a moved library does not erase what the Bot has.
+ const skillNames = (bot) => {
+ const library = typeof rt.capabilityLibraryView === 'function' ? rt.capabilityLibraryView() : []
+ const byName = new Map((Array.isArray(library) ? library : []).map(item => [text(item?.name), item]))
+ const names = []
+ const add = (name) => {
+ const clean = text(name)
+ if (clean !== '' && !names.includes(clean)) names.push(clean)
+ }
+ for (const attached of Array.isArray(bot.capabilities) ? bot.capabilities : []) {
+ const item = byName.get(text(attached))
+ if (item === undefined) add(attached)
+ else if (item.kind === 'skill') add(attached)
+ else if (item.kind === 'pack') for (const child of item.items ?? []) if (byName.get(text(child))?.kind === 'skill') add(child)
+ }
+ return names.slice(0, DOSSIER_LIMITS.skills)
+ }
+
+ // Only this Project's rooms: a dossier is the Project's own read, so a Bot that also works in
+ // another Project's room does not carry that room's name into this answer.
+ const roomsOf = (bot, project) => Object.values(rt.state?.rooms ?? {})
+ .filter(room => room !== null && typeof room === 'object' && Array.isArray(room.members) && room.members.includes(bot.id))
+ .filter(room => rt.projectOfMember?.('group', room.id)?.id === project.id)
+ .map(room => ({ id: text(room.id), name: text(room.name).slice(0, 200) }))
+ .slice(0, DOSSIER_LIMITS.rooms)
+
+ const rowOf = (botId, project) => {
+ const id = text(botId)
+ if (id === '') return null
+ const bot = rt.botOf?.(id)
+ if (bot === undefined || bot === null) return null
+ const digest = rt.memoryDigest?.(id) ?? { head: '', topics: [] }
+ return {
+ botId: id,
+ name: capped(bot.name, DOSSIER_LIMITS.name),
+ role: capped(bot.role, DOSSIER_LIMITS.role),
+ persona: capped(bot.instructions, DOSSIER_LIMITS.persona),
+ model: capped(bot.model, DOSSIER_LIMITS.model),
+ color: capped(bot.color, DOSSIER_LIMITS.color),
+ createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null,
+ memory: {
+ head: (typeof digest.head === 'string' ? digest.head : '').slice(0, DOSSIER_LIMITS.memoryHead),
+ topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics),
+ },
+ skills: skillNames(bot),
+ rooms: roomsOf(bot, project),
+ }
+ }
+
+ // The order is the board's own: Chief, assistant, then the pool. A Project with no Chief keeps
+ // the first slot as `null`, so the client always reads the same three groups.
+ const projectTeam = (projectId) => {
+ const project = rt.readProject?.(text(projectId)) ?? null
+ if (project === null) return { rows: [] }
+ const roles = rolesOf(project)
+ const pool = [...roles.maintainers, ...roles.members].filter((id, at, all) => all.indexOf(id) === at)
+ const ids = [text(project.chief?.botId), text(project.assistant?.botId), ...pool]
+ return { rows: ids.map(id => rowOf(id, project)) }
+ }
+
+ rt.projectTeam = projectTeam
+}
diff --git a/patches/ds-bot/files/test/project-board-panels.test.js b/patches/ds-bot/files/test/project-board-panels.test.js
index aa9273391..0ed3f0d5c 100644
--- a/patches/ds-bot/files/test/project-board-panels.test.js
+++ b/patches/ds-bot/files/test/project-board-panels.test.js
@@ -55,7 +55,7 @@ runInNewContext(bundle.outputFiles[0].text, {
throw new Error(`no platform module ${name}`)
},
})
-const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel, hostText, installLocale } = module.exports
+const { ProjectBoardPane, ProjectSettingsPanel, ProjectMembersPanel, ProjectDossierPanel, ProjectAgentEntry, projectAgentFor, readProjectBoard, hostText, installLocale } = module.exports
const render = (component, props) => { index = 0; return component(props) }
const all = (node, out = []) => {
@@ -450,3 +450,89 @@ test('a Project with no Chief keeps the row and reads "not set"', () => {
assert.match(text(drawn), /Not set/)
assert.deepEqual(opened, [])
})
+
+// #1864: the dossier. The board's team block now carries a Dossier control on every row, the panel
+// draws Persona / Memory / Skills from the one `project-team` answer, and the owner's own chat
+// header has an entry that opens the Project assistant's Session.
+const dossier = () => ({
+ botId: 'bot-chief', name: 'Project Chief (FlyBot)', role: 'Project Chief',
+ persona: 'You are the Project Chief of FlyBot.', model: 'deepseek-chat', color: '#112233', createdAt: 1,
+ memory: { head: 'The Chief remembers the Project.', topics: ['travel', 'work'] },
+ skills: ['ship-it'], rooms: [{ id: 'r1', name: 'FlyBot dev' }],
+})
+
+const projectRecord = () => ({
+ id: 'project-1', name: 'FlyBot', ownerUserId: 'owner-1',
+ chief: { botId: 'bot-chief', sessionId: 'bot-chief', createdAt: 1 },
+ assistant: { botId: 'bot-assistant', sessionId: 'bot-assistant', createdAt: 1 },
+ roles: { maintainers: ['bot-dev'], members: [] },
+})
+
+const boardAnswer = () => ({ tasks: [], done: [], decisions: {}, meetings: {}, waiting: [], inflight: [], blocked: [], counts: null, metrics: null })
+
+test('the board reads the dossier from exactly one project-team call', async () => {
+ resetSlots()
+ const calls = []
+ const read = async (endpoint) => {
+ calls.push(endpoint)
+ if (endpoint === 'projects') return { projects: [{ id: 'project-1', name: 'FlyBot', role: 'owner', choices: [] }] }
+ if (endpoint === 'project-read') return { project: projectRecord(), canManage: true }
+ if (endpoint === 'project-board') return boardAnswer()
+ if (endpoint === 'tasks') return []
+ if (endpoint === 'project-team') return { rows: [dossier(), null, { ...dossier(), botId: 'bot-dev', name: 'Dev' }] }
+ throw new Error(`unexpected endpoint ${endpoint}`)
+ }
+ const state = await readProjectBoard(read, 'project-1')
+ assert.equal(calls.filter(endpoint => endpoint === 'project-team').length, 1, 'one call feeds the whole dossier')
+ assert.equal(state.team.rows.length, 3, 'the null row survives the read so the panel can stay empty')
+ assert.equal(state.team.rows[0].botId, 'bot-chief')
+
+ const tree = render(ProjectBoardPane, { project: state.project, rows: state.rows, team: state.team, canManage: true })
+ const chief = all(tree).find(node => node.props?.['data-dossier-bot'] === 'bot-chief')
+ assert.ok(chief, 'the Chief row carries the dossier control')
+ const opened = []
+ const drawn = render(ProjectBoardPane, { project: state.project, rows: state.rows, team: state.team, canManage: true, onOpenDossier: value => opened.push(value) })
+ const control = all(drawn).find(node => node.props?.['data-dossier-bot'] === 'bot-chief')
+ control.props.onClick()
+ assert.deepEqual(opened.map(entry => entry.botId), ['bot-chief'])
+})
+
+test('the dossier panel draws Persona, Memory and Skills, and stays empty for a null row', () => {
+ resetSlots()
+ const drawn = text(render(ProjectDossierPanel, { row: dossier(), label: 'Project Chief' }))
+ assert.match(drawn, /Persona/, 'the Persona section is drawn')
+ assert.match(drawn, /Project Chief of FlyBot/, 'the persona text is the host answer')
+ assert.match(drawn, /deepseek-chat/, 'the model is shown with the persona')
+ assert.match(drawn, /Memory/, 'the Memory section is drawn')
+ assert.match(drawn, /The Chief remembers the Project\./, 'the memory head is shown')
+ assert.match(drawn, /travel/, 'the topic names are listed')
+ assert.match(drawn, /Skills/, 'the Skills section is drawn')
+ assert.match(drawn, /ship-it/, 'the attached skill is listed')
+
+ const empty = text(render(ProjectDossierPanel, { row: null, label: 'Project Chief' }))
+ assert.match(empty, /Not set/, 'an unset Bot reads "not set"')
+ assert.doesNotMatch(empty, /deepseek-chat/, 'and the panel draws no invented persona')
+})
+
+test('the owner\'s chat entry opens the Project assistant, and only the owner\'s own Bot offers it', async () => {
+ resetSlots()
+ const calls = []
+ const opened = []
+ const read = async (endpoint, payload) => {
+ calls.push({ endpoint, payload })
+ return { project: { assistant: { botId: 'bot-assistant' } } }
+ }
+ let tree = render(ProjectAgentEntry, { projectId: 'project-1', read, onOpen: id => opened.push(id) })
+ const entry = all(tree).find(node => node.props?.['data-project-agent'] === 'project-1')
+ entry.props.onClick()
+ await waitFor(() => (opened.length > 0 ? true : undefined))
+ assert.deepEqual(opened, ['bot-assistant'], 'the entry opens the assistant Session')
+ assert.deepEqual(JSON.parse(JSON.stringify(calls)), [{ endpoint: 'project-read', payload: { id: 'project-1' } }], 'one project-read answers the assistant id')
+ tree = render(ProjectAgentEntry, { projectId: 'project-1', read, onOpen: id => opened.push(id) })
+ assert.equal(all(tree).find(node => node.props?.['data-open-bot']).props['data-open-bot'], 'bot-assistant', 'the resolved entry names the Bot it opens')
+
+ assert.deepEqual(JSON.parse(JSON.stringify(projectAgentFor({ projectId: 'project-1', ownerId: 'owner-1' }, 'owner-1'))), { projectId: 'project-1' })
+ assert.equal(projectAgentFor({ projectId: 'project-1', ownerId: 'other' }, 'owner-1'), null, "another owner's Bot offers no entry")
+ assert.equal(projectAgentFor({ ownerId: 'owner-1' }, 'owner-1'), null, 'a Bot outside a Project offers no entry')
+ assert.equal(projectAgentFor({ projectId: 'project-1', ownerId: 'owner-1', remote: {} }, 'owner-1'), null, "a friend's Bot offers no entry")
+})
diff --git a/patches/ds-bot/files/test/project-board-view.test.js b/patches/ds-bot/files/test/project-board-view.test.js
index cfcb15bb9..fb73f3127 100644
--- a/patches/ds-bot/files/test/project-board-view.test.js
+++ b/patches/ds-bot/files/test/project-board-view.test.js
@@ -316,11 +316,11 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
win.location.hash = '#project'
const count = { calls: 0 }
const { tree, cleanups } = await mount({ actions: {}, call: boardAnswers(count) })
- assert.equal(count.calls, 4, 'the first read is the four calls the board makes: Project, record, board, task texts')
+ assert.equal(count.calls, 5, 'the first read is the five calls the board makes: Project, record, board, task texts, team dossier')
assert.match(textIn(tree), /Dev4 · stalled/, 'the board draws what the first read answered')
const afterFirst = count.calls
await tick()
- assert.equal(count.calls, afterFirst + 4, 'an open board reads again on the next tick')
+ assert.equal(count.calls, afterFirst + 5, 'an open board reads again on the next tick')
// A row opens its own board: the hash names the task and this board stops drawing, so a tick that
// lands before the cleanup must not read. This segment pins the unmount, not the predicate.
const afterTick = count.calls
@@ -330,7 +330,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
// Closing that board puts this one back in front: the next tick reads again.
win.location.hash = '#project'
await tick()
- assert.equal(count.calls, afterTick + 4, 'closing the row’s board resumes the poll')
+ assert.equal(count.calls, afterTick + 5, 'closing the row’s board resumes the poll')
// A hidden tab reads nothing either — this is the segment the named predicate is pinned by.
const afterResume = count.calls
doc.visibilityState = 'hidden'
@@ -338,7 +338,7 @@ test('#1814: an open board reads on every tick, stops once a row opens its own b
assert.equal(count.calls, afterResume, 'no read in a hidden tab')
doc.visibilityState = 'visible'
await tick()
- assert.equal(count.calls, afterResume + 4, 'a visible tab reads again on the next tick')
+ assert.equal(count.calls, afterResume + 5, 'a visible tab reads again on the next tick')
// Close the board: the timer goes with it, so a tick afterwards reads nothing at all.
const afterVisible = count.calls
for (const off of cleanups) off()
diff --git a/patches/ds-bot/files/test/project-team.test.js b/patches/ds-bot/files/test/project-team.test.js
new file mode 100644
index 000000000..b4e10612d
--- /dev/null
+++ b/patches/ds-bot/files/test/project-team.test.js
@@ -0,0 +1,175 @@
+// #1864: the Project board's team dossier. One read-only `project-team` endpoint answers, for the
+// Project's Chief, its assistant and the pool members the Project names, the persona (`instructions`),
+// the memory head and topic names, and the attached skills — capped, and with a missing Bot as a
+// `null` row instead of an error for the whole answer.
+//
+// Before the slice the host answered `Unknown endpoint project-team` before any handler ran, so the
+// first two tests are the red half: they call the endpoint through the same route the browser uses.
+import assert from 'node:assert/strict'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { createRuntime, provideFlyBot } from '../index.js'
+import { fakeContext } from './fake-ctx.js'
+
+const LONG = 'x'.repeat(5000)
+
+const seed = () => ({
+ version: 2,
+ revision: 1,
+ mainBotIds: ['bot-chief'],
+ members: {
+ 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'bot-chief' },
+ 'peer-1': { id: 'peer-1', createdAt: 2 },
+ },
+ bots: {
+ 'bot-chief': {
+ id: 'bot-chief', name: 'Project Chief (FlyBot)', role: 'Project Chief', sessionId: 'bot-chief',
+ createdAt: 11, ownerId: 'owner-1', model: 'deepseek-chat', color: '#112233',
+ instructions: 'You are the Project Chief of FlyBot.', capabilities: ['ship-it'],
+ },
+ 'bot-assistant': {
+ id: 'bot-assistant', name: 'Project Assistant (FlyBot)', role: 'Project Assistant', sessionId: 'bot-assistant',
+ createdAt: 12, ownerId: 'owner-1', model: 'deepseek-chat', color: '#445566',
+ instructions: 'You are the Project Assistant of FlyBot.', capabilities: ['ship-it', 'not-a-skill'],
+ },
+ 'bot-dev': {
+ id: 'bot-dev', name: 'Dev', role: 'Dev', sessionId: 'bot-dev',
+ createdAt: 13, ownerId: 'owner-1', model: 'deepseek-reasoner', color: '#778899',
+ instructions: 'You implement issues.', capabilities: [],
+ },
+ },
+ rooms: {
+ r1: { id: 'r1', name: 'FlyBot dev', members: ['bot-chief', 'bot-dev'], mode: 'admin', admin: 'bot-chief', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-1' },
+ r2: { id: 'r2', name: 'Far Project room', members: ['bot-dev'], mode: 'admin', admin: 'bot-dev', named: true, sessions: {}, log: [], seq: 0, seen: {}, projectId: 'project-2' },
+ },
+ agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, grants: {},
+ capabilities: {
+ items: {
+ 'ship-it': { name: 'ship-it', kind: 'skill', description: 'Ship things' },
+ 'not-a-skill': { name: 'not-a-skill', kind: 'mcp', description: 'Not a skill' },
+ },
+ },
+ projects: {
+ 'project-1': {
+ id: 'project-1', name: 'FlyBot', repository: 'ThinkFlowLab/FlyBot', defaultBranch: 'main',
+ ownerUserId: 'owner-1', createdAt: 1, updatedAt: 1, status: 'active',
+ chief: { botId: 'bot-chief', sessionId: 'bot-chief', createdAt: 1 },
+ assistant: { botId: 'bot-assistant', sessionId: 'bot-assistant', createdAt: 1 },
+ roles: { maintainers: ['bot-dev'], members: ['bot-gone'] },
+ },
+ 'project-2': {
+ id: 'project-2', name: 'Far', repository: null, defaultBranch: 'main',
+ ownerUserId: 'owner-1', createdAt: 2, updatedAt: 2, status: 'active',
+ },
+ },
+})
+
+const memory = (home, botId, head, topics = []) => {
+ const dir = join(home, 'memory', 'bots', botId)
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(join(dir, 'MEMORY.md'), head)
+ for (const topic of topics) writeFileSync(join(dir, `${topic}.md`), `- a fact about ${topic}\n`)
+}
+
+async function start({ signedIn = false } = {}) {
+ const home = mkdtempSync(join(tmpdir(), 'ds-bot-project-team-'))
+ writeFileSync(join(home, 'state.json'), JSON.stringify(seed()))
+ memory(home, 'bot-chief', 'The Chief remembers the Project.\n', ['travel', 'work'])
+ memory(home, 'bot-assistant', 'The assistant remembers the board.\n', ['board'])
+ memory(home, 'bot-dev', 'Dev memory.\n')
+ const ctx = fakeContext()
+ let route
+ const register = ctx.connection.fetch.register
+ ctx.connection.fetch.register = (options) => { route = options; return register(options) }
+ const agents = new Map()
+ const agentOf = (sessionId) => {
+ if (!agents.has(sessionId)) agents.set(sessionId, { session: { id: sessionId }, followup() {}, steer() {}, inject() {}, cancel() {}, whenIdle() {}, inbox: { nextStep: [], nextTurn: [], remove() {} } })
+ return agents.get(sessionId)
+ }
+ Object.assign(ctx, {
+ llm: { listProviders: () => [], listModels: async () => [], resolveModelInfo: async () => ({}) },
+ workspaceRegistry: { get: () => undefined, resolveByPath: async () => undefined, create: async path => ({ id: 'w1', path }), archiveSession: async () => {} },
+ sessionController: { create: async () => ({ sessionId: 's1' }), resolveAgent: async id => ({ agent: agentOf(id) }), selectModel: async () => {} },
+ sessionTitle: { rename: async () => {} },
+ sessions: { flush: async () => {} },
+ })
+ const rt = createRuntime(ctx, { home })
+ provideFlyBot(ctx, rt.flybot)
+ await rt.load()
+ const callAs = async (endpoint, payload, token) => (await route.fetch(new Request('http://localhost/api/bot', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', ...(token === undefined ? {} : { 'x-flybot-member-session': token }) },
+ body: JSON.stringify({ endpoint, payload }),
+ }))).json()
+ if (signedIn) {
+ rt.authenticate = async token => (token === 'tok-owner' ? { memberId: 'owner-1' } : null)
+ rt.state.authSessions = { 's-owner': { memberId: 'owner-1', tokenHash: 'o' } }
+ }
+ const stop = async () => {
+ await ctx.dispose()
+ rmSync(home, { recursive: true, force: true, maxRetries: 5 })
+ }
+ return { callAs, home, rt, stop }
+}
+
+const ROW_KEYS = ['botId', 'color', 'createdAt', 'memory', 'model', 'name', 'persona', 'role', 'rooms', 'skills']
+
+test('project-team answers persona, memory and skills for the Chief, the assistant and the pool', async (t) => {
+ const { callAs, stop } = await start()
+ t.after(stop)
+ const answer = await callAs('project-team', { id: 'project-1' })
+ assert.equal(answer.ok, true, 'the endpoint is dispatched through the channel, not Unknown endpoint')
+ const rows = answer.value.rows
+ assert.equal(rows.length, 4, 'Chief, assistant and the two pool members, in that order')
+ assert.deepEqual(rows.slice(0, 3).map(row => row.botId), ['bot-chief', 'bot-assistant', 'bot-dev'])
+ assert.equal(rows[3], null, 'a pool member whose Bot is gone is a null row, not an error')
+
+ const chief = rows[0]
+ assert.deepEqual(Object.keys(chief).sort(), ROW_KEYS, 'exactly the documented keys, no secret-bearing field')
+ assert.equal(chief.name, 'Project Chief (FlyBot)')
+ assert.equal(chief.role, 'Project Chief')
+ assert.equal(chief.model, 'deepseek-chat')
+ assert.equal(chief.color, '#112233')
+ assert.equal(chief.createdAt, 11)
+ assert.equal(chief.persona, 'You are the Project Chief of FlyBot.', 'persona is the Bot instructions')
+ assert.deepEqual(chief.memory, { head: 'The Chief remembers the Project.\n', topics: ['travel', 'work'] })
+ assert.deepEqual(chief.skills, ['ship-it'], 'only attached skills, not every attached capability')
+ assert.deepEqual(chief.rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'only this Project\'s rooms')
+
+ assert.deepEqual(rows[1].memory, { head: 'The assistant remembers the board.\n', topics: ['board'] })
+ assert.deepEqual(rows[1].skills, ['ship-it'])
+ assert.deepEqual(rows[2].rooms, [{ id: 'r1', name: 'FlyBot dev' }], 'the far Project\'s room is not listed')
+})
+
+test('project-team caps persona, memory head and topics', async (t) => {
+ const { callAs, home, rt, stop } = await start()
+ t.after(stop)
+ rt.state.bots['bot-chief'].instructions = LONG
+ memory(home, 'bot-chief', LONG, Array.from({ length: 70 }, (_, at) => `topic-${String(at).padStart(2, '0')}`))
+ const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
+ assert.equal(rows[0].persona.length, 4000, 'persona is capped at 4000 characters')
+ assert.equal(rows[0].memory.head.length, 4000, 'the memory head is capped at 4000 characters')
+ assert.equal(rows[0].memory.topics.length, 64, 'at most 64 topic names')
+})
+
+test('a deleted Chief is a null row while the rest of the team still answers', async (t) => {
+ const { callAs, rt, stop } = await start()
+ t.after(stop)
+ delete rt.state.bots['bot-chief']
+ const rows = (await callAs('project-team', { id: 'project-1' })).value.rows
+ assert.equal(rows[0], null, 'the missing Chief is null')
+ assert.equal(rows[1].botId, 'bot-assistant', 'the assistant still answers')
+ assert.equal(rows[2].botId, 'bot-dev', 'the pool still answers')
+})
+
+test('project-team reads with the project-read class of caller, including a signed-in member', async (t) => {
+ const { callAs, stop } = await start({ signedIn: true })
+ t.after(stop)
+ const answer = await callAs('project-team', { id: 'project-1' }, 'tok-owner')
+ assert.equal(answer.ok, true, 'the endpoint belongs beside project-read in authz, not behind the owner gate')
+ assert.equal(answer.value.rows[0].botId, 'bot-chief')
+ const unknown = await callAs('project-team', { id: 'project-none' }, 'tok-owner')
+ assert.deepEqual(unknown.value.rows, [], 'an unknown Project answers no rows instead of an error')
+})
diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json
index 2e9692bdf..e78f10c97 100644
--- a/patches/ds-bot/topics.json
+++ b/patches/ds-bot/topics.json
@@ -147,5 +147,14 @@
],
"0017-handoff-route": [
"src/host/tools.js"
+ ],
+ "0018-project-dossier": [
+ "client.js",
+ "index.js",
+ "src/client/header.js",
+ "src/client/i18n.js",
+ "src/host/channel.js",
+ "src/host/memory.js",
+ "src/host/store.js"
]
}