diff --git a/changelog.d/1299-workflows.added.md b/changelog.d/1299-workflows.added.md new file mode 100644 index 000000000..aeea4bce0 --- /dev/null +++ b/changelog.d/1299-workflows.added.md @@ -0,0 +1 @@ +Settings has a **Workflows** page: the factory presets, your own definitions as `workflows/.json` in the workspace, and which one this project uses. A definition is edited as a node table — id, name, role, inputs, outputs, guards, refusal — with the flowchart drawn above it from the same table and each guard showing the sentence it would print right now, or saying the fact could not be read. *Customize* copies a preset into a new file and never edits the shipped one, and saving an unedited definition writes identical bytes (#1272, step 3). diff --git a/core/client.js b/core/client.js index 1905d1804..2b42b4db3 100644 --- a/core/client.js +++ b/core/client.js @@ -42,6 +42,30 @@ module.exports = __toCommonJS(index_exports); // src/client/i18n.js var NAMESPACE = "ds-bot"; var ZH = { + // Workflows (#1272 step 3) + Workflows: "工作流", + "A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.": "工作流就是一个文件:workflows/.json。列、流程图和拒绝语都从它画出来。", + "Factory presets": "出厂预设", + Yours: "你自己的", + "project default": "项目默认", + "Reading workflows…": "正在读取工作流…", + Customize: "定制一份", + Save: "保存", + "Use for this project": "本项目使用", + "This node has no guards, so nothing refuses it but its inputs.": "这个节点没有 guard,除了输入之外没有别的东西会拒绝它。", + Guards: "Guard", + Edges: "边", + "Guards of {node}": "{node} 的 Guard", + holds: "成立", + "cannot read": "读不到", + refuses: "拒绝", + "The flowchart, drawn from the node table": "流程图,按节点表绘制", + "This host could not read the facts, so these guards say what they could not read — none of them is a pass.": "这台主机读不到事实,所以这些 guard 只说读不到什么——没有一条算通过。", + "Saved workflows/{id}.json": "已保存 workflows/{id}.json", + "Could not open this workflow": "打不开这个工作流", + "This definition cannot be saved": "这个定义不能保存", + "This definition cannot be copied": "这个定义不能复制", + "{count} card(s) name a node this workflow draws no column for.": "有 {count} 张卡指向这个工作流不画列的节点。", // Shared "Main Bot": "主 Bot", Working: "工作中", @@ -902,7 +926,6 @@ var ZH = { "At least {count} characters.": "至少 {count} 个字符。", "Replace value": "替换值", "Saving…": "保存中…", - Save: "保存", "Allowing…": "允许中…", Allow: "允许", "{bot} asked for {name}": "{bot} 请求了 {name}", @@ -2172,8 +2195,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } -var eyeMarkup = ([cx, cy, w, h48, tilt]) => ``; -var happyEyeMarkup = ([cx, cy, w, h48, tilt]) => ``; +var eyeMarkup = ([cx, cy, w, h49, tilt]) => ``; +var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; @@ -2869,29 +2892,29 @@ function inviteCountdown(expiresAt, offsetMs = 0) { const total = Math.max(1, Math.ceil(left / 1e3)); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; } -function RoomInvitePreview({ h: h48, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { - if (!preview || typeof h48 !== "function") return null; +function RoomInvitePreview({ h: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h49 !== "function") return null; const at = `${kind}`; const child = (name) => `${at}-${name}`; const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); - return h48( + return h49( "div", { className: "fs-preview", "data-flybot": at, role: "status" }, - h48("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), - preview.lead ? h48("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, - preview.people ? h48("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, - preview.expiresAt ? h48("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, - onJoin || onClear ? h48( + h49("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h49("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h49("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h49("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h49( "div", { className: "fs-preview-foot", "data-flybot": child("foot") }, - showClear && onClear ? h48("button", { + showClear && onClear ? h49("button", { type: "button", className: "fs-btn fs-btn-sm", "data-flybot": child("clear"), disabled: !!busy, onClick: onClear }, clearLabel) : null, - onJoin ? h48("button", { + onJoin ? h49("button", { type: "button", className: "fs-btn fs-btn-sm fs-btn-primary", "data-flybot": child("join"), @@ -3456,12 +3479,12 @@ function liveSprings(apply2) { const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; const steps = Math.ceil(dt * 240); - const h48 = dt / steps; + const h49 = dt / steps; let moving = false; for (const value of values.values()) { for (let index = 0; index < steps; index += 1) { - value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h48; - value.x += value.v * h48; + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h49; + value.x += value.v * h49; } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; @@ -4928,6 +4951,7 @@ var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]) var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +var WorkflowIcon = lineIcon(["M4.5 4h4v3.5h-4z", "M11.5 12.5h4V16h-4z", "M6.5 7.5v3.5a2 2 0 0 0 2 2h3"]); var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); @@ -5036,7 +5060,7 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised }; useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; - const field = ownAnswer ? (0, import_react6.createElement)("textarea", { + const field2 = ownAnswer ? (0, import_react6.createElement)("textarea", { ref: ownRef, rows: 1, value: own, @@ -5075,12 +5099,12 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised selected && !raised ? (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) : null ); }); - if (raised && field) { + if (raised && field2) { rows.push((0, import_react6.createElement)( "label", { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, ownLetter ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, - (0, import_react6.createElement)("span", { className: "bt-q-body" }, field) + (0, import_react6.createElement)("span", { className: "bt-q-body" }, field2) )); } let foot = null; @@ -5117,10 +5141,10 @@ function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised (0, import_react6.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react6.createElement)(CloseIcon)) ), rows.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, - field && !raised ? (0, import_react6.createElement)( + field2 && !raised ? (0, import_react6.createElement)( "div", { className: "bt-q-custom" }, - (0, import_react6.createElement)("label", { className: "bt-q-field" }, field), + (0, import_react6.createElement)("label", { className: "bt-q-field" }, field2), !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null ) : null, foot @@ -8727,8 +8751,10 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = ) ); } -function DeliveryColumns({ view, repo = null }) { +function DeliveryColumns({ view, repo = null, placed = null }) { const columns = view?.columns ?? {}; + const drawn = placed === null || placed === void 0 ? null : new Set(placed); + const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)); return (0, import_react16.createElement)( "div", { className: "bt-del-columns" }, @@ -8739,12 +8765,48 @@ function DeliveryColumns({ view, repo = null }) { "h3", { className: "bt-board-column-head" }, (0, import_react16.createElement)("span", null, t(column.label)), - (0, import_react16.createElement)("span", { className: "bt-board-count" }, String((columns[column.id] ?? []).length)) + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards(column.id).length)) ), - (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) + cards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards(column.id).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) )) ); } +function DeliveryWorkflowSections({ sections, repo = null }) { + const list = Array.isArray(sections) ? sections : []; + if (list.length === 0) return null; + return (0, import_react16.createElement)("div", { className: "bt-del-workflows" }, list.map((section) => (0, import_react16.createElement)( + "section", + { + key: section.id, + className: "bt-del-workflow", + "data-workflow": section.id + }, + (0, import_react16.createElement)( + "h3", + { className: "bt-del-workflow-name" }, + (0, import_react16.createElement)("span", null, section.name), + (0, import_react16.createElement)("span", { className: "bt-wf-path" }, `workflows/${section.id}.json`) + ), + (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => (0, import_react16.createElement)( + "section", + { + key: column.id, + className: "bt-del-column", + "data-column": column.id, + "data-workflow": section.id + }, + (0, import_react16.createElement)( + "h4", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, column.label), + (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), + (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(column.cards.length)) + ), + column.cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : column.cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) + ))), + (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null + ))); +} function DeliveryRepoBlock({ entry }) { const state = entry?.state ?? "fresh"; const source = repoSourceText(entry?.repoSource); @@ -8762,7 +8824,7 @@ function DeliveryRepoBlock({ entry }) { { className: "bt-del-block-state", role: "status" }, state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" - ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo }) + ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed }) ); } function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { @@ -8828,7 +8890,10 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu ) : null, state !== "stale" && missing.length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-partial", role: "status" }, missing.map((entry) => missingText(entry)).join(" · ")) : null, (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), - entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) : (0, import_react16.createElement)( + // A workflow's own columns come first: a card that names a workflow is drawn there and not in + // the five columns below, which keep every card that names none (#1272 step 3). + entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) : null, + entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : (0, import_react16.createElement)( "div", { className: "bt-del-repos" }, entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) @@ -13871,7 +13936,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { } return JSON.stringify(hits); }); - const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); + const matchesName = (...fields) => wanted === "" || fields.some((field2) => (field2 ?? "").toLowerCase().includes(wanted)); const sections = []; const people2 = [ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ @@ -14898,6 +14963,263 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } ); } +// src/client/workflows.js +var import_react36 = require("react"); +function parseList(text) { + return String(text ?? "").split(",").map((item) => item.trim()).filter(Boolean); +} +var formatList = (list) => Array.isArray(list) ? list.join(", ") : ""; +function guardLines(guards) { + return (Array.isArray(guards) ? guards : []).map((guard) => `${guard.spec} is ${guard.is}${guard.value === null || guard.value === void 0 ? "" : ` ${guard.value}`}`).join("\n"); +} +function parseGuards(text, previous = []) { + return String(text ?? "").split("\n").map((line2) => line2.trim()).filter(Boolean).map((line2) => { + const found = /^(\S+)\s+is\s+(\S+)(?:\s+(.+))?$/.exec(line2); + if (found === null) return null; + const [, spec, is, value] = found; + const before = previous.find((guard) => guard.spec === spec && guard.is === is); + return { + id: before?.id ?? `${spec}-${is}`, + spec, + is, + value: value === void 0 ? null : value.trim(), + refusal: before?.refusal ?? "" + }; + }).filter(Boolean); +} +function applyNodeField(definition, nodeId, field2, value) { + if (definition === null || definition === void 0) return definition; + return { + ...definition, + nodes: definition.nodes.map((node) => { + if (node.id !== nodeId) return node; + if (field2 === "inputs" || field2 === "outputs") return { ...node, [field2]: parseList(value) }; + if (field2 === "guards") return { ...node, guards: parseGuards(value, node.guards) }; + return { ...node, [field2]: value }; + }) + }; +} +var field = (label, value, onChange, options = {}) => (0, import_react36.createElement)( + "label", + { className: "bt-wf-field", key: label }, + (0, import_react36.createElement)("span", { className: "bt-wf-label" }, label), + (0, import_react36.createElement)("input", { + className: "bt-wf-input", + value: value ?? "", + disabled: options.disabled === true, + placeholder: options.placeholder ?? "", + onChange: (event) => onChange(event.target.value) + }) +); +var guardState = (row) => row.state === "holds" ? t("holds") : row.state === "unreadable" ? t("cannot read") : t("refuses"); +function Flow({ layout }) { + if (!layout || !Array.isArray(layout.boxes) || layout.boxes.length === 0) return null; + return (0, import_react36.createElement)( + "svg", + { + className: "bt-wf-flow", + viewBox: `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`, + role: "img", + "aria-label": t("The flowchart, drawn from the node table"), + "data-boxes": String(layout.boxes.length), + "data-edges": String(layout.edges.length) + }, + layout.edges.map((edge, index) => (0, import_react36.createElement)("line", { + key: `e${index}`, + className: "bt-wf-edge", + x1: edge.points?.[0]?.x ?? 0, + y1: edge.points?.[0]?.y ?? 0, + x2: edge.points?.[1]?.x ?? 0, + y2: edge.points?.[1]?.y ?? 0 + })), + layout.boxes.map((box) => (0, import_react36.createElement)( + "g", + { key: box.id, className: "bt-wf-box", "data-node": box.id, transform: `translate(${box.x} ${box.y})` }, + (0, import_react36.createElement)("rect", { width: box.width, height: box.height, rx: 12 }), + (0, import_react36.createElement)("text", { x: 14, y: 26, className: "bt-wf-box-name" }, box.name), + (0, import_react36.createElement)("text", { x: 14, y: 48, className: "bt-wf-box-role" }, box.role), + (0, import_react36.createElement)("text", { x: 14, y: 72, className: "bt-wf-box-rails" }, `${box.inputs.join(", ") || "—"} → ${box.outputs.join(", ") || "—"}`) + )) + ); +} +function Preview({ preview }) { + if (!preview) return null; + return (0, import_react36.createElement)( + "div", + { className: "bt-wf-preview", "data-facts": preview.facts }, + preview.facts === "unavailable" ? (0, import_react36.createElement)("div", { className: "bt-wf-hint" }, t("This host could not read the facts, so these guards say what they could not read — none of them is a pass.")) : null, + (preview.rows ?? []).map((row) => (0, import_react36.createElement)( + "div", + { key: row.id, className: "bt-wf-guard", "data-state": row.state }, + (0, import_react36.createElement)("span", { className: "bt-wf-badge", "data-state": row.state }, guardState(row)), + (0, import_react36.createElement)("span", { className: "bt-wf-guard-spec" }, `${row.spec} is ${row.is}${row.value === null || row.value === void 0 ? "" : ` ${row.value}`}`), + row.sentence ? (0, import_react36.createElement)("span", { className: "bt-wf-guard-line" }, row.sentence) : null + )) + ); +} +function WorkflowsPage({ actions }) { + const [list, setList] = (0, import_react36.useState)(null); + const [opened, setOpened] = (0, import_react36.useState)(null); + const [draft, setDraft] = (0, import_react36.useState)(null); + const [node, setNode] = (0, import_react36.useState)(null); + const [preview, setPreview] = (0, import_react36.useState)(null); + const [error, setError] = (0, import_react36.useState)(""); + const [saved, setSaved] = (0, import_react36.useState)(""); + const load = () => Promise.resolve(actions.workflows({ op: "list" })).then((value) => { + setList(value); + return value; + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; + }); + (0, import_react36.useEffect)(() => { + load(); + }, []); + const open = (id, target) => Promise.resolve(actions.workflows({ op: "open", id, node: target ?? null })).then((value) => { + if (value?.ok === false) { + setError(value.message ?? t("Could not open this workflow")); + return value; + } + setOpened(value); + setDraft(value.workflow.definition); + setNode(value.preview?.node ?? value.workflow.definition.nodes[0]?.id ?? null); + setPreview(value.preview); + setError(""); + setSaved(""); + return value; + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; + }); + const showNode = (id) => { + setNode(id); + if (!opened) return; + Promise.resolve(actions.workflows({ op: "preview", id: opened.workflow.id, node: id })).then((value) => setPreview(value?.preview ?? null)).catch((failure) => setError(failure?.message ?? String(failure))); + }; + const save = () => Promise.resolve(actions.workflowSave({ op: "save", definition: draft })).then((value) => { + if (value?.ok === false) { + setError(value.message ?? t("This definition cannot be saved")); + return value; + } + setSaved(t("Saved workflows/{id}.json", { id: value.id })); + setError(""); + return load().then(() => open(value.id, node)); + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; + }); + const customize = () => { + if (!opened) return null; + const base = opened.workflow.id; + const id = `${base}-copy`; + return Promise.resolve(actions.workflowSave({ op: "customize", from: base, id, name: `${opened.workflow.name} (mine)` })).then((value) => { + if (value?.ok === false) { + setError(value.message ?? t("This definition cannot be copied")); + return value; + } + return load().then(() => open(id)); + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; + }); + }; + const useDefault = () => { + if (!opened) return null; + return Promise.resolve(actions.workflowSave({ op: "default", id: opened.workflow.id })).then((value) => { + if (value?.ok !== false) setList(value); + return value; + }).catch((failure) => { + setError(failure?.message ?? String(failure)); + return null; + }); + }; + const editable = opened?.workflow.editable === true; + const current = draft?.nodes?.find((entry) => entry.id === node) ?? null; + return (0, import_react36.createElement)( + "div", + { className: "bt-wf-page" }, + (0, import_react36.createElement)( + "div", + { className: "bt-wf-head" }, + (0, import_react36.createElement)("h3", null, (0, import_react36.createElement)(WorkflowIcon), t("Workflows")), + (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.")) + ), + error ? (0, import_react36.createElement)("div", { className: "bt-error", role: "alert" }, error) : null, + saved ? (0, import_react36.createElement)("div", { className: "bt-wf-saved", role: "status" }, saved) : null, + (0, import_react36.createElement)( + "div", + { className: "bt-wf-list" }, + ["presets", "own"].map((group) => (0, import_react36.createElement)( + "div", + { key: group, className: "bt-wf-group" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, group === "presets" ? t("Factory presets") : t("Yours")), + (list?.[group] ?? []).map((entry) => (0, import_react36.createElement)("button", { + key: `${group}:${entry.id}`, + type: "button", + className: "bt-wf-pick", + "aria-current": opened?.workflow.id === entry.id ? "true" : void 0, + "data-id": entry.id, + "data-source": entry.source, + onClick: () => open(entry.id) + }, entry.name, list?.default === entry.id ? (0, import_react36.createElement)("span", { className: "bt-wf-default" }, t("project default")) : null)) + )), + list === null ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("Reading workflows…")) : null + ), + opened === null ? null : (0, import_react36.createElement)( + "div", + { className: "bt-wf-doc" }, + (0, import_react36.createElement)( + "div", + { className: "bt-wf-doc-head" }, + (0, import_react36.createElement)("strong", null, opened.workflow.name), + (0, import_react36.createElement)("span", { className: "bt-wf-path" }, `workflows/${opened.workflow.id}.json`), + (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: customize }, t("Customize")), + editable ? (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: save }, t("Save")) : null, + (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: useDefault }, t("Use for this project")) + ), + (0, import_react36.createElement)(Flow, { layout: opened.layout }), + (0, import_react36.createElement)( + "div", + { className: "bt-wf-table" }, + (0, import_react36.createElement)( + "div", + { className: "bt-wf-row bt-wf-row-head" }, + ["id", "name", "role", "inputs", "outputs", "guards", "refusal"].map((key) => (0, import_react36.createElement)("span", { key }, key)) + ), + (draft?.nodes ?? []).map((entry) => (0, import_react36.createElement)( + "div", + { + key: entry.id, + className: "bt-wf-row", + "data-node": entry.id, + "aria-current": entry.id === node ? "true" : void 0, + onClick: () => showNode(entry.id) + }, + (0, import_react36.createElement)("span", { className: "bt-wf-cell bt-wf-id" }, entry.id), + editable ? field("name", entry.name, (value) => setDraft(applyNodeField(draft, entry.id, "name", value))) : (0, import_react36.createElement)("span", null, entry.name), + editable ? field("role", entry.role, (value) => setDraft(applyNodeField(draft, entry.id, "role", value))) : (0, import_react36.createElement)("span", null, entry.role), + editable ? field("inputs", formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, "inputs", value))) : (0, import_react36.createElement)("span", null, formatList(entry.inputs)), + editable ? field("outputs", formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, "outputs", value))) : (0, import_react36.createElement)("span", null, formatList(entry.outputs)), + editable ? (0, import_react36.createElement)("textarea", { className: "bt-wf-guards-input", value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, "guards", event.target.value)) }) : (0, import_react36.createElement)("span", null, guardLines(entry.guards)), + editable ? field("refusal", entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, "refusal", value))) : (0, import_react36.createElement)("span", null, entry.refusal) + )) + ), + (0, import_react36.createElement)( + "div", + { className: "bt-wf-edges" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, t("Edges")), + (draft?.edges ?? []).map((edge) => (0, import_react36.createElement)("span", { key: `${edge.from}->${edge.to}`, className: "bt-wf-edge-row" }, `${edge.from} → ${edge.to}`)) + ), + (0, import_react36.createElement)( + "div", + { className: "bt-wf-node" }, + (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, node === null ? t("Guards") : t("Guards of {node}", { node: current?.name ?? node })), + current === null || current.guards.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("This node has no guards, so nothing refuses it but its inputs.")) : (0, import_react36.createElement)(Preview, { preview }) + ) + ) + ); +} + // src/client/usage-data.js var HOUR = 0; var OWNER = 1; @@ -15031,7 +15353,7 @@ function percent(part, whole) { } // src/client/usage-page.js -var import_react36 = require("react"); +var import_react37 = require("react"); var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; @@ -15078,9 +15400,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { - const [shown, setShown] = (0, import_react36.useState)(0); - const from = (0, import_react36.useRef)(0); - (0, import_react36.useEffect)(() => { + 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); @@ -15100,9 +15422,9 @@ function useTween(value) { }, [value]); return shown; } -var glyphBox = (size, icon3) => (0, import_react36.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react36.createElement)(icon3)); +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_react36.createElement)("span", { + 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))` } @@ -15119,7 +15441,7 @@ function ownerFace(owner, roster) { sub: owner.deleted ? t("Deleted") : role.toLowerCase() === name.toLowerCase() ? "" : role, paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], - glyph: (size) => (0, import_react36.createElement)(BotAvatar, { bot, size, badge: false, live: false }) + glyph: (size) => (0, import_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false }) }; } if (owner.kind === "room") { @@ -15129,7 +15451,7 @@ function ownerFace(owner, roster) { 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_react36.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) + glyph: (size) => room2 ? (0, import_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) }; } if (owner.kind === "agent") { @@ -15151,30 +15473,30 @@ function Tabs({ label, value, options, onChange, boxRef }) { const names = options.map(([, text]) => text).join("\n"); const glide = useGlide(`${value} ${names}`, { box: boxRef }); - return (0, import_react36.createElement)( + return (0, import_react37.createElement)( "div", { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label }, - (0, import_react36.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.map(([id, text, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text)) + (0, import_react37.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + options.map(([id, text, icon3]) => (0, import_react37.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react37.createElement)(icon3) : null, text)) ); } 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_react36.createElement)("div", { className: "bt-us-stat" }, (0, import_react36.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react36.createElement)("span", { className: "bt-us-stat-value" }, value)); - return (0, import_react36.createElement)( + 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_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-stat bt-us-stat-main" }, - (0, import_react36.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), - (0, import_react36.createElement)( + (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), + (0, import_react37.createElement)( "span", { className: "bt-us-stat-value" }, compact(shown), - (0, import_react36.createElement)("small", null, t("tokens")), - change === null ? null : (0, import_react36.createElement)( + (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)}%` @@ -15192,83 +15514,83 @@ function niceTop(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_react36.useState)(null); + 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_react36.createElement)( + return (0, import_react37.createElement)( "section", { className: "bt-us-card bt-us-chart", style: { "--i": 1 } }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-card-head" }, - (0, import_react36.createElement)("h3", null, t("Tokens over time"), (0, import_react36.createElement)("span", null, unitLabel(range.unit))), - onBy ? (0, import_react36.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null + (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 ), - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-chart-body" }, - (0, import_react36.createElement)( + (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_react36.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react36.createElement)("span", null, compact(top * level)))), - (0, import_react36.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react36.createElement)("span", null, "0")), - (0, import_react36.createElement)( + [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_react36.createElement)("div", { + 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_react36.createElement)( + }, 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_react36.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) - ) : (0, import_react36.createElement)("div", { className: "bt-us-stub" }))) + 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_react36.createElement)( + tip ? (0, import_react37.createElement)( "div", { className: "bt-us-tip", style: tipSide }, - (0, import_react36.createElement)("div", { className: "bt-us-tip-title" }, tip.title), - (0, import_react36.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_react36.createElement)( + (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_react36.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), - (0, import_react36.createElement)("span", null, tipOf.get(key)?.name), - (0, import_react36.createElement)("b", null, compact(value)) + (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_react36.createElement)( + (0, import_react37.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_react36.createElement)( + 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 : "" )) ) ), - series.length > 1 || series[0]?.pick ? (0, import_react36.createElement)( + series.length > 1 || series[0]?.pick ? (0, import_react37.createElement)( "div", { className: "bt-us-legend" }, - series.map((entry) => (0, import_react36.createElement)(entry.pick ? "button" : "span", { + 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_react36.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) + }, (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) ) : null ); } function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick }) { const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0; - return (0, import_react36.createElement)( + return (0, import_react37.createElement)( onClick ? "button" : "div", { className: "bt-us-row", @@ -15277,69 +15599,69 @@ function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick title: t("{tokens} tokens · {calls} calls", { tokens: full(sum.total), calls: full(sum.calls) }), ...onClick ? { type: "button", onClick } : {} }, - glyph ? (0, import_react36.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, - (0, import_react36.createElement)( + 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_react36.createElement)( + (0, import_react37.createElement)( "span", { className: "bt-us-row-line" }, - (0, import_react36.createElement)("span", { className: "bt-us-row-name" }, name), - sub ? (0, import_react36.createElement)("span", { className: "bt-us-row-sub" }, sub) : null + (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_react36.createElement)("span", { className: "bt-us-track" }, (0, import_react36.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) + (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ), - (0, import_react36.createElement)("span", { className: "bt-us-row-num" }, (0, import_react36.createElement)("b", null, compact(sum.total)), (0, import_react36.createElement)("small", null, `${percent(sum.total, whole)}%`)) + (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_react36.createElement)( + return (0, import_react37.createElement)( "section", { className: "bt-us-card", style: { "--i": index } }, - (0, import_react36.createElement)("div", { className: "bt-us-card-head" }, (0, import_react36.createElement)("h3", null, title, count === void 0 ? null : (0, import_react36.createElement)("span", null, count))), - (0, import_react36.createElement)("div", { className: "bt-us-rows" }, children) + (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_react36.createElement)( + return (0, import_react37.createElement)( "section", { className: "bt-us-profile" }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "span", { className: "bt-us-ring" }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "svg", { viewBox: "0 0 96 96", "aria-hidden": true }, - face.stops ? (0, import_react36.createElement)("defs", null, (0, import_react36.createElement)( + 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_react36.createElement)("stop", { key: offset, offset, stopColor: color })) + face.stops.map(([offset, color]) => (0, import_react37.createElement)("stop", { key: offset, offset, stopColor: color })) )) : null, - (0, import_react36.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), - arc > 0 ? (0, import_react36.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)("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_react36.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) + (0, import_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ), - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-profile-copy" }, - (0, import_react36.createElement)("h3", null, face.name), - face.sub ? (0, import_react36.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, - (0, import_react36.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) })) + (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) })) ) ); } 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_react36.useRef)(null); + const box = (0, import_react37.useRef)(null); const todayMs = hourMs(today); const thisMonth = monthOf(todayMs); - const [right, setRight] = (0, import_react36.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); - const [anchor, setAnchor] = (0, import_react36.useState)(null); - const [hover, setHover] = (0, import_react36.useState)(null); - (0, import_react36.useEffect)(() => { + 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(); }; @@ -15365,17 +15687,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; - const weekdays = (0, import_react36.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); + 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_react36.createElement)("span", { key: `b${at}` })); + 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_react36.createElement)("button", { + cells.push((0, import_react37.createElement)("button", { key, type: "button", className: "bt-us-day", @@ -15391,15 +15713,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) - }, (0, import_react36.createElement)("b", null, day))); + }, (0, import_react37.createElement)("b", null, day))); } - const nav = side === "left" ? (0, import_react36.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_react36.createElement)(ChevronLeftIcon)) : (0, import_react36.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_react36.createElement)(ChevronRightIcon)); - return (0, import_react36.createElement)( + 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_react36.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react36.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), - (0, import_react36.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react36.createElement)("span", { key: at }, name))), - (0, import_react36.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) + (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 = [ @@ -15408,30 +15730,30 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { ["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_react36.createElement)( + return (0, import_react37.createElement)( "div", { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-pick-quick" }, - shortcuts.map(([id, label, from, to]) => (0, import_react36.createElement)("button", { + 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)) ), - (0, import_react36.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), - (0, import_react36.createElement)( + (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_react36.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), - low === null ? null : (0, import_react36.createElement)("span", null, (0, import_react36.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) + (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)}`) ) ); } function Heat({ view, keep, paint, index }) { - const [hover, setHover] = (0, import_react36.useState)(null); + 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); @@ -15447,13 +15769,13 @@ function Heat({ view, keep, paint, index }) { 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_react36.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); + 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_react36.createElement)("span", { + cells.push((0, import_react37.createElement)("span", { key: ms, className: "bt-us-cell", style: { "--w": week }, @@ -15461,7 +15783,7 @@ function Heat({ view, keep, paint, index }) { "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_react36.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); + }, level > 0 ? (0, import_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); } } const tipSide = hover ? { @@ -15470,61 +15792,61 @@ function Heat({ view, keep, paint, index }) { } : 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_react36.createElement)( + return (0, import_react37.createElement)( "section", { className: "bt-us-card bt-us-heat", style: { "--i": index } }, - (0, import_react36.createElement)("div", { className: "bt-us-card-head" }, (0, import_react36.createElement)("h3", null, t("Activity")), (0, import_react36.createElement)("span", { className: "bt-us-head-note" }, summary)), - (0, import_react36.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), - (0, import_react36.createElement)( + (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_react36.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), - hover ? (0, import_react36.createElement)( + (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_react36.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), - (0, import_react36.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) })) + (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_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-heat-scale", "aria-hidden": true }, t("Light"), - levels.map((level) => (0, import_react36.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react36.createElement)("i", { style: { background: soften(paint, level) } }))), + 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_react36.createElement)( + return (0, import_react37.createElement)( "div", { className: "bt-us", "aria-busy": true }, - (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), - (0, import_react36.createElement)( + (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_react36.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), - (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), - (0, import_react36.createElement)( + (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_react36.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), - (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) + (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_react36.useState)(null); - const [error, setError] = (0, import_react36.useState)(""); - const [rangeId, setRangeId] = (0, import_react36.useState)("7d"); - const [span, setSpan] = (0, import_react36.useState)(null); - const [picking, setPicking] = (0, import_react36.useState)(false); - const [by, setBy] = (0, import_react36.useState)("bot"); - const tabs = (0, import_react36.useRef)(null); + 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_react36.useEffect)(() => { + (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(() => { @@ -15534,7 +15856,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; - (0, import_react36.startTransition)(() => setView(value)); + (0, import_react37.startTransition)(() => setView(value)); setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { @@ -15551,7 +15873,7 @@ function UsagePage({ roster, actions, focusId }) { }, []); 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_react36.useMemo)(() => { + const shaped = (0, import_react37.useMemo)(() => { if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); @@ -15560,7 +15882,7 @@ function UsagePage({ roster, actions, focusId }) { 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_react36.createElement)(ModelGlyph, { name, paint, size }) }; + 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; @@ -15608,23 +15930,23 @@ function UsagePage({ roster, actions, focusId }) { }); 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_react36.useRef)(null); - (0, import_react36.useEffect)(() => { + 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_react36.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react36.createElement)(Skeleton2); + 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_react36.createElement)( + return (0, import_react37.createElement)( "div", { className: "bt-us" }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-empty" }, - (0, import_react36.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), - (0, import_react36.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), - (0, import_react36.createElement)("span", null, t("Token usage shows up here after a model is called.")) + (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.")) ) ); } @@ -15649,7 +15971,7 @@ function UsagePage({ roster, actions, focusId }) { ["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_react36.createElement)(Row2, { + const ownerRow = ([at, sum], index, total = whole) => (0, import_react37.createElement)(Row2, { key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), @@ -15661,7 +15983,7 @@ function UsagePage({ roster, actions, focusId }) { 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_react36.createElement)(Row2, { + const modelRow = ([at, sum], index) => (0, import_react37.createElement)(Row2, { key: `m${at}`, kind: "model", glyph: models[at].glyph(32), @@ -15673,30 +15995,30 @@ function UsagePage({ roster, actions, focusId }) { 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_react36.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); + 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_react36.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), - (0, import_react36.createElement)( + (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_react36.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), - (0, import_react36.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), - (0, import_react36.createElement)( + (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_react36.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react36.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_react36.createElement)( + 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_react36.createElement)(Row2, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing + kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react37.createElement)(Row2, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing ) ), - (0, import_react36.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) + (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ) ]; } else { @@ -15705,39 +16027,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { key: "sheet", className: "bt-us-sheet" }, - (0, import_react36.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), - (0, import_react36.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), - (0, import_react36.createElement)( + (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_react36.createElement)( + (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_react36.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, + 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_react36.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) + (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) ), - (0, import_react36.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) + (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ) ]; } - return (0, import_react36.createElement)( + return (0, import_react37.createElement)( "div", { ref: root, className: "bt-us", key: focusId ?? "all" }, - (0, import_react36.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-us-top" }, - (0, import_react36.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), - view.scanning ? (0, import_react36.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, - picking ? (0, import_react36.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null + (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_react36.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, + error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, body ); } @@ -15894,7 +16216,7 @@ button.bt-us-row:hover{background:var(--bt-hover)} `; // src/client/connectors.js -var import_react37 = require("react"); +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"; @@ -15928,17 +16250,17 @@ var APPS = [ ["youtube", "YouTube"] ]; var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug; -var GitHubMark = () => (0, import_react37.createElement)( +var GitHubMark = () => (0, import_react38.createElement)( "svg", { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react37.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" }) + (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_react37.createElement)( +var FeishuMark = () => (0, import_react38.createElement)( "svg", { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, - (0, import_react37.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_react37.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_react37.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" }) + (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 = { @@ -15958,11 +16280,11 @@ function statusText2(connector) { } function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { const label = t(credential.label); - if (credential.masked) return (0, import_react37.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); - return (0, import_react37.createElement)( + 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_react37.createElement)("input", { + (0, import_react38.createElement)("input", { className: "bt-input", type: "text", value, @@ -15978,31 +16300,31 @@ function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { ); } function AllowedSenders({ connector, actions, run, busy }) { - const [openId, setOpenId] = (0, import_react37.useState)(""); + 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_react37.createElement)( + return (0, import_react38.createElement)( "div", { className: "bt-connector-allowed" }, - (0, import_react37.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), - allowed.length === 0 && refused.length === 0 ? (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, - allowed.map((entry) => (0, import_react37.createElement)( + (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_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), - (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) + (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_react37.createElement)( + refused.map((entry) => (0, import_react38.createElement)( "div", { key: entry.openId, className: "bt-connector-person" }, - (0, import_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), - (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) + (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_react37.createElement)( + (0, import_react38.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react37.createElement)("input", { + (0, import_react38.createElement)("input", { className: "bt-input", type: "text", value: openId, @@ -16017,7 +16339,7 @@ function AllowedSenders({ connector, actions, run, busy }) { } } }), - (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { + (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { allow(openId.trim()); setOpenId(""); } }, t("Allow")) @@ -16027,20 +16349,20 @@ function AllowedSenders({ connector, actions, run, busy }) { 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_react37.createElement)( + 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_react37.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null + label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null ); } function AppPicker({ apps, onChange, search, statuses = {} }) { - const [query, setQuery] = (0, import_react37.useState)(""); - const [found, setFound] = (0, import_react37.useState)(null); - const [failed, setFailed] = (0, import_react37.useState)(null); - const names = (0, import_react37.useRef)(new Map(APPS)); - const asked = (0, import_react37.useRef)(0); - (0, import_react37.useEffect)(() => { + 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 text = query.trim(); if (text === "" || search === void 0) { setFound(null); @@ -16067,13 +16389,13 @@ function AppPicker({ apps, onChange, search, statuses = {} }) { 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_react37.createElement)( + return (0, import_react38.createElement)( "div", { className: "bt-connector-apps" }, - apps.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react37.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, - (0, import_react37.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_react37.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, - (0, import_react37.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react37.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => { + 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))) @@ -16081,22 +16403,22 @@ function AppPicker({ apps, onChange, search, statuses = {} }) { } function AppStatusList({ apps, statuses }) { if (apps.length === 0 || Object.keys(statuses).length === 0) return null; - return (0, import_react37.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react37.createElement)( + 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_react37.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), - (0, import_react37.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") + (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]]?.() ?? "") ))); } function ConnectorRow({ connector, roster, actions }) { - const [form, setForm] = (0, import_react37.useState)(false); - const [bots, setBots] = (0, import_react37.useState)(false); - const [token, setToken] = (0, import_react37.useState)(""); - const [apps, setApps] = (0, import_react37.useState)(() => connector.apps ?? []); - const [picking, setPicking] = (0, import_react37.useState)(false); - const [busy, setBusy] = (0, import_react37.useState)(false); - const [error, setError] = (0, import_react37.useState)(null); - const [message, setMessage] = (0, import_react37.useState)(""); + 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); @@ -16108,15 +16430,15 @@ function ConnectorRow({ connector, roster, actions }) { const sender = connector.kind === "sender"; const channel = connector.kind === "channel"; const modes = connector.modes ?? []; - const [mode, setMode] = (0, import_react37.useState)(() => connector.mode ?? modes[0]?.id); - const [values, setValues] = (0, import_react37.useState)({}); + 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 field2 = (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_react37.useState)({}); + const filled = chosen !== void 0 && chosen.credentials.every((credential) => field2(credential.name).trim() !== ""); + const [statuses, setStatuses] = (0, import_react38.useState)({}); const appsKey = (connector.apps ?? []).join(","); - (0, import_react37.useEffect)(() => { + (0, import_react38.useEffect)(() => { if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) { setStatuses({}); return void 0; @@ -16131,12 +16453,12 @@ function ConnectorRow({ connector, roster, actions }) { live = false; }; }, [gateway, connector.state, appsKey, picking]); - const search = (0, import_react37.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); + 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)])); + const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field2(credential.name)])); void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => { setValues({}); setForm(false); @@ -16165,52 +16487,52 @@ function ConnectorRow({ connector, roster, actions }) { const canPost = sender || channel && connector.mode === "webhook"; const perBot = !sender && (!channel || connector.mode === "app"); const Mark = MARKS[connector.id]; - const row = (0, import_react37.useRef)(null); + const row = (0, import_react38.useRef)(null); useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); - return (0, import_react37.createElement)( + return (0, import_react38.createElement)( "div", { ref: row, className: "bt-connector", "data-state": connector.state }, - (0, import_react37.createElement)( + (0, import_react38.createElement)( "div", { className: "bt-connector-head" }, - (0, import_react37.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react37.createElement)(Mark) : null), - (0, import_react37.createElement)( + (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_react37.createElement)("span", { className: "bt-connector-name" }, connector.name), - (0, import_react37.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) + (0, import_react38.createElement)("span", { className: "bt-connector-name" }, connector.name), + (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ), - (0, import_react37.createElement)( + (0, import_react38.createElement)( "span", { className: "bt-connector-actions" }, - connector.state === "error" ? (0, import_react37.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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + 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_react37.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_react37.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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) + 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_react37.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, - on && perBot ? (0, import_react37.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, - on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, - on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, - on && canPost ? (0, import_react37.createElement)( + 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_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.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_react37.createElement)( + (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)( "div", { className: "bt-connector-form-row" }, - (0, import_react37.createElement)("span", { className: "bt-connector-link" }), - (0, import_react37.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")) + (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_react37.createElement)( + !on && form ? (0, import_react38.createElement)( "div", { className: "bt-connector-form" }, - channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { + 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, type: "button", className: "bt-state", @@ -16221,67 +16543,67 @@ function ConnectorRow({ connector, roster, actions }) { setError(null); } }, t(entry.label)))) : null, - channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react37.createElement)(CredentialField, { + channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, { key: credential.name, credential, - value: field(credential.name), + value: field2(credential.name), onChange: (value) => setField(credential.name, value), onEnter: connect, autoFocus: index === 0 - })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), - gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, - (0, import_react37.createElement)( + })) : (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_react37.createElement)( + (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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (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_react37.createElement)( + on && gateway && picking ? (0, import_react38.createElement)( "div", { className: "bt-connector-form" }, - (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), - (0, import_react37.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_react37.createElement)( + (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_react37.createElement)("span", { className: "bt-connector-link" }), - (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) + (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_react37.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : 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_react37.createElement)( + return (0, import_react38.createElement)( "section", { className: "bt-set-section" }, - (0, import_react37.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react37.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions }))) + (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 }))) ); } // src/client/feedback.js -var import_react38 = require("react"); -var FeedbackIcon = () => (0, import_react38.createElement)( +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_react38.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_react38.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) + (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, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); @@ -16304,16 +16626,16 @@ function entrySummary(entry) { } var SOURCES = { turn: "Model call", host: "FlyBot", api: "FlyBot", browser: "This window" }; function FeedbackPage({ actions, run, Section, Row: Row3 }) { - const [what, setWhat] = (0, import_react38.useState)(""); - const [report, setReport] = (0, import_react38.useState)(null); - const [preview, setPreview] = (0, import_react38.useState)(false); - const [note, setNote] = (0, import_react38.useState)(""); + 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_react38.useEffect)(() => { + (0, import_react39.useEffect)(() => { void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { @@ -16321,7 +16643,7 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { 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_react38.useState)(null); + const [issueLink, setIssueLink] = (0, import_react39.useState)(null); const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; @@ -16349,59 +16671,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ - (0, import_react38.createElement)( + (0, import_react39.createElement)( Section, { key: "report", title: t("Report a problem") }, - (0, import_react38.createElement)( + (0, import_react39.createElement)( "div", { className: "bt-set-row bt-set-paste bt-fb-what" }, - (0, import_react38.createElement)( + (0, import_react39.createElement)( "label", { className: "bt-set-copy", htmlFor: "bt-fb-what" }, - (0, import_react38.createElement)("span", null, t("What happened?")), - (0, import_react38.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)("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_react38.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)("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_react38.createElement)( + (0, import_react39.createElement)( Row3, { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") }, - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy")) + (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_react38.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react38.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, - (0, import_react38.createElement)( + 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)( Row3, { 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_react38.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) + (0, import_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ) ), - note ? (0, import_react38.createElement)( + note ? (0, import_react39.createElement)( "div", { key: "note", className: "bt-note bt-fb-note", role: "status" }, note, - issueLink ? [" ", (0, import_react38.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null + issueLink ? [" ", (0, import_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ) : null, - (0, import_react38.createElement)( + (0, import_react39.createElement)( Section, { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") }, - shown.length === 0 ? (0, import_react38.createElement)(Row3, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react38.createElement)( + shown.length === 0 ? (0, import_react39.createElement)(Row3, { 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_react38.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), - (0, import_react38.createElement)( + (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_react38.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), - (0, import_react38.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · ")) + (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_react38.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null + entry.code ? (0, import_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null )), - (0, import_react38.createElement)( + (0, import_react39.createElement)( Row3, { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") }, - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) + (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")) ) ) ]; @@ -16409,8 +16731,8 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { var TOAST_MS = 6e3; function ErrorToast({ useUi, actions }) { const toast = useUi((state) => state.toast); - const [leaving, setLeaving] = (0, import_react38.useState)(false); - (0, import_react38.useEffect)(() => { + 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); @@ -16422,15 +16744,15 @@ function ErrorToast({ useUi, actions }) { }, [toast?.token]); if (!toast) return null; const info = toast.tone === "info"; - return (0, import_react38.createElement)( + return (0, import_react39.createElement)( "div", { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 }, - (0, import_react38.createElement)("span", { className: "bt-toast-text" }, toast.text), - info ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { + (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_react38.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react38.createElement)(CloseIcon)) + (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)) ); } var FEEDBACK_CSS = ` @@ -16461,7 +16783,7 @@ var FEEDBACK_CSS = ` `; // src/client/about.js -var import_react39 = require("react"); +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…"); @@ -16496,9 +16818,9 @@ function dshHint(dsh, shown) { 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: Row3 }) { const update = useUpdate((value) => value) ?? {}; - const [checking, setChecking] = (0, import_react39.useState)(false); - const [stars, setStars] = (0, import_react39.useState)(null); - (0, import_react39.useEffect)(() => { + 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); @@ -16519,29 +16841,29 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { 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_react39.createElement)( + 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_react39.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_react39.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_react39.createElement)( + ) : (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_react39.createElement)( + (0, import_react40.createElement)( "div", { key: "hero", className: "bt-about-hero" }, - (0, import_react39.createElement)("span", { className: "bt-about-mark" }, (0, import_react39.createElement)(FlyBotMark, { size: 52 })), - (0, import_react39.createElement)( + (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_react39.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), - update.current ? (0, import_react39.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null + (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_react39.createElement)( + repo ? (0, import_react40.createElement)( "a", { className: "bt-star", @@ -16551,23 +16873,23 @@ function AboutPage({ useUpdate, actions, run, Section, Row: Row3 }) { 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_react39.createElement)("span", { className: "bt-star-main" }, (0, import_react39.createElement)("span", { className: "bt-star-icon" }, (0, import_react39.createElement)(StarLineIcon)), "Star"), - stars === null ? null : (0, import_react39.createElement)("span", { className: "bt-star-count" }, starCount(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_react39.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_react39.createElement)( + 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_react39.createElement)(Row3, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react39.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null), - dsh?.current ? (0, import_react39.createElement)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null + (0, import_react40.createElement)(Row3, { 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)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null ), - (0, import_react39.createElement)( + (0, import_react40.createElement)( Section, { key: "links", title: t("Links") }, - repo ? (0, import_react39.createElement)(Row3, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react39.createElement)("a", linkProps(repo), (0, import_react39.createElement)(GitHubMark), t("Open"))) : null, - update.notesUrl ? (0, import_react39.createElement)(Row3, { label: t("Release notes") }, (0, import_react39.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, - (0, import_react39.createElement)(Row3, { label: t("Report a problem") }, (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) + repo ? (0, import_react40.createElement)(Row3, { 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)(Row3, { label: t("Release notes") }, (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, + (0, import_react40.createElement)(Row3, { label: t("Report a problem") }, (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) ) ]; } @@ -16596,19 +16918,19 @@ var ABOUT_CSS = ` `; // src/client/settings.js -var import_react40 = require("react"); +var import_react41 = require("react"); var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1); -var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; -var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; +var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; +var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; function SetSection({ title, children }) { - return (0, import_react40.createElement)("section", { className: "bt-set-section" }, (0, import_react40.createElement)("h3", null, title), (0, import_react40.createElement)("div", { className: "bt-set-card" }, 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 SetRow({ label, hint, children }) { - return (0, import_react40.createElement)( + return (0, import_react41.createElement)( "div", { className: "bt-set-row" }, - (0, import_react40.createElement)("div", { className: "bt-set-copy" }, (0, import_react40.createElement)("span", null, label), hint ? (0, import_react40.createElement)("span", { className: "bt-set-hint" }, hint) : null), - children ? (0, import_react40.createElement)("div", { className: "bt-set-control" }, children) : null + (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 ); } var NOTIFY_KINDS = [ @@ -16620,35 +16942,35 @@ 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_react40.useState)(prefs.accent ?? "#4d6bfe"); - const timer = (0, import_react40.useRef)(0); - (0, import_react40.useEffect)(() => () => clearTimeout(timer.current), []); + 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_react40.createElement)( + (0, import_react41.createElement)( SetSection, { key: "look", title: t("Appearance") }, - (0, import_react40.createElement)( + (0, import_react41.createElement)( SetRow, { label: t("Theme"), hint: t("Colors for the whole team, on every device.") }, - (0, import_react40.createElement)( + (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_react40.createElement)("option", { key: id, value: id }, t(theme.label))) + Object.entries(themes).map(([id, theme]) => (0, import_react41.createElement)("option", { key: id, value: id }, t(theme.label))) ) ), - (0, import_react40.createElement)( + (0, import_react41.createElement)( SetRow, { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") }, - (0, import_react40.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-swatches" }, - (0, import_react40.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_react40.createElement)("button", { + (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", @@ -16658,26 +16980,26 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), - (0, import_react40.createElement)( + (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_react40.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) + (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_react40.createElement)( + (0, import_react41.createElement)( SetSection, { key: "notify", title: t("Notifications") }, - (0, import_react40.createElement)(SwitchRow, { + (0, import_react41.createElement)(SwitchRow, { label: t("Notifications"), showLabel: false, 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_react40.createElement)(SwitchRow, { + NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react41.createElement)(SwitchRow, { key: kind, label: t(label), hint: hint ? t(hint) : void 0, @@ -16693,52 +17015,52 @@ function MainBotsSection({ roster, actions, run }) { const [first, ...others] = mains; const own = roster.bots.filter((bot) => !bot.remote); const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); - return (0, import_react40.createElement)( + return (0, import_react41.createElement)( SetSection, { title: t("Main Bot") }, - (0, import_react40.createElement)( + (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_react40.createElement)("select", { + (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_react40.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react40.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) + }, 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_react40.createElement)( + others.map((bot) => (0, import_react41.createElement)( SetRow, { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") }, - (0, import_react40.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_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role")) + (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_react40.createElement)( + 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_react40.createElement)("select", { + (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_react40.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react40.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) + }, (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 ); } 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_react40.createElement)( + return (0, import_react41.createElement)( SetSection, { title: t("Animation") }, - (0, import_react40.createElement)( + (0, import_react41.createElement)( SetRow, { label: t("Bot animation") }, - (0, import_react40.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react40.createElement)("button", { + (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, @@ -16750,69 +17072,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react40.useState)(false); + const [creating, setCreating] = (0, import_react41.useState)(false); return [ - (0, import_react40.createElement)(MainBotsSection, { key: "main", roster, actions, run }), - (0, import_react40.createElement)(MotionSection, { key: "motion", roster, actions, run }), - creating ? (0, import_react40.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react40.createElement)( + (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_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react40.createElement)(PlusIcon), t("New Bot")) + (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_react40.createElement)( + (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_react40.createElement)( + 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_react40.createElement)( + (0, import_react41.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react40.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), - (0, import_react40.createElement)("span", { className: "bt-row-title" }, bot.name), - bot.role ? (0, import_react40.createElement)("span", { className: "bt-tag" }, bot.role) : null, - isMainOf(roster, bot.id) ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, - bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null + (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_react40.createElement)("span", { className: "bt-chevron" }, (0, import_react40.createElement)(ChevronRightIcon)) + (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) )) ) ]; } function GroupsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react40.useState)(false); + const [creating, setCreating] = (0, import_react41.useState)(false); return [ - creating ? (0, import_react40.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react40.createElement)( + 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_react40.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_react40.createElement)(PlusIcon), t("Create group chat")) + (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_react40.createElement)( + (0, import_react41.createElement)( SetSection, { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) }, - roster.rooms.length === 0 ? (0, import_react40.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) => { + 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_react40.createElement)( + return (0, import_react41.createElement)( "button", { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) }, - (0, import_react40.createElement)( + (0, import_react41.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react40.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), - (0, import_react40.createElement)("span", { className: "bt-row-title" }, room2.name), - (0, import_react40.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })), - admin ? (0, import_react40.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react40.createElement)(AdminStar), admin.name) : null, - room2.hidden ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null + (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_react40.createElement)("span", { className: "bt-chevron" }, (0, import_react40.createElement)(ChevronRightIcon)) + (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) ); }) ) ]; } function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) { - const [error, setError] = (0, import_react40.useState)(""); - const close = (0, import_react40.useCallback)(() => actions.closeSettings(), []); + 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(""); @@ -16833,34 +17155,34 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false const nav = useGlide(shown, { axis: "y" }); const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`; const depth = bot || room2 ? 1 : 0; - const last = (0, import_react40.useRef)({ key: pageKey, depth, move: "up" }); + 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_react40.useState)(shown === "usage"); + const [usageOpened, setUsageOpened] = (0, import_react41.useState)(shown === "usage"); if (shown === "usage" && !usageOpened) setUsageOpened(true); - const errorLine = error ? (0, import_react40.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; - return (0, import_react40.createElement)( + 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_react40.createElement)("div", { className: "bt-scrim", onMouseDown: close }), - (0, import_react40.createElement)( + (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_react40.createElement)( + (0, import_react41.createElement)( "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, - (0, import_react40.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - pages.map(([key, text]) => (0, import_react40.createElement)("button", { + (0, import_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), + pages.map(([key, text]) => (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_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + }, (0, import_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), // 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_react40.createElement)("button", { + (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"), @@ -16869,51 +17191,52 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false close(); openHarnessSettings(); } - }, (0, import_react40.createElement)(SlidersIcon), t("Harness settings"), (0, import_react40.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) + }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ), - (0, import_react40.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-settings-main" }, - (0, import_react40.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-settings-head" }, - back ? (0, import_react40.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react40.createElement)(ChevronLeftIcon)) : null, - (0, import_react40.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) + 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_react40.createElement)( + 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_react40.createElement)(GeneralPage, { roster, actions, run }) : null, - bot ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, - shown === "bots" && !bot ? (0, import_react40.createElement)(BotsPage, { roster, actions, run }) : null, - room2 ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, - shown === "groups" && !room2 ? (0, import_react40.createElement)(GroupsPage, { roster, actions, run }) : null, - shown === "connectors" ? (0, import_react40.createElement)(ConnectorsPage, { roster, actions }) : null, - pluginPage ? (0, import_react40.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, - shown === "capabilities" ? (0, import_react40.createElement)(CapabilitiesPage, { roster, actions, run }) : null, - shown === "schedules" ? (0, import_react40.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, - shown === "secrets" ? (0, import_react40.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, - shown === "feedback" ? (0, import_react40.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, - shown === "about" ? (0, import_react40.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null + 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 === "connectors" ? (0, import_react41.createElement)(ConnectorsPage, { roster, actions }) : null, + shown === "workflows" ? (0, import_react41.createElement)(WorkflowsPage, { actions }) : 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_react40.createElement)( + 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_react40.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) + (0, import_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ) : null ), - (0, import_react40.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react40.createElement)(CloseIcon)) + (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ) ); } // src/client/context-menu.js -var import_react41 = require("react"); +var import_react42 = require("react"); function ContextMenu({ menu, roster, actions, leaving = false }) { useEscape(() => actions.closeMenu()); - (0, import_react41.useEffect)(() => { + (0, import_react42.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu(); }; @@ -16921,14 +17244,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { return () => window.removeEventListener("mousedown", onDown); }, []); const structure = useSidebarStructure(); - const ref = (0, import_react41.useRef)(null); - const [place, setPlace] = (0, import_react41.useState)({ left: menu.x, top: menu.y }); - (0, import_react41.useLayoutEffect)(() => { + 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_react41.createElement)(AgentMenu, { menu, roster, actions, leaving }); + 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; @@ -16970,14 +17293,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { ] ]; const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); - return (0, import_react41.createElement)( + 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_react41.createElement)( - import_react41.Fragment, + shown.map((group, index) => (0, import_react42.createElement)( + import_react42.Fragment, { key: index }, - index > 0 ? (0, import_react41.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react41.createElement)("button", { + 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, type: "button", role: "menuitem", @@ -16986,14 +17309,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } - }, (0, import_react41.createElement)(Icon), label)) + }, (0, import_react42.createElement)(Icon), label)) )) ); } function AgentMenu({ menu, roster, actions, leaving }) { - const ref = (0, import_react41.useRef)(null); - const [place, setPlace] = (0, import_react41.useState)({ left: menu.x, top: menu.y }); - (0, import_react41.useLayoutEffect)(() => { + 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)) }); @@ -17015,14 +17338,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; - return (0, import_react41.createElement)( + 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_react41.createElement)( - import_react41.Fragment, + groups.map((group, index) => (0, import_react42.createElement)( + import_react42.Fragment, { key: index }, - index > 0 ? (0, import_react41.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react41.createElement)("button", { + 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, type: "button", role: "menuitem", @@ -17031,7 +17354,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } - }, (0, import_react41.createElement)(Icon), label)) + }, (0, import_react42.createElement)(Icon), label)) )) ); } @@ -17117,7 +17440,7 @@ function migrateStorage(storage = globalThis.localStorage) { } // src/client/details-drawer.js -var import_react42 = require("react"); +var import_react43 = require("react"); function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; @@ -17142,10 +17465,10 @@ 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_react42.useState)(null); + const [pending, setPending] = (0, import_react43.useState)(null); const catalog = roster.permissions; const saved = bot.permission ?? catalog?.defaultPreset; - (0, import_react42.useEffect)(() => { + (0, import_react43.useEffect)(() => { if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; @@ -17155,14 +17478,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; - return (0, import_react42.createElement)( + return (0, import_react43.createElement)( "div", { className: "bt-drawer-permission" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Permissions")), - (0, import_react42.createElement)( + (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_react42.createElement)("button", { + catalog.options.map((option) => (0, import_react43.createElement)("button", { key: option.value, type: "button", "aria-pressed": option.value === current, @@ -17176,42 +17499,42 @@ function PermissionRow({ bot, roster, actions, run }) { function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) { const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); - (0, import_react42.useEffect)(() => { + (0, import_react43.useEffect)(() => { actions.browserEnsure?.(bot.id); }, [bot.id]); if (roster.browser !== true || !actions.browserShow) { - return (0, import_react42.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); + 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_react42.createElement)( + return (0, import_react43.createElement)( "div", { className: "bt-browser-empty-mark" }, - (0, import_react42.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), - (0, import_react42.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), - (0, import_react42.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_react42.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) + (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_react42.createElement)( + return (0, import_react43.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_react42.createElement)( + return (0, import_react43.createElement)( "div", { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } }, - (0, import_react42.createElement)( + (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_react42.createElement)( + page.excerpt ? (0, import_react43.createElement)( "span", { className: "bt-bcard-preview" }, - (0, import_react42.createElement)("span", { className: "bt-bcard-ptitle" }, label), - (0, import_react42.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) - ) : (0, import_react42.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react42.createElement)(GlobeIcon), (0, import_react42.createElement)("span", null, label)) + (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_react42.createElement)("button", { + (0, import_react43.createElement)("button", { type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), @@ -17220,36 +17543,36 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), - (0, import_react42.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-bcard-row" }, - (0, import_react42.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), - (0, import_react42.createElement)("span", { className: "bt-bcard-title" }, label), - page.url ? (0, import_react42.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null + (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_react42.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react42.createElement)(PlusIcon), t("New tab")) + (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")) ); } function About({ text, title = "About" }) { - const [open, setOpen] = (0, import_react42.useState)(false); + const [open, setOpen] = (0, import_react43.useState)(false); const long = text.split("\n").length > 5 || text.length > 240; - const body = (0, import_react42.useRef)(null); + const body = (0, import_react43.useRef)(null); useMorph(body, open); - return (0, import_react42.createElement)( + return (0, import_react43.createElement)( "div", null, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), - (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), - long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : 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 }, text), + long ? (0, import_react43.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_react42.useState)(room2.pendingApprovals ?? []); - const [busy, setBusy] = (0, import_react42.useState)(""); + 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_react42.useEffect)(() => { + (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; @@ -17275,28 +17598,28 @@ function SharedRoomApprovals({ room: room2 }) { setBusy(""); } }; - return (0, import_react42.createElement)( + return (0, import_react43.createElement)( "div", { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react42.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), - items.map((item) => (0, import_react42.createElement)( + (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_react42.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), - (0, import_react42.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), - (0, import_react42.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + (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_react42.createElement)( + return (0, import_react43.createElement)( "div", null, - (0, import_react42.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), - (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react42.createElement)(FacePersonRow, { + (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, @@ -17320,79 +17643,79 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti }; const face = room2.origin === "face-to-face"; return [ - (0, import_react42.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), - invited && room2.sharedFrom && !face ? (0, import_react42.createElement)( + (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_react42.createElement)("div", { className: "bt-section-title" }, title), - (0, import_react42.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_react42.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, - (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) - ) : face ? null : room2.notice ? (0, import_react42.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react42.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - face ? null : (0, import_react42.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), - face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react42.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react42.createElement)( + (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_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), - (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, [ - ...humans.map((person) => (0, import_react42.createElement)( + (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_react42.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-member" }, - (0, import_react42.createElement)("span", { className: "bt-member-name" }, (0, import_react42.createElement)("span", null, person.displayName || person.id)), - (0, import_react42.createElement)("span", { className: "bt-chip" }, t("Human")), - person.role === "admin" ? (0, import_react42.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + (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_react42.createElement)( + ...remotes.map((person) => (0, import_react43.createElement)( "div", { key: person.handle || person.id, role: "listitem" }, - (0, import_react42.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-member" }, - (0, import_react42.createElement)( + (0, import_react43.createElement)( "span", { className: "bt-member-name" }, - (0, import_react42.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - (0, import_react42.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) + (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_react42.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + 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_react42.createElement)( + return (0, import_react43.createElement)( "div", { key: member.id, role: "listitem" }, - (0, import_react42.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-member" }, - (0, import_react42.createElement)( + (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_react42.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), - (0, import_react42.createElement)("span", null, member.name) + (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_react42.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ), - entry ? (0, import_react42.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null + entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null ); }) ]) ), - face ? null : (0, import_react42.createElement)( + face ? null : (0, import_react43.createElement)( "div", { key: "mode" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Who answers")), - (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) + (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_react42.createElement)( + face || !usage ? null : (0, import_react43.createElement)( "div", { key: "usage" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, room2.name), (0, import_react42.createElement)("span", null, 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)) ) ]; } @@ -17400,15 +17723,15 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const owner = roster.byId[sessionId]; const part = owner ? currentPart(owner) : sessionId; const isMainBot = isMainOf(roster, owner?.id); - const [tab, setTab] = (0, import_react42.useState)("details"); + const [tab, setTab] = (0, import_react43.useState)("details"); const room2 = roster.roomsById[sessionId]; - const [view, setView] = (0, import_react42.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); - const [editing, setEditing] = (0, import_react42.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); - const [draftName, setDraftName] = (0, import_react42.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); - const [copied, setCopied] = (0, import_react42.useState)(false); - const [error, setError] = (0, import_react42.useState)(""); + 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_react42.useRef)({ view, tab, move: void 0 }); + 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; @@ -17433,43 +17756,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setTimeout(() => setCopied(false), 1600); }); }; - const errorLine = error ? (0, import_react42.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; + 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_react42.createElement)( + (0, import_react43.createElement)( "div", { key: "head", className: "bt-subhead", "data-move": move }, - (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react42.createElement)(ChevronLeftIcon)), - (0, import_react42.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), - hasStrip ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react42.createElement)(CloseIcon)) + (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_react42.createElement)( + (0, import_react43.createElement)( "div", { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, errorLine, - bot ? (0, import_react42.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react42.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) + 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_react42.createElement)( + const top = (0, import_react43.createElement)( "div", { className: "bt-drawer-top", "data-move": side }, - bot || room2 ? (0, import_react42.createElement)("button", { + 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_react42.createElement)(GearIcon)) : null, - bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react42.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_react42.createElement)(GlobeIcon)) : null, - bot || room2 ? (0, import_react42.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react42.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react42.createElement)(ShareIcon)) : null, - hasStrip ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react42.createElement)(CloseIcon)) + }, (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", "aria-label": copied ? t("Copied conversation") : t("Share 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)) ); if (!bot && !room2) { return [ - (0, import_react42.createElement)(import_react42.Fragment, { key: "top" }, top), - (0, import_react42.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react42.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) + (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."))) ]; } const current = bot?.name ?? room2.name; @@ -17483,12 +17806,12 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const subtitle = bot ? bot.role : roomSubtitle(room2, roster); const tabs = bot ? ["details", "browser"] : ["details"]; return [ - (0, import_react42.createElement)(import_react42.Fragment, { key: "top" }, top), - (0, import_react42.createElement)( + (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_react42.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react42.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - bot?.remote ? (0, import_react42.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react42.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react42.createElement)("input", { + 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, @@ -17503,59 +17826,59 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } - }) : (0, import_react42.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { + }) : (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_react42.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react42.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null + 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_react42.createElement)( + bot?.remote ? null : (0, import_react43.createElement)( "div", { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side }, - (0, import_react42.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - tabs.map((key) => (0, import_react42.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)("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_react42.createElement)( + (0, import_react43.createElement)( "div", { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move }, errorLine, - room2 && tab === "details" ? (0, import_react42.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, + 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_react42.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, - (0, import_react42.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) + 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_react42.createElement)( + (0, import_react43.createElement)( "div", { key: "model", className: "bt-drawer-model" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Model")), - (0, import_react42.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), - modelOf(bot, roster) ? null : (0, import_react42.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name })) + (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_react42.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), - (0, import_react42.createElement)( + (0, import_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), + (0, import_react43.createElement)( "div", { key: "folder", className: "bt-drawer-model" }, - (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Working folder")), - (0, import_react42.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) }) + (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_react42.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react42.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), - roster.memory === false ? null : (0, import_react42.createElement)(MemoryRow, { key: "memory", bot, actions }), - usage ? (0, import_react42.createElement)( + 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_react42.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, bot.name), (0, import_react42.createElement)("span", null, 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_react42.createElement)(ScheduleSummary, { key: "routines", bot, actions }) + (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ] : null, - bot && !bot.remote && tab === "browser" ? (0, import_react42.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null + bot && !bot.remote && tab === "browser" ? (0, import_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } // src/client/browser-pane.js -var import_react43 = require("react"); +var import_react44 = require("react"); var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; @@ -17565,10 +17888,10 @@ 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_react43.createElement)( + 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_react43.createElement)("path", { key: index, d })) + paths.map((d, index) => (0, import_react44.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"]); @@ -17580,9 +17903,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_react43.useState)(null); - if (!src || failed === src) return (0, import_react43.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react43.createElement)(GlobeIcon)); - return (0, import_react43.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); + 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 savedWidth = () => { try { @@ -17641,11 +17964,11 @@ var reducedMotion2 = () => typeof matchMedia === "function" && matchMedia("(pref 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_react43.useRef)(null); - const ind = (0, import_react43.useRef)(null); - const spot = (0, import_react43.useRef)(null); - const under = (0, import_react43.useRef)(null); - (0, import_react43.useLayoutEffect)(() => { + 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; @@ -17674,7 +17997,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { @@ -17688,13 +18011,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); - return (0, import_react43.createElement)( + return (0, import_react44.createElement)( "div", { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") }, - (0, import_react43.createElement)( + (0, import_react44.createElement)( "div", { className: "bt-ptabs-in", ref: wrap }, - (0, import_react43.createElement)( + (0, import_react44.createElement)( "button", { type: "button", @@ -17704,13 +18027,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, - (0, import_react43.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), - view === "details" ? (0, import_react43.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null + (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_react43.createElement)( + return (0, import_react44.createElement)( "button", { key: tabId, @@ -17731,9 +18054,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, - (0, import_react43.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }), - (0, import_react43.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)), - (0, import_react43.createElement)("span", { + (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"), @@ -17745,20 +18068,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), - (0, import_react43.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) + (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_react43.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react43.createElement)(PlusTabIcon)), - (0, import_react43.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_react43.createElement)(CloseIcon2)) + (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)) ); } function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) { const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE; - const frame = (0, import_react43.useRef)(null); - const input = (0, import_react43.useRef)(null); - const [draft, setDraft] = (0, import_react43.useState)(null); + 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_react43.useLayoutEffect)(() => { + (0, import_react44.useLayoutEffect)(() => { const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); @@ -17784,7 +18107,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); @@ -17800,27 +18123,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }; const editing = draft !== null; const secure = /^https:/i.test(page.url); - return (0, import_react43.createElement)( + return (0, import_react44.createElement)( "div", { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) }, - (0, import_react43.createElement)( + (0, import_react44.createElement)( "div", { className: "bt-browser-bar" }, - (0, import_react43.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_react43.createElement)(BackIcon)), - (0, import_react43.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_react43.createElement)(ForwardIcon)), - (0, import_react43.createElement)("button", { + (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_react43.createElement)(page.loading ? StopIcon : ReloadIcon)), - (0, import_react43.createElement)( + }, (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_react43.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react43.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), - (0, import_react43.createElement)("input", { + (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, @@ -17844,25 +18167,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), - page.loading ? (0, import_react43.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null + page.loading ? (0, import_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ), - (0, import_react43.createElement)( + (0, import_react44.createElement)( "div", { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 }, - page.error ? (0, import_react43.createElement)( + page.error ? (0, import_react44.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react43.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), - (0, import_react43.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), - page.url ? (0, import_react43.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => { + (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_react43.createElement)( + ) : !page.url ? (0, import_react44.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react43.createElement)(BotMark, { bot, size: 48, 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-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 })) + (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 ) ); @@ -17874,41 +18197,41 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu 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_react43.useRef)(null); - const fly = (0, import_react43.useRef)(null); - const [landed, setLanded] = (0, import_react43.useState)(false); - const [settling, setSettling] = (0, import_react43.useState)(false); - const [width, setWidth] = (0, import_react43.useState)(savedWidth); - const [viewport, setViewport] = (0, import_react43.useState)(() => window.innerWidth); - const [dragging, setDragging] = (0, import_react43.useState)(false); - const [resizing, setResizing] = (0, import_react43.useState)(false); + 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_react43.useState)(false); + const [reading, setReading] = (0, import_react44.useState)(false); const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport)); - (0, import_react43.useLayoutEffect)(() => { + (0, import_react44.useLayoutEffect)(() => { if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH); }, [view, width]); - (0, import_react43.useLayoutEffect)(() => { + (0, import_react44.useLayoutEffect)(() => { chatRoom.hold(instant); }, [instant]); - const [last, setLast] = (0, import_react43.useState)({ view, width: panelWidth }); + 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_react43.useRef)(view); - (0, import_react43.useLayoutEffect)(() => { + 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_react43.useEffect)(() => () => { + (0, import_react44.useEffect)(() => () => { chatRoom.set(null); chatRoom.hold(false); }, []); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { let timer; const onResize = () => { setResizing(true); @@ -17922,28 +18245,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); - (0, import_react43.useEffect)(() => { + (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_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { const timer = setTimeout(() => setLanded(true), 360); return () => clearTimeout(timer); }, []); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { if (canBrowse) actions.browserEnsure(bot.id); }, [bot?.id, canBrowse]); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details"); }, [view, active, tabState.ids.length]); - (0, import_react43.useEffect)(() => { + (0, import_react44.useEffect)(() => { if (view !== "browser" || leaving) void actions.browserShow?.(null); }, [view, leaving]); - (0, import_react43.useEffect)(() => () => { + (0, import_react44.useEffect)(() => () => { void actions.browserShow?.(null); }, []); - (0, import_react43.useEffect)(() => { + (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); @@ -17980,7 +18303,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } - (0, import_react43.useLayoutEffect)(() => { + (0, import_react44.useLayoutEffect)(() => { const move = fly.current; if (!move) return; fly.current = null; @@ -18063,7 +18386,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu for (const name of ends) grip.addEventListener(name, onEnd); }; const hasStrip = canBrowse && tabState.ids.length > 0; - return (0, import_react43.createElement)( + return (0, import_react44.createElement)( "aside", { ref: root, @@ -18080,15 +18403,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, - leaving ? null : (0, import_react43.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }), + 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_dom8.createPortal)((0, import_react43.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null, - hasStrip ? (0, import_react43.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null, - view === "browser" && bot ? (0, import_react43.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react43.createElement)( + dragging ? (0, import_react_dom8.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)( "div", { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" }, - (0, import_react43.createElement)(DetailsDrawer, { + (0, import_react44.createElement)(DetailsDrawer, { sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, @@ -18185,7 +18508,7 @@ var BROWSER_CSS = ` `; // src/client/remote-bot-settings.js -var import_react44 = require("react"); +var import_react45 = require("react"); var REMOTE_BOT_SETTINGS_CSS = ` .bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} .bt-remote-sheet-layer[data-leaving]{pointer-events:none} @@ -18209,8 +18532,8 @@ var REMOTE_BOT_SETTINGS_CSS = ` function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) { const close = () => actions.closeSettings(); useEscape(close); - const dialog = (0, import_react44.useRef)(null); - (0, import_react44.useEffect)(() => { + const dialog = (0, import_react45.useRef)(null); + (0, import_react45.useEffect)(() => { const before = document.activeElement; dialog.current?.focus?.({ preventScroll: true }); return () => { @@ -18225,37 +18548,37 @@ function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) { }; if (!bot?.remote) return null; const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot"); - return (0, import_react44.createElement)( + return (0, import_react45.createElement)( "div", { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" }, - (0, import_react44.createElement)("div", { className: "bt-scrim", onMouseDown: close }), - (0, import_react44.createElement)( + (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }), + (0, import_react45.createElement)( "div", { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown }, - (0, import_react44.createElement)( + (0, import_react45.createElement)( "div", { className: "bt-remote-sheet-head" }, - (0, import_react44.createElement)(BotMark, { bot, size: 40, live: true }), - (0, import_react44.createElement)( + (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }), + (0, import_react45.createElement)( "div", { className: "bt-remote-sheet-who" }, - (0, import_react44.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), - (0, import_react44.createElement)("span", { className: "bt-remote-sheet-sub" }, 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_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react44.createElement)(CloseIcon)) + (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_react44.createElement)( + (0, import_react45.createElement)( "div", { className: "bt-remote-sheet-body" }, - bot.remote.handle ? (0, import_react44.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, - (0, import_react44.createElement)(FlyBotDetails, { bot, roster, actions }) + 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 }) ) ) ); } // src/client/overlays.js -var import_react45 = require("react"); +var import_react46 = require("react"); var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; @@ -18264,15 +18587,15 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity 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_react45.useRef)(current); - (0, import_react45.useEffect)(() => { + 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_react45.useState)(open); - (0, import_react45.useEffect)(() => { + const [kept, setKept] = (0, import_react46.useState)(open); + (0, import_react46.useEffect)(() => { if (open) { setKept(open); return void 0; @@ -18288,20 +18611,20 @@ function Overlays({ useRoster, useUi, useSessions, useSessionStatus, useActivity 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_react45.createElement)( - import_react45.Fragment, + return (0, import_react46.createElement)( + import_react46.Fragment, null, - pane ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null, - ui2.newChat ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, - ui2.palette ? (0, import_react_dom9.createPortal)((0, import_react45.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_dom9.createPortal)((0, import_react45.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, - ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, - menu ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, - settings ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, - remoteBotSettings ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, - memory && roster.byId[memory] ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, - inbox ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, - board ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null + pane ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, + ui2.palette ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, + ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, + menu ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, + remoteBotSettings ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, + inbox ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, + board ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); } @@ -19256,7 +19579,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js -var import_react46 = require("react"); +var import_react47 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { @@ -19342,38 +19665,38 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - const chip = (part) => [(0, import_react46.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + const chip = (part) => [(0, import_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; return userTextParts(text, bots, referenceLabels).map((part, index) => { - if (!part.bot) return (0, import_react46.createElement)(import_react46.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + if (!part.bot) return (0, import_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); const title = `@${part.bot.name}`; - return typeof part.bot.id === "string" ? (0, import_react46.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react46.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); + return typeof part.bot.id === "string" ? (0, import_react47.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react47.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; - return (0, import_react46.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { + return (0, import_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { if (block.type === "image") { - return renderMessageImages ? (0, import_react46.createElement)(import_react46.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; + return renderMessageImages ? (0, import_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; } const file = block.attachment; const meta = [ui.fileExtension?.(file.name)?.toUpperCase().slice(0, 8), ui.fileSizeText?.(file.bytes)].filter(Boolean).join(" · "); - return (0, import_react46.createElement)( + return (0, import_react47.createElement)( "span", { key: index, className: "bt-ufile", title: file.name }, - ui.FileTypeIcon ? (0, import_react46.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, - (0, import_react46.createElement)("span", { className: "bt-ufile-text" }, (0, import_react46.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react46.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) + ui.FileTypeIcon ? (0, import_react47.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, + (0, import_react47.createElement)("span", { className: "bt-ufile-text" }, (0, import_react47.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react47.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ); })); } function CopyButton({ text }) { - const [copied, setCopied] = (0, import_react46.useState)(false); + const [copied, setCopied] = (0, import_react47.useState)(false); const copy2 = () => { void navigator.clipboard?.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1e3); }); }; - return (0, import_react46.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react46.createElement)(CheckIcon) : (0, import_react46.createElement)(CopyIcon)); + return (0, import_react47.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react47.createElement)(CheckIcon) : (0, import_react47.createElement)(CopyIcon)); } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { @@ -19390,7 +19713,7 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma const person = incoming && author.authorKind === "human" && !author.ownerName; const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; - return (0, import_react46.createElement)( + return (0, import_react47.createElement)( "div", { className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", @@ -19400,22 +19723,22 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma "data-author-kind": author?.authorKind, "data-author-name": author?.name }, - person ? (0, import_react46.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react46.createElement)(PersonMark, { name: author.name })) : null, - faceMine ? (0, import_react46.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react46.createElement)(PersonMark, { name: myName || void 0 })) : null, - (0, import_react46.createElement)( + person ? (0, import_react47.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react47.createElement)(PersonMark, { name: author.name })) : null, + faceMine ? (0, import_react47.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react47.createElement)(PersonMark, { name: myName || void 0 })) : null, + (0, import_react47.createElement)( "div", { className: "bt-ustack" }, - faceMine && myName ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react46.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, - attachments.length > 0 ? (0, import_react46.createElement)(Attachments, { attachments, renderMessageImages }) : null, - text !== "" || rest.length > 0 ? (0, import_react46.createElement)( + faceMine && myName ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react47.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, + attachments.length > 0 ? (0, import_react47.createElement)(Attachments, { attachments, renderMessageImages }) : null, + text !== "" || rest.length > 0 ? (0, import_react47.createElement)( "div", { className: "bt-ubody", title: clock3(data.time) }, - (0, import_react46.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), - rest.map((block, index) => ui.JsonBlock ? (0, import_react46.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) + (0, import_react47.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), + rest.map((block, index) => ui.JsonBlock ? (0, import_react47.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ) : null, - referenceLabels.length > 0 ? (0, import_react46.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null + referenceLabels.length > 0 ? (0, import_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ), - text === "" ? null : (0, import_react46.createElement)("div", { className: "bt-utools" }, (0, import_react46.createElement)(CopyButton, { text })) + text === "" ? null : (0, import_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text })) ); } var USER_MESSAGE_CSS = ` @@ -19513,7 +19836,7 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since } // src/client/stats-dock.js -var import_react47 = require("react"); +var import_react48 = require("react"); var STATS_DOCK = "conversation.composer.dock"; var STATS_ID = "stats"; var STATS_IDS = [STATS_ID, "activity"]; @@ -19529,17 +19852,17 @@ function pluralStats(t2) { function statsDock(entriesOf, id = STATS_ID) { const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); function StatsDock(props) { - const t2 = (0, import_react47.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); + const t2 = (0, import_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); const entry = shell(); if (!entry || typeof props.usePerformanceUsage !== "function") return null; - return (0, import_react47.createElement)(entry.component, { ...props, t: t2 }); + return (0, import_react48.createElement)(entry.component, { ...props, t: t2 }); } return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; } var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); // src/client/index.js -var import_react48 = require("react"); +var import_react49 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { const flybot = createClientApi2(); @@ -20291,6 +20614,8 @@ function apply(ctx) { wakeUpdate(); }; actions.repoStars = () => call("repo-stars"); + actions.workflows = (payload) => call("workflows", payload); + actions.workflowSave = (payload) => call("workflow-save", payload); actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; turnClock.set((state) => { @@ -20499,7 +20824,7 @@ function apply(ctx) { ]; ctx.effect(() => { const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); - const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react48.createElement)(TurnFailures, { ...props, work }) }); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); return () => { off(); style.remove(); diff --git a/core/index.js b/core/index.js index 993bd4e2b..7c256ec3e 100644 --- a/core/index.js +++ b/core/index.js @@ -42,6 +42,7 @@ import * as dshUpdate from './src/host/dsh-update.js' import * as hooks from './src/host/hooks.js' import * as remoteChat from './src/host/remote-chat.js' import * as deliveryBoard from './src/host/delivery-board.js' +import * as workflowStore from './src/host/workflow-store.js' import * as channel from './src/host/channel.js' export { COLORS } from './src/host/constants.js' @@ -53,7 +54,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, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, channel] +const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, workflowStore, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index eb5d5e227..1a8e49919 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -359,16 +359,47 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on // One repository's columns (#1265 step 4). The card key carries the repository, so the same issue // number in two repositories is two cards and neither is mistaken for the other. -function DeliveryColumns({ view, repo = null }) { +function DeliveryColumns({ view, repo = null, placed = null }) { const columns = view?.columns ?? {} + // A card a workflow section already draws is not drawn again here (#1272 step 3): the section owns + // it, and the five legacy columns keep every card that names no workflow. + const drawn = placed === null || placed === undefined ? null : new Set(placed) + const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) return h('div', { className: 'bt-del-columns' }, stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), - (columns[column.id] ?? []).length === 0 + h('span', { className: 'bt-board-count' }, String(cards(column.id).length))), + cards(column.id).length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) + : cards(column.id).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) +} + +// The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, +// the columns in the definition's own order, and every header naming the workflow it belongs to — so +// two workflows never share a column and a reader can always tell whose column it is. +function DeliveryWorkflowSections({ sections, repo = null }) { + const list = Array.isArray(sections) ? sections : [] + if (list.length === 0) return null + return h('div', { className: 'bt-del-workflows' }, list.map(section => h('section', { + key: section.id, className: 'bt-del-workflow', 'data-workflow': section.id, + }, + h('h3', { className: 'bt-del-workflow-name' }, + h('span', null, section.name), + h('span', { className: 'bt-wf-path' }, `workflows/${section.id}.json`)), + h('div', { className: 'bt-del-columns' }, section.columns.map(column => h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, + }, + h('h4', { className: 'bt-board-column-head' }, + h('span', null, column.label), + h('span', { className: 'bt-del-workflow-tag' }, section.name), + h('span', { className: 'bt-board-count' }, String(column.cards.length))), + column.cards.length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : column.cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))))), + (section.unplaced ?? []).length > 0 + ? h('p', { className: 'bt-del-hint' }, t('{count} card(s) name a node this workflow draws no column for.', { count: section.unplaced.length })) + : null))) } // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside @@ -383,7 +414,7 @@ function DeliveryRepoBlock({ entry }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed })) } export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { @@ -443,8 +474,13 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet ? h('p', { className: 'bt-del-partial', role: 'status' }, missing.map(entry => missingText(entry)).join(' · ')) : null, h(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), + // A workflow's own columns come first: a card that names a workflow is drawn there and not in + // the five columns below, which keep every card that names none (#1272 step 3). + entries === null + ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) + : null, entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : h('div', { className: 'bt-del-repos' }, entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) } diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 8397b2a75..58b4d4185 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -5,6 +5,32 @@ export const NAMESPACE = 'ds-bot' const ZH = { + // Workflows (#1272 step 3) + Workflows: '工作流', + 'A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.': + '工作流就是一个文件:workflows/.json。列、流程图和拒绝语都从它画出来。', + 'Factory presets': '出厂预设', + Yours: '你自己的', + 'project default': '项目默认', + 'Reading workflows…': '正在读取工作流…', + Customize: '定制一份', + Save: '保存', + 'Use for this project': '本项目使用', + 'This node has no guards, so nothing refuses it but its inputs.': '这个节点没有 guard,除了输入之外没有别的东西会拒绝它。', + Guards: 'Guard', + Edges: '边', + 'Guards of {node}': '{node} 的 Guard', + holds: '成立', + 'cannot read': '读不到', + refuses: '拒绝', + 'The flowchart, drawn from the node table': '流程图,按节点表绘制', + 'This host could not read the facts, so these guards say what they could not read — none of them is a pass.': + '这台主机读不到事实,所以这些 guard 只说读不到什么——没有一条算通过。', + 'Saved workflows/{id}.json': '已保存 workflows/{id}.json', + 'Could not open this workflow': '打不开这个工作流', + 'This definition cannot be saved': '这个定义不能保存', + 'This definition cannot be copied': '这个定义不能复制', + '{count} card(s) name a node this workflow draws no column for.': '有 {count} 张卡指向这个工作流不画列的节点。', // Shared 'Main Bot': '主 Bot', Working: '工作中', @@ -848,7 +874,6 @@ const ZH = { 'At least {count} characters.': '至少 {count} 个字符。', 'Replace value': '替换值', 'Saving…': '保存中…', - Save: '保存', 'Allowing…': '允许中…', Allow: '允许', '{bot} asked for {name}': '{bot} 请求了 {name}', diff --git a/core/src/client/icons.js b/core/src/client/icons.js index 65a6d85eb..916095e78 100644 --- a/core/src/client/icons.js +++ b/core/src/client/icons.js @@ -60,6 +60,8 @@ export const DownloadIcon = lineIcon(['M10 3.5v9', 'M6.5 9.5L10 13l3.5-3.5', 'M4 export const GaugeIcon = lineIcon(['M3.5 13.5a6.5 6.5 0 1 1 13 0', 'M10 13.5l3-4', 'M10 13.5h.01']) export const PlugIcon = lineIcon(['M7 3v4M13 3v4', 'M5 7h10v2.5a5 5 0 0 1-10 0z', 'M10 14.5V17']) export const CalendarIcon = lineIcon(['M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z', 'M4 8.75h12', 'M7.25 3.5v3M12.75 3.5v3']) +export const WorkflowIcon = lineIcon(['M4.5 4h4v3.5h-4z', 'M11.5 12.5h4V16h-4z', 'M6.5 7.5v3.5a2 2 0 0 0 2 2h3']) + export const ConnectorIcon = lineIcon(['M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M7 10h6']) export const SwitchIcon = lineIcon(['M4 7h9.5M10.5 3.5L14 7l-3.5 3.5', 'M16 13H6.5M9.5 9.5L6 13l3.5 3.5']) export const PaperclipIcon = lineIcon(['M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5']) diff --git a/core/src/client/index.js b/core/src/client/index.js index f92427636..3a6c433ca 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -727,6 +727,10 @@ export function apply(ctx) { wakeUpdate() } actions.repoStars = () => call('repo-stars') + // A workflow definition is read and written through the host (#1272 step 3): the page never writes + // a file or GitHub itself, and workflow-save is the endpoint authz allows. + actions.workflows = (payload) => call('workflows', payload) + actions.workflowSave = (payload) => call('workflow-save', payload) actions.noteTurnTime = (sessionId, turn, key, time) => { if (sessionId === undefined || !Number.isFinite(turn) || !Number.isFinite(time)) return turnClock.set((state) => { diff --git a/core/src/client/settings.js b/core/src/client/settings.js index b072ebaaf..f474b2227 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -2,6 +2,8 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're import { ACCENT_INKS, INKS } from './inks.js' import { isMainOf } from './sources.js' import { allThemes } from './themes.js' +import { WorkflowIcon } from './icons.js' +import { WorkflowsPage } from './workflows.js' import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' @@ -24,8 +26,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list // their members, and one opens on its own settings form. Usage widens the dialog. -const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] -const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } +const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] +const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) @@ -228,6 +230,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, + shown === 'workflows' ? h(WorkflowsPage, { actions }) : null, pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null, shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null, diff --git a/core/src/client/workflows.js b/core/src/client/workflows.js new file mode 100644 index 000000000..a40cda9bb --- /dev/null +++ b/core/src/client/workflows.js @@ -0,0 +1,228 @@ +// The Workflows page (#1272 step 3): the factory presets, the owner's own workflows, which one the +// project uses, and the node table that edits one. The page is a reader of the engine: the columns a +// definition draws, the flowchart's layout and the sentence a guard would print all come from +// `shared/workflow*.js` through the host, so nothing here re-decides what a workflow is. +// +// Three things the page is careful about: +// - a preset is copied, never edited: its fields are read-only and `Customize` is the only button +// that changes one into something editable; +// - a guard row shows the sentence this definition would print right now, or says the fact could +// not be read — a row with no sentence means the guard was read and holds, never a guess; +// - the flowchart is drawn from the table, so a box per node and an edge per edge row are what the +// page shows, not a second copy of the definition. +import { createElement as h, useEffect, useState } from 'react' +import { t } from './i18n.js' +import { WorkflowIcon } from './icons.js' + +// What `inputs` / `outputs` are: one comma-separated line, the spelling the definition itself uses. +export function parseList(text) { + return String(text ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +export const formatList = (list) => (Array.isArray(list) ? list.join(', ') : '') + +// A guard as the page writes it: `spec is value` — `check:tests-ok is fresh`, `verdict is equals PASS`. +// The id and the per-guard refusal are kept from the guard that was already there, so editing a +// value does not silently drop the sentence the definition refuses with. +export function guardLines(guards) { + return (Array.isArray(guards) ? guards : []) + .map((guard) => `${guard.spec} is ${guard.is}${guard.value === null || guard.value === undefined ? '' : ` ${guard.value}`}`) + .join('\n') +} + +export function parseGuards(text, previous = []) { + return String(text ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const found = /^(\S+)\s+is\s+(\S+)(?:\s+(.+))?$/.exec(line) + if (found === null) return null + const [, spec, is, value] = found + const before = previous.find((guard) => guard.spec === spec && guard.is === is) + return { + id: before?.id ?? `${spec}-${is}`, + spec, + is, + value: value === undefined ? null : value.trim(), + refusal: before?.refusal ?? '', + } + }) + .filter(Boolean) +} + +// One field of one node, from a text input. The node id is not one of them: changing it would leave +// every edge naming a node that no longer exists, and a definition that cannot be drawn is exactly +// what the save refuses. +export function applyNodeField(definition, nodeId, field, value) { + if (definition === null || definition === undefined) return definition + return { + ...definition, + nodes: definition.nodes.map((node) => { + if (node.id !== nodeId) return node + if (field === 'inputs' || field === 'outputs') return { ...node, [field]: parseList(value) } + if (field === 'guards') return { ...node, guards: parseGuards(value, node.guards) } + return { ...node, [field]: value } + }), + } +} + +const field = (label, value, onChange, options = {}) => h('label', { className: 'bt-wf-field', key: label }, + h('span', { className: 'bt-wf-label' }, label), + h('input', { + className: 'bt-wf-input', value: value ?? '', disabled: options.disabled === true, + placeholder: options.placeholder ?? '', onChange: (event) => onChange(event.target.value), + })) + +const guardState = (row) => (row.state === 'holds' ? t('holds') : row.state === 'unreadable' ? t('cannot read') : t('refuses')) + +function Flow({ layout }) { + if (!layout || !Array.isArray(layout.boxes) || layout.boxes.length === 0) return null + return h('svg', { + className: 'bt-wf-flow', viewBox: `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`, + role: 'img', 'aria-label': t('The flowchart, drawn from the node table'), + 'data-boxes': String(layout.boxes.length), 'data-edges': String(layout.edges.length), + }, + layout.edges.map((edge, index) => h('line', { + key: `e${index}`, className: 'bt-wf-edge', x1: edge.points?.[0]?.x ?? 0, y1: edge.points?.[0]?.y ?? 0, + x2: edge.points?.[1]?.x ?? 0, y2: edge.points?.[1]?.y ?? 0, + })), + layout.boxes.map((box) => h('g', { key: box.id, className: 'bt-wf-box', 'data-node': box.id, transform: `translate(${box.x} ${box.y})` }, + h('rect', { width: box.width, height: box.height, rx: 12 }), + h('text', { x: 14, y: 26, className: 'bt-wf-box-name' }, box.name), + h('text', { x: 14, y: 48, className: 'bt-wf-box-role' }, box.role), + h('text', { x: 14, y: 72, className: 'bt-wf-box-rails' }, `${box.inputs.join(', ') || '—'} → ${box.outputs.join(', ') || '—'}`)))) +} + +function Preview({ preview }) { + if (!preview) return null + return h('div', { className: 'bt-wf-preview', 'data-facts': preview.facts }, + preview.facts === 'unavailable' + ? h('div', { className: 'bt-wf-hint' }, t('This host could not read the facts, so these guards say what they could not read — none of them is a pass.')) + : null, + (preview.rows ?? []).map((row) => h('div', { key: row.id, className: 'bt-wf-guard', 'data-state': row.state }, + h('span', { className: 'bt-wf-badge', 'data-state': row.state }, guardState(row)), + h('span', { className: 'bt-wf-guard-spec' }, `${row.spec} is ${row.is}${row.value === null || row.value === undefined ? '' : ` ${row.value}`}`), + row.sentence ? h('span', { className: 'bt-wf-guard-line' }, row.sentence) : null))) +} + +export function WorkflowsPage({ actions }) { + const [list, setList] = useState(null) + const [opened, setOpened] = useState(null) + const [draft, setDraft] = useState(null) + const [node, setNode] = useState(null) + const [preview, setPreview] = useState(null) + const [error, setError] = useState('') + const [saved, setSaved] = useState('') + + const load = () => Promise.resolve(actions.workflows({ op: 'list' })) + .then((value) => { setList(value); return value }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + useEffect(() => { load() }, []) + + const open = (id, target) => Promise.resolve(actions.workflows({ op: 'open', id, node: target ?? null })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('Could not open this workflow')); return value } + setOpened(value) + setDraft(value.workflow.definition) + setNode(value.preview?.node ?? value.workflow.definition.nodes[0]?.id ?? null) + setPreview(value.preview) + setError('') + setSaved('') + return value + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + const showNode = (id) => { + setNode(id) + if (!opened) return + Promise.resolve(actions.workflows({ op: 'preview', id: opened.workflow.id, node: id })) + .then((value) => setPreview(value?.preview ?? null)) + .catch((failure) => setError(failure?.message ?? String(failure))) + } + + const save = () => Promise.resolve(actions.workflowSave({ op: 'save', definition: draft })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('This definition cannot be saved')); return value } + setSaved(t('Saved workflows/{id}.json', { id: value.id })) + setError('') + return load().then(() => open(value.id, node)) + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + const customize = () => { + if (!opened) return null + const base = opened.workflow.id + const id = `${base}-copy` + return Promise.resolve(actions.workflowSave({ op: 'customize', from: base, id, name: `${opened.workflow.name} (mine)` })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('This definition cannot be copied')); return value } + return load().then(() => open(id)) + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + } + + const useDefault = () => { + if (!opened) return null + return Promise.resolve(actions.workflowSave({ op: 'default', id: opened.workflow.id })) + .then((value) => { if (value?.ok !== false) setList(value); return value }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + } + + const editable = opened?.workflow.editable === true + const current = draft?.nodes?.find((entry) => entry.id === node) ?? null + + return h('div', { className: 'bt-wf-page' }, + h('div', { className: 'bt-wf-head' }, + h('h3', null, h(WorkflowIcon), t('Workflows')), + h('span', { className: 'bt-wf-hint' }, t('A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.'))), + error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null, + saved ? h('div', { className: 'bt-wf-saved', role: 'status' }, saved) : null, + h('div', { className: 'bt-wf-list' }, + ['presets', 'own'].map((group) => h('div', { key: group, className: 'bt-wf-group' }, + h('span', { className: 'bt-wf-group-name' }, group === 'presets' ? t('Factory presets') : t('Yours')), + (list?.[group] ?? []).map((entry) => h('button', { + key: `${group}:${entry.id}`, type: 'button', className: 'bt-wf-pick', + 'aria-current': opened?.workflow.id === entry.id ? 'true' : undefined, + 'data-id': entry.id, 'data-source': entry.source, + onClick: () => open(entry.id), + }, entry.name, list?.default === entry.id ? h('span', { className: 'bt-wf-default' }, t('project default')) : null)))), + list === null ? h('span', { className: 'bt-wf-hint' }, t('Reading workflows…')) : null), + opened === null ? null : h('div', { className: 'bt-wf-doc' }, + h('div', { className: 'bt-wf-doc-head' }, + h('strong', null, opened.workflow.name), + h('span', { className: 'bt-wf-path' }, `workflows/${opened.workflow.id}.json`), + h('button', { type: 'button', className: 'bt-wf-action', onClick: customize }, t('Customize')), + editable ? h('button', { type: 'button', className: 'bt-wf-action', onClick: save }, t('Save')) : null, + h('button', { type: 'button', className: 'bt-wf-action', onClick: useDefault }, t('Use for this project'))), + h(Flow, { layout: opened.layout }), + h('div', { className: 'bt-wf-table' }, + h('div', { className: 'bt-wf-row bt-wf-row-head' }, + ['id', 'name', 'role', 'inputs', 'outputs', 'guards', 'refusal'].map((key) => h('span', { key }, key))), + (draft?.nodes ?? []).map((entry) => h('div', { + key: entry.id, className: 'bt-wf-row', 'data-node': entry.id, + 'aria-current': entry.id === node ? 'true' : undefined, + onClick: () => showNode(entry.id), + }, + h('span', { className: 'bt-wf-cell bt-wf-id' }, entry.id), + editable ? field('name', entry.name, (value) => setDraft(applyNodeField(draft, entry.id, 'name', value))) : h('span', null, entry.name), + editable ? field('role', entry.role, (value) => setDraft(applyNodeField(draft, entry.id, 'role', value))) : h('span', null, entry.role), + editable ? field('inputs', formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, 'inputs', value))) : h('span', null, formatList(entry.inputs)), + editable ? field('outputs', formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, 'outputs', value))) : h('span', null, formatList(entry.outputs)), + editable + ? h('textarea', { className: 'bt-wf-guards-input', value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, 'guards', event.target.value)) }) + : h('span', null, guardLines(entry.guards)), + editable ? field('refusal', entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, 'refusal', value))) : h('span', null, entry.refusal)))), + h('div', { className: 'bt-wf-edges' }, + h('span', { className: 'bt-wf-group-name' }, t('Edges')), + (draft?.edges ?? []).map((edge) => h('span', { key: `${edge.from}->${edge.to}`, className: 'bt-wf-edge-row' }, `${edge.from} → ${edge.to}`))), + h('div', { className: 'bt-wf-node' }, + h('span', { className: 'bt-wf-group-name' }, node === null ? t('Guards') : t('Guards of {node}', { node: current?.name ?? node })), + current === null || current.guards.length === 0 + ? h('span', { className: 'bt-wf-hint' }, t('This node has no guards, so nothing refuses it but its inputs.')) + : h(Preview, { preview })))) +} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index d2cce743c..74148bdba 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -60,6 +60,9 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss const TASK_ENDPOINTS = new Set([ 'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-send-back', 'task-cancel', 'task-wait-input', 'task-answer-input', + // Saving a workflow definition writes `workflows/.json` in the workspace (#1272 step 3): the + // write needs a signed-in actor, and it is an explicit entry rather than a `task-*` prefix. + 'workflow-save', ]) const deny = (code, message) => ({ ok: false, code, message }) @@ -399,7 +402,7 @@ export function install(rt) { if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' || ep === 'diagnostics' || ep === 'diagnostics-log' || ep === 'usage' - || ep === 'delivery-board' || ep === 'delivery-probe') return ALLOW + || ep === 'delivery-board' || ep === 'delivery-probe' || ep === 'workflows') return ALLOW return deny('bot/not-found', `Unknown endpoint ${ep}`) } diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 96483b35f..7167b0c2e 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -217,7 +217,7 @@ export function install(rt) { // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. // delivery-board serves the cached snapshot and delivery-probe only GETs GitHub (#1225); both were // missing, so a window that only reads the team could not open the board at all. - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'delivery-board', 'delivery-probe']) + const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'delivery-board', 'delivery-probe', 'workflows']) // A chat reference chip names a Session whose snapshot rides to every recipient of // the message, so the author must be able to read what they share (#53, #106). @@ -659,6 +659,12 @@ export function install(rt) { case 'delivery-probe': await rt.load() return ok(await rt.deliveryProbe(body)) + // A workflow definition is read by the page and written by `workflow-save`, which is in + // authz's TASK_ENDPOINTS: the client writes a file through the host, never to GitHub + // (#1272 step 3, acceptance 7). + case 'workflows': + case 'workflow-save': + return ok(await rt.workflowEndpoint(endpoint, body)) case 'share-brief': await rt.load() await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) diff --git a/core/src/host/delivery-board.js b/core/src/host/delivery-board.js index 3958dc752..5310e339d 100644 --- a/core/src/host/delivery-board.js +++ b/core/src/host/delivery-board.js @@ -12,6 +12,7 @@ // to do about it. "No GitHub connection yet" is reserved for a host that has never tried. import { repoSlug } from './update.js' import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' +import { boardSections } from '../shared/workflow-board.js' export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] @@ -118,12 +119,22 @@ const verdictOf = (comments) => { // A delivery card is a GitHub issue or pull request, never a Bot task record (#1173 keeps the // track_tasks board separate). export function cardOf(row, { kind, linked = null, owner = null, badge = null, sha = null } = {}) { + const labels = orderLabels(labelNames(row)) + // Which workflow and which node the card is in (#1272 step 3): two labels, the same shape as every + // other state a card carries. A card without them is today's card and keeps the five columns it + // always had (#1299 acceptance 8); nothing here invents a placement for a card that names none. + const prefixed = (prefix) => { + const found = labels.find((name) => name.startsWith(prefix)) + return found === undefined ? null : found.slice(prefix.length) || null + } return { kind, number: row.number, title: String(row.title ?? ''), url: String(row.html_url ?? ''), - labels: orderLabels(labelNames(row)), + labels, + workflow: prefixed('workflow:'), + node: prefixed('node:'), owner, created: row.created_at ?? null, updated: row.updated_at ?? null, @@ -951,8 +962,35 @@ export function install(rt) { const view = await boardFor(entry).read({ force: payload?.force === true, wait: payload?.wait === true }) views.push({ ...view, repo: entry.repo, repoSource: entry.source }) } - return combineRepos(views) - } + const decorated = views.map((view) => ({ ...view, ...withWorkflowColumns(view) })) + return { ...combineRepos(decorated), ...sectionsOf(combinedCards(decorated)) } + } + // Every card of every repository, each stamped with its own repository, so the same number in two + // repositories is two cards. + const combinedCards = (views) => { + const cards = [] + for (const view of views) { + if (view.columns === undefined || view.columns === null) continue + for (const id of DELIVERY_COLUMNS) for (const card of view.columns[id] ?? []) cards.push({ ...card, repo: view.repo ?? null }) + } + return cards + } + // Cards that name a workflow and a node are laid in a column of that workflow's definition + // (#1272 step 3, acceptance 1 and 2). The section is additive: the repository's own five columns + // keep every other card, and `placed` names the cards a section draws so the view does not draw + // one twice. A card that names no workflow, or names one this host does not have, stays in the + // columns it has always been in — losing a card would be worse than an old column. + const sectionsOf = (cards) => { + let definitions = [] + try { definitions = rt.workflowDefinitions?.() ?? [] } catch { definitions = [] } + const { sections } = boardSections(definitions, cards) + const placed = sections.flatMap((section) => section.columns.flatMap((column) => column.cards)) + .map((card) => `${card.repo ?? ''}#${card.kind}${card.number}`) + return { workflows: sections, placed } + } + const withWorkflowColumns = (view) => sectionsOf(view.columns === undefined || view.columns === null + ? [] + : DELIVERY_COLUMNS.flatMap((id) => (view.columns[id] ?? []).map((card) => ({ ...card, repo: card.repo ?? view.repo ?? null })))) // The 诊断 button and `--probe` run this one function (#1211): same reads, same statuses. One // repository answers exactly as before; more than one is one report with the repository named on // every row, because a status without its repository cannot be acted on. diff --git a/core/src/host/workflow-store.js b/core/src/host/workflow-store.js new file mode 100644 index 000000000..793cd0f05 --- /dev/null +++ b/core/src/host/workflow-store.js @@ -0,0 +1,240 @@ +// Where a workflow definition lives (#1272 step 3): the two FlyBot ships as presets, plus the +// owner's own `workflows/.json` in the workspace. This module is the only writer — a page asks +// it, it validates with the engine's own `validateWorkflow`, and it writes the canonical bytes +// `workflow-doc.js` produces. Three rules are enforced here rather than trusted to the page: +// +// - **a preset is copied, never mutated.** `repo-dev` and `blank` are read from the shipped +// modules and no file with their id is ever written, so the factory preset's bytes cannot +// change by any path this host serves (`customize` is the only way to start from one). +// - **a save that changes nothing writes the same bytes** (acceptance 7): `workflowBytes` is +// canonical, so a round trip is byte-identical and a definition diffs cleanly in git. +// - **a guard preview never invents a pass** (acceptance 5): the facts come from a reader the +// caller supplies; a reader that answered nothing makes every row say it could not read. +// +// The endpoint is `workflow-save`, which is in `authz.js`'s `TASK_ENDPOINTS` allow list; `workflows` +// is the read side and is listed with the other reads. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { layoutWorkflow, normaliseWorkflow, validateWorkflow } from '../shared/workflow.js' +import { copyOfPreset, isPresetId, isWorkflowId, workflowBytes, WORKFLOW_DIR } from '../shared/workflow-doc.js' +import { previewNode } from '../shared/workflow-preview.js' +import { repoDevWorkflow } from '../shared/workflow-repo-dev.js' +import { blankWorkflow } from '../shared/workflow-blank.js' + +// The shipped presets, as definitions. `PRESET_IDS` in `workflow-doc.js` names the same two; a test +// asserts they cannot drift apart. +export const PRESETS = Object.freeze([repoDevWorkflow, blankWorkflow]) +// The project's own answer for "which workflow does this board use": a tiny file beside the +// definitions, so it is exportable and diffable like they are. +const PROJECT_FILE = 'project.json' + +const summary = (definition, source) => ({ + id: normaliseWorkflow(definition).id, + name: normaliseWorkflow(definition).name, + source, + nodes: normaliseWorkflow(definition).nodes.length, + columns: normaliseWorkflow(definition).nodes.filter((node) => node.column !== false).length, +}) + +// A directory may not exist yet (a workspace nobody has customized anything in), and a file in it +// may be someone's hand-edit that does not parse: one broken file must not take the list down. +const ownFiles = (dir) => { + try { + return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() + } catch { + return [] + } +} + +export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {} } = {}) { + if (typeof dir !== 'string' || dir === '') throw new Error('a workflow store needs a directory') + const fileFor = (id) => join(dir, `${id}.json`) + + const readOwn = (id) => { + try { + return normaliseWorkflow(JSON.parse(readFileSync(fileFor(id), 'utf8'))) + } catch { + return null + } + } + + const readDefinition = (id) => { + const preset = presets.map(normaliseWorkflow).find((definition) => definition.id === id) + if (preset !== undefined) return { definition: preset, source: 'preset' } + const own = readOwn(id) + return own === null ? null : { definition: own, source: 'workspace' } + } + + const projectDefault = () => { + try { + const value = JSON.parse(readFileSync(join(dir, PROJECT_FILE), 'utf8')) + return typeof value?.default === 'string' && value.default !== '' ? value.default : null + } catch { + return null + } + } + + const list = () => { + const own = ownFiles(dir).flatMap((name) => { + const definition = readOwn(name.slice(0, -'.json'.length)) + if (definition === null || definition.id === '') { + warn('ds-bot: workflow store: %s in %s does not parse as a definition; skipped', name, dir) + return [] + } + return [summary(definition, 'workspace')] + }) + const current = projectDefault() + return { + ok: true, + presets: presets.map((definition) => summary(definition, 'preset')), + own, + // A default naming a workflow nobody has (a deleted file) is answered as "none", never echoed + // back as a workflow the page would then fail to open. + default: current !== null && readDefinition(current) !== null ? current : null, + } + } + + const open = (id, node = null) => { + const found = readDefinition(String(id ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${id}"`, problems: [] } + const { definition, source } = found + const target = typeof node === 'string' && node !== '' ? node : definition.nodes.find((entry) => entry.column !== false)?.id ?? definition.nodes[0]?.id ?? null + const answer = factsFor({ id: definition.id, node: target }) + return { + ok: true, + ...list(), + workflow: { + id: definition.id, + name: definition.name, + source, + editable: source === 'workspace', + definition, + // The bytes a save would write, so the page can say "unchanged" before it asks. + bytes: workflowBytes(definition), + sha: null, + }, + layout: layoutWorkflow(definition), + preview: target === null ? null : previewNode(definition, target, answer), + } + } + + const validate = (definition) => { + const problems = validateWorkflow(definition, { team: team() ?? undefined }).problems + return problems + } + + const save = ({ definition } = {}) => { + const def = normaliseWorkflow(definition) + if (!isWorkflowId(def.id)) { + return { ok: false, code: 'workflow/bad-id', message: `"${def.id}" is not a file name a workflow can have (lowercase letters, digits and hyphens)`, problems: [] } + } + if (isPresetId(def.id)) { + return { ok: false, code: 'workflow/preset-read-only', message: `"${def.id}" is a preset FlyBot ships; customize it into a new workflow instead of saving over it`, problems: [] } + } + const problems = validate(def) + if (problems.length > 0) return { ok: false, code: 'workflow/invalid', message: problems[0].message, problems } + const bytes = workflowBytes(def) + const previous = (() => { + try { + return readFileSync(fileFor(def.id), 'utf8') + } catch { + return null + } + })() + if (previous !== bytes) { + mkdirSync(dir, { recursive: true }) + writeFileSync(fileFor(def.id), bytes) + } + return { ok: true, id: def.id, bytes, changed: previous !== bytes, ...list() } + } + + const customize = ({ from, id, name } = {}) => { + const found = readDefinition(String(from ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${from}" to customize`, problems: [] } + const copy = copyOfPreset(found.definition, { id, name }) + const saved = save({ definition: copy }) + if (!saved.ok) return saved + return { ...saved, copiedFrom: found.definition.id, source: 'workspace' } + } + + const setDefault = ({ id } = {}) => { + const found = readDefinition(String(id ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${id}"`, problems: [] } + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, PROJECT_FILE), `${JSON.stringify({ default: found.definition.id }, null, 2)}\n`) + return { ok: true, ...list() } + } + + return { + dir, + list, + open, + save, + customize, + setDefault, + // The definitions in play, for the board's columns. Presets always; a workspace file is read on + // every call so a save is visible to the next board read without a restart. + definitions: () => { + const own = ownFiles(dir).map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') + return [...presets.map(normaliseWorkflow), ...own] + }, + bytesOf: (id) => { + const found = readDefinition(String(id ?? '')) + return found === null ? null : { source: found.source, bytes: workflowBytes(found.definition) } + }, + } +} + +// `workflows` reads; `workflow-save` writes. Both answer the store's own objects, so a refusal is a +// code and a sentence the page can show rather than an exception in the channel. +export function install(rt) { + const dirFor = () => { + let base = null + try { base = rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { base = null } + return base === null ? null : join(base, WORKFLOW_DIR) + } + // The roster's role names are the team a definition may name; a node role that is not one of them + // is refused by `validateWorkflow` (`system` and `human:` stay allowed by the engine). + const team = () => { + try { + const roles = new Set() + for (const bot of Object.values(rt.state?.bots ?? {})) if (typeof bot?.role === 'string' && bot.role !== '') roles.add(bot.role) + // An empty roster is "no team to check against", not a team of nobody: refusing every role + // would make the first definition unsavable on a fresh host. + return roles.size === 0 ? null : [...roles] + } catch { + return null + } + } + const store = () => { + const dir = dirFor() + if (dir === null) return null + if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) + return rt.workflowStore + } + rt.workflowEndpoint = async (endpoint, body = {}) => { + const found = store() + if (found === null) return { ok: false, code: 'workflow/no-workspace', message: 'this host has no workspace, so there is nowhere to read or save a workflow', problems: [] } + if (endpoint === 'workflow-save') { + const op = String(body?.op ?? 'save') + if (op === 'save') return found.save({ definition: body?.definition }) + if (op === 'customize') return found.customize({ from: body?.from, id: body?.id, name: body?.name }) + if (op === 'default') return found.setDefault({ id: body?.id }) + return { ok: false, code: 'workflow/unknown-op', message: `workflow-save has no "${op}"; it saves, customizes or sets the default`, problems: [] } + } + const op = String(body?.op ?? 'list') + if (op === 'list') return found.list() + if (op === 'open') return found.open(body?.id, body?.node) + if (op === 'preview') { + const opened = found.open(body?.id, body?.node) + if (!opened.ok) return opened + return { ok: true, id: opened.workflow.id, preview: opened.preview } + } + return { ok: false, code: 'workflow/unknown-op', message: `workflows has no "${op}"; it lists, opens or previews`, problems: [] } + } + // The board reads its columns from these definitions (acceptance 1 and 2). + rt.workflowDefinitions = () => { + try { return store()?.definitions() ?? PRESETS.map(normaliseWorkflow) } catch { return PRESETS.map(normaliseWorkflow) } + } + return rt +} diff --git a/core/src/shared/workflow-board.js b/core/src/shared/workflow-board.js new file mode 100644 index 000000000..1ae1ec10a --- /dev/null +++ b/core/src/shared/workflow-board.js @@ -0,0 +1,87 @@ +// The board the definition draws (#1272 step 3, acceptance 1 and 2): which columns a workflow has, +// in which order, and which of them a card sits in. Today's five fixed columns (`raw`, `ready`, +// `working`, `review`, `done-today`) stay for cards that name no workflow — a card without a +// `node` renders exactly as it did (#1299 acceptance 8) — and a card that does name one is laid in +// a column of its own workflow, so two workflows never share a column and every header can name the +// workflow it belongs to. +// +// Pure: definitions in, sections out. The host assembles the payload, the client draws it. +import { normaliseWorkflow, nodeById, workflowColumns } from './workflow.js' + +// One workflow's columns, in the order the definition puts its nodes: a node's `column` is the lane +// its cards sit in and the node id is the lane when the definition states none. `column: false` +// draws no lane at all. Two nodes may share a lane (`build` and `verify` are one "in-progress" +// lane); the lane is then named by its id, because naming it after one of the two would say the +// other node's cards belong elsewhere. +export function columnPlan(raw) { + const def = normaliseWorkflow(raw) + const plan = [] + const byLane = new Map() + for (const column of workflowColumns(def)) { + let lane = byLane.get(column.column) + if (lane === undefined) { + lane = { id: column.column, label: column.name, nodes: [] } + byLane.set(column.column, lane) + plan.push(lane) + } + lane.nodes.push(column.id) + if (lane.nodes.length > 1) lane.label = lane.id + } + return plan +} + +// Which lane a card sits in, or `null` when this definition cannot place it: the card names another +// workflow, names no node, or names a node that draws no column. `null` is not "drop it" — the +// caller keeps the card where it draws today. +export function placeCard(raw, card) { + const def = normaliseWorkflow(raw) + if (!card || typeof card !== 'object') return null + if (card.workflow !== def.id) return null + const node = card.node ? nodeById(def, card.node) : null + if (node === null) return null + if (node.column === false) return null + return node.column ?? node.id +} + +// The board's workflow sections: one per definition that has a card, its columns in definition +// order, and a card in the column its node names. Cards this definition cannot place are in +// `unplaced` so a caller can show them instead of losing them; cards naming no workflow come back +// as `legacy`, which is what keeps a board of today's cards looking exactly like today's board. +export function boardSections(definitions, cards) { + const defs = (Array.isArray(definitions) ? definitions : []).map(normaliseWorkflow).filter((def) => def.id !== '') + const list = Array.isArray(cards) ? cards.filter((card) => card && typeof card === 'object') : [] + const claimed = new Set() + const sections = [] + for (const def of defs) { + const plan = columnPlan(def) + const lanes = new Map(plan.map((column) => [column.id, []])) + const unplaced = [] + for (const card of list) { + if (card.workflow !== def.id) continue + const lane = placeCard(def, card) + if (lane === null) { + // A card this definition cannot place is the section's `unplaced`, not a card the legacy + // columns draw a second time. + claimed.add(card) + unplaced.push(card) + continue + } + claimed.add(card) + if (!lanes.has(lane)) lanes.set(lane, []) + lanes.get(lane).push(card) + } + if (unplaced.length === 0 && [...lanes.values()].every((lane) => lane.length === 0)) continue + sections.push({ + id: def.id, + name: def.name, + columns: [...lanes].map(([id, laneCards]) => ({ + id, + label: plan.find((column) => column.id === id)?.label ?? id, + cards: laneCards, + })), + unplaced, + }) + } + const legacy = list.filter((card) => !claimed.has(card)) + return { sections, legacy } +} diff --git a/core/src/shared/workflow-doc.js b/core/src/shared/workflow-doc.js new file mode 100644 index 000000000..c6d3fb771 --- /dev/null +++ b/core/src/shared/workflow-doc.js @@ -0,0 +1,85 @@ +// A workflow definition as a file (#1272 step 3): `workflows/.json` in the workspace, written +// once and read back byte for byte. The two rules that live here are the ones a page cannot keep +// for itself — +// +// - **saving an unedited definition changes nothing.** `workflowBytes` is canonical: a fixed key +// order, two spaces, a trailing newline, and fields a definition does not state left out. So a +// load → save round trip is byte-identical, a definition can be diffed in git, and a save that +// reorders keys is a defect here rather than a surprise in someone's review. +// - **a preset is copied, never mutated.** `PRESET_IDS` names the definitions FlyBot ships; a +// workspace file may not take one of those ids, and a copy differs from its preset only in +// `id` / `name` — which is the assertion `workflow-doc.test.js` makes before any page exists. +// +// Nothing here does I/O: the host writes the bytes this module produces (`host/workflow-store.js`). +import { normaliseWorkflow } from './workflow.js' + +// The folder in the workspace, and the shipped ids a workspace file may not claim. +export const WORKFLOW_DIR = 'workflows' +export const PRESET_IDS = Object.freeze(['repo-dev', 'blank']) + +// A file name, not a title: lowercase, no spaces, no path separators, so `workflows/.json` can +// never leave its folder and two workflows cannot share one file. +const ID_RULE = /^[a-z0-9][a-z0-9-]{0,62}$/ + +export const isWorkflowId = (id) => typeof id === 'string' && ID_RULE.test(id) +export const isPresetId = (id) => PRESET_IDS.includes(id) + +const guardDoc = (guard) => { + const doc = { id: guard.id, spec: guard.spec, is: guard.is } + if (guard.value !== null) doc.value = guard.value + if (guard.refusal !== '') doc.refusal = guard.refusal + return doc +} + +const nodeDoc = (node) => { + const doc = { id: node.id, name: node.name, role: node.role } + // `column: false` is a decision ("this node draws no column"), and a missing one is the other + // decision ("the node id is the column"), so the two are written differently (#1265). + if (node.column !== null) doc.column = node.column + doc.inputs = node.inputs.slice() + doc.outputs = node.outputs.slice() + doc.guards = node.guards.map(guardDoc) + doc.refusal = node.refusal + return doc +} + +// The canonical document of a definition, as an object with a fixed key order. +export function workflowDocument(raw) { + const def = normaliseWorkflow(raw) + return { + id: def.id, + name: def.name, + version: def.version, + inputs: def.inputs.slice(), + deliverables: def.deliverables.slice(), + nodes: def.nodes.map(nodeDoc), + edges: def.edges.map((edge) => ({ from: edge.from, to: edge.to })), + loops: def.loops.map((loop) => ({ from: loop.from, to: loop.to })), + } +} + +// The bytes a save writes and a load compares against. Same definition in, same bytes out. +export function workflowBytes(raw) { + return `${JSON.stringify(workflowDocument(raw), null, 2)}\n` +} + +// What a `Customize` writes: the preset's own nodes and edges, with a new id and name. Everything +// else is the preset's, which is what makes "the copy differs only in id/name" checkable. +export function copyOfPreset(preset, { id, name } = {}) { + const def = normaliseWorkflow(preset) + return normaliseWorkflow({ + ...workflowDocument(def), + id, + name: name === undefined || name === null || String(name).trim() === '' ? def.name : String(name).trim(), + }) +} + +// The fields in which two definitions differ, by name — `['id', 'name']` for a preset and its copy. +// Used by the store to refuse a save that would rewrite a shipped preset, and by the test that +// holds acceptance 6. +export function differingFields(left, right) { + const a = workflowDocument(left) + const b = workflowDocument(right) + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + return [...keys].filter((key) => JSON.stringify(a[key]) !== JSON.stringify(b[key])).sort() +} diff --git a/core/src/shared/workflow-preview.js b/core/src/shared/workflow-preview.js new file mode 100644 index 000000000..2def7a120 --- /dev/null +++ b/core/src/shared/workflow-preview.js @@ -0,0 +1,41 @@ +// The guard preview an editor shows (#1272 step 3, acceptance 5): for one node, the sentence each +// of its guards would print under the facts somebody just read. Two rules, both of them the +// engine's, not the page's — +// +// - the sentence is `renderRefusal`'s, produced from the definition, so the preview and the card's +// own refusal cannot disagree; +// - a fact nobody could read is `unreadable`, never a pass. When the caller read nothing at all, +// every row says so; a row with no sentence means only "this guard was read and holds". +import { guardRows, normaliseWorkflow, nodeById } from './workflow.js' + +// The row a guard gets when the fact source answered nothing. It names the fact it could not read +// and says what that means for the card; it never says the guard passes. +export const cannotRead = (node, spec) => `${node?.name || node?.id || 'this node'} refuses: its check on ${spec ?? 'a fact'} could not be read — no answer came back, so nothing here says it holds.` + +// `answer` is `{ ok: true, facts }` from a reader that answered, or `{ ok: false, why }` from one that +// did not. The rows keep the definition's guard order. +export function previewNode(raw, nodeId, answer = {}) { + const def = normaliseWorkflow(raw) + const node = nodeById(def, nodeId) + if (node === null) return { node: nodeId, facts: 'unknown', rows: [], refusal: `there is no node "${nodeId}" in this workflow` } + if (answer?.ok !== true) { + return { + node: node.id, + facts: 'unavailable', + why: typeof answer?.why === 'string' && answer.why !== '' ? answer.why : null, + rows: node.guards.map((guard) => ({ + id: guard.id, + spec: guard.spec, + is: guard.is, + value: guard.value, + state: 'unreadable', + sentence: cannotRead(node, guard.spec), + unreadable: true, + })), + refusal: node.guards.length === 0 ? null : cannotRead(node, node.guards[0].spec), + } + } + const rows = guardRows(def, node.id, answer.facts ?? {}) + const first = rows.find((row) => row.state !== 'holds') + return { node: node.id, facts: 'read', why: null, rows, refusal: first?.sentence ?? null } +} diff --git a/core/src/shared/workflow.js b/core/src/shared/workflow.js index 21e321bd1..6bc1d6a8d 100644 --- a/core/src/shared/workflow.js +++ b/core/src/shared/workflow.js @@ -402,13 +402,13 @@ function guardRefusals(node, facts) { for (const guard of node.guards) { const spec = canonicalSpec(guard.spec) if (!spec) { - refusals.push({ guard, refusal: renderRefusal(node, { spec: guard.spec }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec: guard.spec }), unreadable: false }) continue } // Read before the fact: an unknown check is a defect in the file, and a reader has to be told // that rather than shown a refusal about a fact that was never looked at. if (!GUARD_CHECKS.includes(guard.is)) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, guard, unchecked: guard.is }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, guard, unchecked: guard.is }), unreadable: false }) continue } const have = artifactAt(present, spec) @@ -416,20 +416,45 @@ function guardRefusals(node, facts) { if (!have) { // A guard's own sentence says what the fact said, and a fact that is not there said nothing: // the node's wording answers this one, or the sentence would name two revisions that do not exist. - refusals.push({ guard, refusal: renderRefusal(node, { spec }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec }), unreadable: false }) continue } if (guard.is === 'fresh' && !state.fresh) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, stale: true, have: state.have, now: state.now }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, stale: true, have: state.have, now: state.now }), unreadable: state.reason === 'unreadable' }) continue } if (guard.is === 'equals' && String(have.value ?? '') !== String(guard.value ?? '')) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, have: have.revision, want: guard.value }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, have: have.revision, want: guard.value }), unreadable: false }) } } return refusals } +// One row per guard of one node, in the order the definition lists them (#1272 step 3, acceptance +// 5): the sentence a guard would print under these facts, or no sentence when it holds. This reads +// the same function `enterNode` reads, so the editor's preview and the board's refusal cannot +// disagree; it exists because the editor shows every guard while `enterNode` answers with the first +// refusal only. +export function guardRows(raw, nodeId, facts = {}) { + const node = nodeById(normaliseWorkflow(raw), nodeId) + if (!node) return [] + const refused = new Map(guardRefusals(node, facts).map((entry) => [entry.guard.id, entry])) + return node.guards.map((guard) => { + const entry = refused.get(guard.id) + return { + id: guard.id, + spec: guard.spec, + is: guard.is, + value: guard.value, + // `holds` is a guard that was read and passes; `refuse` carries the producer's sentence, and + // `unreadable` says the source could not be read, which is never a pass. + state: entry === undefined ? 'holds' : entry.unreadable ? 'unreadable' : 'refuse', + sentence: entry?.refusal ?? null, + unreadable: entry === undefined ? false : entry.unreadable === true, + } + }) +} + // Whether this node may start: every input present, every one of them current, and every guard // satisfied. The first failure is returned as the node's own sentence, with both revisions named — // a refusal moves nothing. diff --git a/core/test/delivery-board.test.js b/core/test/delivery-board.test.js index 66c2b9c55..f4c97c9f2 100644 --- a/core/test/delivery-board.test.js +++ b/core/test/delivery-board.test.js @@ -10,7 +10,7 @@ import { test } from 'node:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { combineRepos, createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' +import { cardOf, combineRepos, createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' import { install as installAuthz } from '../src/host/authz.js' import { install as installChannel } from '../src/host/channel.js' @@ -1095,3 +1095,18 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi assert.equal(withMissing.state, 'partial') assert.deepEqual(withMissing.missing.map(entry => [entry.repo, entry.part]), [['acme/one', 'ci']]) }) + +// A card names its workflow and its node with two labels (#1272 step 3, acceptance 8): a card that +// carries them can be laid in its workflow's column, and a card that carries none is exactly today's +// card — the same fields, the five columns it always had, nothing invented. +test('a card reads its workflow and node from its labels, and a card without them is unchanged (#1272 step 3)', () => { + const tagged = cardOf({ number: 5, title: 'tagged', html_url: 'u', labels: [{ name: 'workflow:repo-dev' }, { name: 'node:build' }, { name: 'bug' }] }, { kind: 'issue' }) + assert.equal(tagged.workflow, 'repo-dev') + assert.equal(tagged.node, 'build') + assert.deepEqual([...tagged.labels], ['bug', 'node:build', 'workflow:repo-dev']) + const plain = cardOf({ number: 6, title: 'plain', html_url: 'u', labels: [{ name: 'bug' }] }, { kind: 'issue' }) + assert.equal(plain.workflow, null) + assert.equal(plain.node, null) + // A label with the prefix and nothing after it names no workflow rather than an empty one. + assert.equal(cardOf({ number: 7, labels: [{ name: 'workflow:' }] }, { kind: 'issue' }).workflow, null) +}) diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index eefd3daa2..c7d976392 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -733,3 +733,36 @@ test('no token names the GitHub side, never an empty board (#1211, #1265)', () = assert.equal(all.props['data-state'], 'failed') assert.doesNotMatch(text(all), /Nothing here\./) }) + +// A workflow's columns are the definition's (#1272 step 3, acceptance 1 and 2): the section draws its +// own columns beside the five legacy ones, every header names the workflow, and a card a section +// draws is not drawn a second time below it. +test('a workflow draws its own columns, named by the workflow, and its card once (#1272 step 3)', () => { + const placed = { kind: 'issue', number: 21, title: 'Content card', labels: ['workflow:content', 'node:草稿'], url: 'https://x/21', repo: 'o/r' } + const payload = { + ...snapshot, + repo: 'o/r', + columns: { ...snapshot.columns, ready: [...snapshot.columns.ready, placed] }, + workflows: [{ id: 'content', name: 'Content', columns: [ + { id: '选题', label: '选题', cards: [] }, + { id: '草稿', label: '草稿', cards: [placed] }, + ], unplaced: [] }], + placed: ['o/r#issue21'], + } + const board = DeliveryBoard({ snapshot: payload }) + const section = walk(board).find(node => node.props?.['data-workflow'] === 'content') + assert.ok(section, 'the workflow section is drawn') + assert.match(text(section), /Content/) + const workflowColumns = walk(section).filter(node => node.props?.['data-column']).map(node => node.props['data-column']) + assert.deepEqual([...workflowColumns], ['选题', '草稿']) + // The legacy five are still there and still named. + const legacy = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'] && node.props['data-workflow'] === undefined) + assert.deepEqual([...legacy.map(node => node.props['data-column'])], ['raw', 'ready', 'working', 'review', 'done-today']) + // The card is in the definition's column, and only there: no column holds two workflows, and the + // card is drawn once. + const drawn = walk(board).filter(node => node.props?.['data-number'] === 21) + assert.equal(drawn.length, 1) + assert.ok(walk(section).includes(drawn[0])) + const ready = legacy.find(node => node.props['data-column'] === 'ready') + assert.equal(walk(ready).some(node => node.props?.['data-number'] === 21), false) +}) diff --git a/core/test/workflow-board.test.js b/core/test/workflow-board.test.js new file mode 100644 index 000000000..4d99d204e --- /dev/null +++ b/core/test/workflow-board.test.js @@ -0,0 +1,100 @@ +// The board's columns come from the definition (#1272 step 3, acceptance 1, 2 and 8): a five-node +// content workflow draws its own columns beside a repository card with no code change between them, +// two workflows never share a column, every column header names its workflow, and a card that names +// no workflow keeps the five columns it has today. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { boardSections, columnPlan, placeCard } from '../src/shared/workflow-board.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +// The content workflow from the parent issue's acceptance 1: five nodes, no repository, no `column` +// stated anywhere — so each node id is the lane, which is what "no code change between them" means. +const content = { + id: 'content', + name: 'Content', + version: 1, + inputs: ['text:topic'], + deliverables: ['link:published'], + nodes: [ + { id: '选题', name: '选题', role: 'Writer', inputs: ['text:topic'], outputs: ['text:draft'], guards: [], refusal: '' }, + { id: '草稿', name: '草稿', role: 'Writer', inputs: ['text:draft'], outputs: ['text:edited'], guards: [], refusal: '' }, + { id: '审稿', name: '审稿', role: 'Editor', inputs: ['text:edited'], outputs: ['verdict'], guards: [], refusal: '' }, + { id: '发布', name: '发布', role: 'Publisher', inputs: ['verdict'], outputs: ['link:published'], guards: [], refusal: '' }, + { id: '归档', name: '归档', role: 'Publisher', inputs: ['link:published'], outputs: [], guards: [], refusal: '' }, + ], + edges: [ + { from: '选题.text:draft', to: '草稿.text:draft' }, + { from: '草稿.text:edited', to: '审稿.text:edited' }, + { from: '审稿.verdict', to: '发布.verdict' }, + { from: '发布.link:published', to: '归档.link:published' }, + ], + loops: [], +} + +const card = (over) => ({ kind: 'issue', number: 1, title: 'a card', workflow: null, node: null, ...over }) + +test('a five-node content workflow draws its own five columns, in the definition order', () => { + assert.deepEqual(columnPlan(content).map((column) => column.id), ['选题', '草稿', '审稿', '发布', '归档']) +}) + +test('repo-dev draws the three lanes its nodes name, and the review node draws none', () => { + const plan = columnPlan(repoDevWorkflow) + assert.deepEqual(plan.map((column) => column.id), ['not-started', 'in-progress', 'done']) + // Two nodes share the middle lane, so it is named by its id rather than after one of them. + assert.equal(plan[1].label, 'in-progress') + assert.deepEqual(plan[1].nodes, ['build', 'verify', 'gate']) + // `review` carries `column: false` and is in no lane at all (#1265). + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'review' })), null) +}) + +test('a card sits in the lane its node names, and nowhere when it names no workflow', () => { + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'build' })), 'in-progress') + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'release' })), 'done') + assert.equal(placeCard(content, card({ workflow: 'content', node: '审稿' })), '审稿') + assert.equal(placeCard(content, card({ workflow: 'repo-dev', node: 'build' })), null) + assert.equal(placeCard(content, card()), null) + // A node the definition does not have is not placed by a guess. + assert.equal(placeCard(content, card({ workflow: 'content', node: 'nope' })), null) +}) + +test('a content card and a repo-dev card get their own columns and no column holds both', () => { + const cards = [ + card({ number: 7, workflow: 'repo-dev', node: 'build' }), + card({ number: 8, workflow: 'content', node: '草稿' }), + card({ number: 9 }), + ] + const { sections, legacy } = boardSections([repoDevWorkflow, content], cards) + assert.deepEqual(sections.map((section) => section.id), ['repo-dev', 'content']) + const repoColumn = sections[0].columns.find((column) => column.id === 'in-progress') + const contentColumn = sections[1].columns.find((column) => column.id === '草稿') + assert.deepEqual(repoColumn.cards.map((entry) => entry.number), [7]) + assert.deepEqual(contentColumn.cards.map((entry) => entry.number), [8]) + // Every column names its workflow: `data-workflow` comes from the section id, and the header text + // is the definition's own name. + assert.equal(sections[0].name, 'Repository change') + assert.equal(sections[1].name, 'Content') + // No column holds two workflows' cards: the sections are disjoint by construction. + const numbers = sections.flatMap((section) => section.columns.flatMap((column) => column.cards.map((entry) => entry.number))) + assert.deepEqual(numbers.sort(), [7, 8]) + // The card that names no workflow is still where it was, in the legacy columns. + assert.deepEqual(legacy.map((entry) => entry.number), [9]) +}) + +test('a workflow with no cards draws no section, and a blank card names its own workflow', () => { + assert.deepEqual(boardSections([repoDevWorkflow, blankWorkflow], [card({ workflow: 'blank', node: 'draft' })]).sections.map((section) => section.id), ['blank']) + const { sections } = boardSections([blankWorkflow], [card({ workflow: 'blank', node: 'draft' }), card({ number: 2, workflow: 'blank', node: 'publish' })]) + assert.deepEqual(sections[0].columns.filter((column) => column.cards.length > 0).map((column) => column.id), ['draft', 'publish']) +}) + +test('a card naming a node with no column is kept, not dropped', () => { + const { sections, legacy } = boardSections([repoDevWorkflow], [card({ number: 3, workflow: 'repo-dev', node: 'review' })]) + assert.deepEqual(sections[0].unplaced.map((entry) => entry.number), [3]) + assert.deepEqual(legacy, []) +}) + +test('an unknown workflow in a card keeps it in the five columns it had', () => { + const { sections, legacy } = boardSections([repoDevWorkflow], [card({ number: 4, workflow: 'gone', node: 'build' })]) + assert.deepEqual(sections, []) + assert.deepEqual(legacy.map((entry) => entry.number), [4]) +}) diff --git a/core/test/workflow-doc.test.js b/core/test/workflow-doc.test.js new file mode 100644 index 000000000..329aef82b --- /dev/null +++ b/core/test/workflow-doc.test.js @@ -0,0 +1,59 @@ +// The definition as a file (#1272 step 3, acceptance 6 and 7): canonical bytes, a preset that a copy +// leaves byte-identical, and a load → save round trip that changes nothing. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { copyOfPreset, differingFields, isPresetId, isWorkflowId, PRESET_IDS, workflowBytes, workflowDocument } from '../src/shared/workflow-doc.js' +import { normaliseWorkflow } from '../src/shared/workflow.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +const sha256 = (text) => createHash('sha256').update(text).digest('hex') + +test('a definition saves to canonical bytes and reads back unchanged', () => { + const bytes = workflowBytes(repoDevWorkflow) + assert.ok(bytes.endsWith('}\n')) + assert.deepEqual(JSON.parse(bytes), workflowDocument(repoDevWorkflow)) + // Saving what was read changes nothing: the same bytes, so a definition diffs cleanly in git. + assert.equal(workflowBytes(JSON.parse(bytes)), bytes) +}) + +test('the same definition in a different key order saves to the same bytes', () => { + const reordered = JSON.parse(workflowBytes(repoDevWorkflow)) + const shuffled = Object.fromEntries(Object.entries(reordered).reverse()) + shuffled.nodes = reordered.nodes.map((node) => Object.fromEntries(Object.entries(node).reverse())) + assert.equal(workflowBytes(shuffled), workflowBytes(repoDevWorkflow)) +}) + +test('a preset copy differs from its preset only in id and name, and the preset file is untouched', () => { + const before = sha256(readFileSync(new URL('../src/shared/workflow-repo-dev.js', import.meta.url), 'utf8')) + const copy = copyOfPreset(repoDevWorkflow, { id: 'repo-dev-mine', name: 'Mine' }) + assert.deepEqual(differingFields(repoDevWorkflow, copy), ['id', 'name']) + assert.equal(copy.id, 'repo-dev-mine') + assert.equal(copy.name, 'Mine') + // The file the preset lives in is not what a save touches; the copy is a value, not a write. + const after = sha256(readFileSync(new URL('../src/shared/workflow-repo-dev.js', import.meta.url), 'utf8')) + assert.equal(after, before) + // Every field but the two is the preset's, node for node. + assert.deepEqual(normaliseWorkflow({ ...copy, id: repoDevWorkflow.id, name: repoDevWorkflow.name }), normaliseWorkflow(repoDevWorkflow)) +}) + +test('an id is a file name: lowercase, digits and hyphens, and a preset id is never one of ours', () => { + assert.equal(isWorkflowId('repo-dev-copy'), true) + assert.equal(isWorkflowId('Repo Dev'), false) + assert.equal(isWorkflowId('../escape'), false) + assert.equal(isWorkflowId(''), false) + assert.equal(isPresetId('repo-dev'), true) + assert.equal(isPresetId('blank'), true) + assert.equal(isPresetId('repo-dev-copy'), false) + // The shipped presets and the ids a workspace may not claim are the same two, and cannot drift. + assert.deepEqual([...PRESET_IDS].sort(), [repoDevWorkflow.id, blankWorkflow.id].sort()) +}) + +test('a blank copy keeps its nodes and edges and nothing else moves', () => { + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: '' }) + assert.deepEqual(differingFields(blankWorkflow, copy), ['id']) + // An empty name keeps the preset's name rather than writing a definition with no name. + assert.equal(copy.name, blankWorkflow.name) +}) diff --git a/core/test/workflow-preview.test.js b/core/test/workflow-preview.test.js new file mode 100644 index 000000000..713e7faa0 --- /dev/null +++ b/core/test/workflow-preview.test.js @@ -0,0 +1,93 @@ +// The guard preview (#1272 step 3, acceptance 5): the sentence this definition would print under the +// facts somebody just read, naming both revisions, and — when the reader answered nothing — a row +// that says it could not read the fact rather than a guessed pass. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { artifact } from '../src/shared/artifact.js' +import { previewNode } from '../src/shared/workflow-preview.js' + +const review = { + id: 'review-flow', + name: 'Review flow', + version: 1, + inputs: ['pr'], + deliverables: ['release'], + nodes: [ + { + id: 'gate', + name: 'Gate', + role: 'Reviewer', + column: 'in-progress', + inputs: ['pr'], + outputs: ['risk:tier'], + guards: [ + { id: 'no-hold', spec: 'pr', is: 'equals', value: 'none', refusal: 'the pull request carries the `hold` label' }, + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', refusal: 'the pull request head is {now}, not the expected {have}' }, + ], + refusal: 'Gate cannot start: its input {input} is not current.', + }, + { id: 'done', name: 'Done', role: 'Release', inputs: ['risk:tier'], outputs: ['release'], guards: [], refusal: 'Done cannot start.' }, + ], + edges: [], + loops: [], +} + +const facts = (over = {}) => ({ + present: { + pr: artifact('pr', 'abc1234', { value: 'none' }), + verdict: artifact('verdict', 'abc1234', { value: 'PASS', by: 'Reviewer' }), + }, + current: { pr: 'abc1234', verdict: 'def5678' }, + ...over, +}) + +test('a stale verdict prints the producer sentence with both revisions and no pass', () => { + const preview = previewNode(review, 'gate', { ok: true, facts: facts() }) + assert.equal(preview.facts, 'read') + const row = preview.rows.find((entry) => entry.id === 'verdict-at-tip') + assert.equal(row.state, 'refuse') + assert.equal(row.unreadable, false) + assert.match(row.sentence, /abc1234/) + assert.match(row.sentence, /def5678/) + // The first refusal is the node's own answer, which is the sentence a card would show. + assert.equal(preview.refusal, row.sentence) + // The guard that holds says nothing at all — a row with no sentence is a fact that was read. + assert.equal(preview.rows.find((entry) => entry.id === 'no-hold').state, 'holds') + assert.equal(preview.rows.find((entry) => entry.id === 'no-hold').sentence, null) +}) + +test('a fact that could not be read is named, and a reader that answered nothing says so', () => { + const unreadable = previewNode(review, 'gate', { ok: true, facts: facts({ current: { pr: 'abc1234', verdict: null } }) }) + const row = unreadable.rows.find((entry) => entry.id === 'verdict-at-tip') + assert.equal(row.state, 'unreadable') + assert.equal(row.unreadable, true) + assert.match(row.sentence, /could not be checked against the source/) + + const silent = previewNode(review, 'gate', { ok: false, why: 'no GITHUB_TOKEN' }) + assert.equal(silent.facts, 'unavailable') + assert.equal(silent.why, 'no GITHUB_TOKEN') + assert.equal(silent.rows.length, 2) + for (const entry of silent.rows) { + assert.equal(entry.state, 'unreadable') + assert.match(entry.sentence, /could not be read/) + assert.doesNotMatch(entry.sentence, /passes|it is fine/i) + } + // The definition itself is never changed by a preview. + assert.equal(silent.rows[0].spec, 'pr') +}) + +test('a check nobody implemented refuses instead of being read as present', () => { + const broken = { ...review, nodes: [{ ...review.nodes[0], guards: [{ id: 'typo', spec: 'pr', is: 'fres' }] }] } + const preview = previewNode(broken, 'gate', { ok: true, facts: facts() }) + assert.equal(preview.rows[0].state, 'refuse') + assert.match(preview.rows[0].sentence, /is not one of present, fresh, equals/) +}) + +test('a node with no guards and a node that does not exist both answer plainly', () => { + const none = previewNode(review, 'done', { ok: true, facts: facts() }) + assert.deepEqual(none.rows, []) + assert.equal(none.refusal, null) + const missing = previewNode(review, 'nope', { ok: true, facts: facts() }) + assert.deepEqual(missing.rows, []) + assert.match(missing.refusal, /there is no node "nope"/) +}) diff --git a/core/test/workflow-store.test.js b/core/test/workflow-store.test.js new file mode 100644 index 000000000..7669f3ae4 --- /dev/null +++ b/core/test/workflow-store.test.js @@ -0,0 +1,153 @@ +// Where a definition lives (#1272 step 3): the workspace's `workflows/.json`, the presets that +// are read-only, and the two endpoints — `workflows` reads, `workflow-save` writes with a signed-in +// actor (acceptance 6 and 7). +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { install as installAuthz } from '../src/host/authz.js' +import { createWorkflowStore, install, PRESETS } from '../src/host/workflow-store.js' +import { copyOfPreset, differingFields, workflowBytes } from '../src/shared/workflow-doc.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +const withDir = (run) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + try { + return run(join(root, 'workflows'), root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +const sha256 = (text) => createHash('sha256').update(text).digest('hex') + +test('a save writes canonical bytes, and saving an unedited definition rewrites the same bytes', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: 'Mine' }) + const first = store.save({ definition: copy }) + assert.equal(first.ok, true) + assert.equal(first.changed, true) + const written = readFileSync(join(dir, 'blank-mine.json'), 'utf8') + assert.equal(written, workflowBytes(copy)) + // Acceptance 7: normalising and re-saving an unedited definition produces identical bytes. + const opened = store.open('blank-mine') + const again = store.save({ definition: opened.workflow.definition }) + assert.equal(again.ok, true) + assert.equal(again.changed, false) + assert.equal(readFileSync(join(dir, 'blank-mine.json'), 'utf8'), written) + assert.equal(sha256(readFileSync(join(dir, 'blank-mine.json'), 'utf8')), sha256(written)) + }) +}) + +test('a preset id cannot be saved over, and an invalid definition is refused with its own sentence', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const preset = store.save({ definition: repoDevWorkflow }) + assert.equal(preset.ok, false) + assert.equal(preset.code, 'workflow/preset-read-only') + assert.equal(existsSync(join(dir, 'repo-dev.json')), false) + + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: 'Mine' }) + copy.nodes[0] = { ...copy.nodes[0], role: '' } + const invalid = store.save({ definition: copy }) + assert.equal(invalid.ok, false) + assert.equal(invalid.code, 'workflow/invalid') + assert.match(invalid.problems[0].message, /names no role/) + assert.equal(existsSync(join(dir, 'blank-mine.json')), false) + }) +}) + +test('customize writes a new file and leaves the shipped preset byte-identical', () => { + withDir((dir) => { + const presetFile = new URL('../src/shared/workflow-repo-dev.js', import.meta.url) + const before = sha256(readFileSync(presetFile, 'utf8')) + const store = createWorkflowStore({ dir }) + const copied = store.customize({ from: 'repo-dev', id: 'repo-dev-mine', name: 'Mine' }) + assert.equal(copied.ok, true) + assert.equal(copied.copiedFrom, 'repo-dev') + assert.equal(sha256(readFileSync(presetFile, 'utf8')), before) + const copy = JSON.parse(readFileSync(join(dir, 'repo-dev-mine.json'), 'utf8')) + assert.deepEqual(differingFields(repoDevWorkflow, copy), ['id', 'name']) + // The preset itself is still served from its module, not from the workspace file. + assert.equal(store.open('repo-dev').workflow.source, 'preset') + assert.equal(store.open('repo-dev-mine').workflow.source, 'workspace') + }) +}) + +test('the list names the presets, the owner files and the project default', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const empty = store.list() + assert.deepEqual(empty.presets.map((entry) => entry.id), PRESETS.map((entry) => entry.id)) + assert.deepEqual(empty.own, []) + assert.equal(empty.default, null) + + store.customize({ from: 'blank', id: 'blank-mine', name: 'Mine' }) + const withDefault = store.setDefault({ id: 'blank-mine' }) + assert.equal(withDefault.ok, true) + assert.equal(withDefault.default, 'blank-mine') + assert.deepEqual(withDefault.own.map((entry) => entry.id), ['blank-mine']) + // A default naming a file somebody deleted answers "none" instead of a workflow that cannot open. + rmSync(join(dir, 'blank-mine.json')) + assert.equal(store.list().default, null) + }) +}) + +test('open answers the definition, its layout and a guard preview', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir, factsFor: () => ({ ok: true, facts: { present: { pr: { spec: 'pr', revision: 'abc1234', value: 'none' } }, current: { pr: 'abc1234' } } }) }) + const opened = store.open('repo-dev') + assert.equal(opened.ok, true) + assert.equal(opened.workflow.editable, false) + assert.equal(opened.workflow.bytes, workflowBytes(repoDevWorkflow)) + assert.equal(opened.layout.boxes.length, repoDevWorkflow.nodes.length) + assert.equal(opened.layout.edges.length, repoDevWorkflow.edges.length) + assert.equal(opened.preview.node, 'triage') + // No fact reader answers on this host: the preview says it could not read rather than guessing. + const blind = createWorkflowStore({ dir }).open('repo-dev', 'gate') + assert.equal(blind.preview.facts, 'unavailable') + assert.ok(blind.preview.rows.every((row) => row.state === 'unreadable')) + }) +}) + +test('the host endpoints read and write, and workflow-save is in the authz allow list', async () => { + await withDir(async (dir, root) => { + const rt = { state: { bots: {} }, ctx: { workspaceRegistry: { get: () => ({ path: root }) } }, warn: () => {} } + install(rt) + installAuthz(rt) + // Enforced means someone has signed in on this host; before that every endpoint answers. + rt.state.authSessions = { 'session-1': {} } + const actor = { memberId: 'm1' } + assert.equal(rt.authorize(actor, 'workflow-save', {}).ok, true) + assert.equal(rt.authorize(actor, 'workflows', {}).ok, true) + assert.equal(rt.authorize(actor, 'not-an-endpoint', {}).ok, false) + + const listed = await rt.workflowEndpoint('workflows', { op: 'list' }) + assert.equal(listed.ok, true) + assert.deepEqual(listed.presets.map((entry) => entry.id), ['repo-dev', 'blank']) + const saved = await rt.workflowEndpoint('workflow-save', { op: 'customize', from: 'blank', id: 'blank-mine', name: 'Mine' }) + assert.equal(saved.ok, true) + assert.equal(existsSync(join(dir, 'blank-mine.json')), true) + const opened = await rt.workflowEndpoint('workflows', { op: 'open', id: 'blank-mine' }) + assert.equal(opened.workflow.source, 'workspace') + // The board reads its columns from these definitions (acceptance 1 and 2). + assert.deepEqual(rt.workflowDefinitions().map((definition) => definition.id).sort(), ['blank', 'blank-mine', 'repo-dev']) + const unknown = await rt.workflowEndpoint('workflow-save', { op: 'rewrite-everything' }) + assert.equal(unknown.ok, false) + assert.equal(unknown.code, 'workflow/unknown-op') + }) +}) + +test('a workspace file that does not parse is skipped instead of breaking the list', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir, warn: () => {} }) + store.customize({ from: 'blank', id: 'blank-mine', name: 'Mine' }) + writeFileSync(join(dir, 'broken.json'), '{ not json') + const listed = store.list() + assert.deepEqual(listed.own.map((entry) => entry.id), ['blank-mine']) + }) +}) diff --git a/core/test/workflows-page.test.js b/core/test/workflows-page.test.js new file mode 100644 index 000000000..6d90b0836 --- /dev/null +++ b/core/test/workflows-page.test.js @@ -0,0 +1,187 @@ +// The Workflows page (#1272 step 3, acceptance 3, 4, 5 and 6): the node table, the flowchart drawn +// from it, a guard's live refusal, and a preset that is read-only until it is customized. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { WorkflowsPage, applyNodeField, formatList, guardLines, parseGuards, parseList } from './src/client/workflows.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +let states = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useMemo: (fn) => fn(), useCallback: (fn) => fn(), + useRef: (current) => ({ current }), useSyncExternalStore: (_s, get) => get(), useState: (value) => [states.length > 0 ? states.shift() : value, () => {}], +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + setTimeout, clearTimeout, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { WorkflowsPage, applyNodeField, guardLines, parseGuards, parseList } = module.exports + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const all = (node, out = []) => { + if (node && typeof node === 'object') { + if (Array.isArray(node)) node.forEach((child) => all(child, out)) + else { out.push(node); all(node.props?.children, out) } + } + return out +} +const text = (node) => (node === null || node === undefined ? '' : Array.isArray(node) ? node.map(text).join('') : typeof node === 'object' ? text(node.props.children) : String(node)) + +const node = (id, name, role, over = {}) => ({ id, name, role, column: null, inputs: [], outputs: [], guards: [], refusal: `${name} cannot start.`, ...over }) +const definition = { + id: 'content', + name: 'Content', + version: 1, + inputs: ['text:topic'], + deliverables: ['link:published'], + nodes: [ + node('选题', '选题', 'Writer', { inputs: ['text:topic'], outputs: ['text:draft'] }), + node('草稿', '草稿', 'Writer', { inputs: ['text:draft'], outputs: ['text:edited'] }), + node('审稿', '审稿', 'Editor', { inputs: ['text:edited'], outputs: ['verdict'], guards: [{ id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: '' }] }), + node('发布', '发布', 'Publisher', { inputs: ['verdict'], outputs: ['link:published'] }), + node('归档', '归档', 'Publisher', { inputs: ['link:published'] }), + ], + edges: [ + { from: '选题.text:draft', to: '草稿.text:draft' }, + { from: '草稿.text:edited', to: '审稿.text:edited' }, + { from: '审稿.verdict', to: '发布.verdict' }, + { from: '发布.link:published', to: '归档.link:published' }, + ], + loops: [], +} +const layout = { + id: 'content', + name: 'Content', + boxes: definition.nodes.map((entry, at) => ({ id: entry.id, name: entry.name, role: entry.role, rank: at, row: 0, inputs: entry.inputs, outputs: entry.outputs, x: at * 250, y: 0, width: 220, height: 120 })), + edges: definition.edges.map((edge) => ({ from: edge.from, to: edge.to, artifact: edge.from, fromBox: '选题', toBox: '草稿', points: [{ x: 220, y: 60 }, { x: 250, y: 60 }] })), + width: 1250, + height: 120, +} +const list = { + ok: true, + presets: [{ id: 'repo-dev', name: 'Repository change', source: 'preset', nodes: 7 }, { id: 'blank', name: 'Blank', source: 'preset', nodes: 3 }], + own: [{ id: 'content', name: 'Content', source: 'workspace', nodes: 5 }], + default: 'repo-dev', +} +const preview = { + node: '审稿', + facts: 'read', + rows: [ + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, state: 'refuse', sentence: '审稿 refuses: its input verdict@abc1234 is stale', unreadable: false }, + { id: 'no-hold', spec: 'pr', is: 'equals', value: 'none', state: 'holds', sentence: null, unreadable: false }, + ], + refusal: '审稿 refuses: its input verdict@abc1234 is stale', +} +const opened = { + ok: true, + ...list, + workflow: { id: 'content', name: 'Content', source: 'workspace', editable: true, definition, bytes: '{}\n' }, + layout, + preview, +} +const render = (props, given) => { states = [...given]; const tree = expand(h(WorkflowsPage, props)); states = []; return tree } + +test('the table is one row per node with its seven fields, and the edges are listed', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const rows = all(tree).filter((entry) => entry.props?.className === 'bt-wf-row' && entry.props['data-node'] !== undefined) + assert.equal(rows.length, 5) + assert.deepEqual(rows.map((row) => row.props['data-node']), ['选题', '草稿', '审稿', '发布', '归档']) + const head = all(tree).find((entry) => entry.props?.className === 'bt-wf-row bt-wf-row-head') + assert.deepEqual(all(head).filter((entry) => entry.type === 'span').map(text), ['id', 'name', 'role', 'inputs', 'outputs', 'guards', 'refusal']) + const edges = all(tree).filter((entry) => entry.props?.className === 'bt-wf-edge-row').map(text) + assert.equal(edges.length, 4) + assert.equal(edges[0], '选题.text:draft → 草稿.text:draft') +}) + +test('the flowchart is drawn from the table: one box per node, one edge per edge row', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const svg = all(tree).find((entry) => entry.type === 'svg' && entry.props?.['data-boxes'] !== undefined) + assert.equal(svg.props['data-boxes'], '5') + assert.equal(svg.props['data-edges'], '4') + assert.equal(all(tree).filter((entry) => entry.type === 'rect').length, 5) + assert.equal(all(tree).filter((entry) => entry.type === 'line').length, 4) + // The same definition draws the same picture twice: layout is the host's, computed, never stored. + const again = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + assert.equal(all(again).find((entry) => entry.type === 'svg' && entry.props?.['data-boxes'] !== undefined).props.viewBox, svg.props.viewBox) +}) + +test('a guard row prints its refusal and never turns an unreadable fact into a pass', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const guards = all(tree).filter((entry) => entry.props?.className === 'bt-wf-guard') + assert.equal(guards.length, 2) + assert.equal(guards[0].props['data-state'], 'refuse') + assert.match(text(guards[0]), /verdict@abc1234 is stale/) + assert.equal(guards[1].props['data-state'], 'holds') + const blind = render({ actions: {} }, [list, opened, definition, '审稿', { node: '审稿', facts: 'unavailable', rows: [{ id: 'g', spec: 'verdict', is: 'fresh', value: null, state: 'unreadable', sentence: 'could not be read', unreadable: true }], refusal: null }, '', '']) + const row = all(blind).find((entry) => entry.props?.className === 'bt-wf-guard') + assert.equal(row.props['data-state'], 'unreadable') + assert.match(text(blind), /could not read the facts/) + // The hint says none of them is a pass; no row claims the guard holds. + assert.doesNotMatch(text(row), /holds/) +}) + +test('a preset is read-only until Customize, which copies it into a new file', () => { + const calls = [] + const actions = { + workflows: (payload) => { calls.push(['workflows', payload]); return Promise.resolve(payload.op === 'list' ? list : opened) }, + workflowSave: (payload) => { calls.push(['save', payload]); return Promise.resolve({ ok: true, id: 'repo-dev-copy', ...list }) }, + } + const presetOpened = { ...opened, workflow: { id: 'repo-dev', name: 'Repository change', source: 'preset', editable: false, definition, bytes: '' } } + const tree = render({ actions }, [list, presetOpened, definition, '审稿', preview, '', '']) + const labels = all(tree).filter((entry) => entry.type === 'button').map(text) + assert.ok(labels.includes('Customize')) + assert.ok(!labels.includes('Save')) + // Every editable field is disabled, so a preset has no save path at all. + assert.equal(all(tree).filter((entry) => entry.type === 'input').length, 0) + + all(tree).filter((entry) => entry.type === 'button').find((button) => text(button) === 'Customize').props.onClick() + const save = calls.find(([kind]) => kind === 'save') + assert.equal(JSON.stringify(save[1]), JSON.stringify({ op: 'customize', from: 'repo-dev', id: 'repo-dev-copy', name: 'Repository change (mine)' })) +}) + +test('editing one field keeps the edges and the other nodes, and guards round-trip', () => { + const edited = applyNodeField(definition, '审稿', 'name', 'Re-read') + assert.equal(edited.nodes.find((entry) => entry.id === '审稿').name, 'Re-read') + assert.equal(JSON.stringify(edited.nodes.map((entry) => entry.id)), JSON.stringify(definition.nodes.map((entry) => entry.id))) + assert.equal(JSON.stringify(edited.edges), JSON.stringify(definition.edges)) + const listed = applyNodeField(definition, '审稿', 'inputs', 'text:edited, text:notes') + assert.equal(JSON.stringify(listed.nodes.find((entry) => entry.id === '审稿').inputs), JSON.stringify(['text:edited', 'text:notes'])) + assert.equal(JSON.stringify(parseList('a, b ,,c')), JSON.stringify(['a', 'b', 'c'])) + // A guard keeps its id and its own refusal, so saving a table does not drop the sentence it refuses with. + const guards = [{ id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: 'the head is {now}' }] + assert.equal(guardLines(guards), 'verdict is fresh') + assert.equal(JSON.stringify(parseGuards('verdict is fresh\npr is equals none', guards)), JSON.stringify([ + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: 'the head is {now}' }, + { id: 'pr-equals', spec: 'pr', is: 'equals', value: 'none', refusal: '' }, + ])) +}) + +test('the page names the file a definition is, and the project default', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + assert.match(text(tree), /workflows\/content\.json/) + assert.match(text(tree), /project default/) + const picks = all(tree).filter((entry) => entry.props?.['data-id'] !== undefined).map((entry) => entry.props['data-id']) + assert.deepEqual(picks, ['repo-dev', 'blank', 'content']) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a4fbd5a2b..a38af94ff 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -175,6 +175,8 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### New-chat menu - The + menu is a popover anchored next to the sidebar **+** (#195, #173): its `.bt-pane` carries `data-menu` and stays see-through, the menu is a 280px card at the top left with no title row, and a press on the pane outside the card (or Escape) closes it. New chat, New group chat, New Bot and plugin rows open their full pane as before. +- Settings → **Workflows** (`src/client/workflows.js`, #1272 step 3) sits right after **Scheduled tasks**, beside the other automation page, so the page list's existing order and the plugin-slot rule (a `flybot.settings.page` entry is listed after Connectors) are unchanged. It is not a second row in the account menu: the menu keeps its one name per destination. The page reads the presets, the owner's own `workflows/.json` and the project default through `actions.workflows` (`workflows`) and edits through `actions.workflowSave` (`workflow-save`, in `authz`'s `TASK_ENDPOINTS`). A preset is read-only — no input, no Save — and **Customize** copies it into `-copy`; the node table edits name / role / inputs / outputs / guards / refusal (the id is shown, not edited, because an edge names it), and adding or dropping a guard keeps the id and the refusal wording it already had. The flowchart above is drawn from the host's `layoutWorkflow`, so a box per node and an edge per edge row are what the table says; the guard rows under it print the sentence the definition would print right now, or say the fact could not be read — never a pass. Tests: `core/test/workflows-page.test.js`. +- The delivery board draws a section per workflow the definitions in play produce (`payload.workflows`, #1272 step 3): the columns come from the definition's order, each header names its workflow, and a card the section draws is not drawn again in the five legacy columns (`payload.placed`). A board whose cards name no workflow looks exactly as it did. - The sidebar footer button (`ConnectApps` in `sidebar.js`, label **Connect apps** / 连接应用) opens Settings > Connectors through `actions.openConnectors()`, the same call and the same words as the account menu's **Connect apps** row. The shell's plugin panel stays reachable in the Bot surface through the account menu's **Plugins** row (`actions.selectPanel('plugins')`, 插件), without switching to classic Agent mode. - One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only. - Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected. diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 511086e88..8b51c6826 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -56,6 +56,10 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/src/shared/workflow.js` | The workflow engine's pure half (#1272): a workflow is data — `nodes` (each with a required `role` and typed `inputs` / `outputs`), `edges` (`. -> .`) and declared `loops` — and every function below is a function of that file. `validateWorkflow` refuses each way a definition cannot run, with its own sentence (an input nothing produces, an output nothing consumes and that is not a `deliverable`, two producers of one fact, an undeclared cycle, a node with no `role`, a role not on the team, an unknown artifact kind, a misaligned edge, a guard whose `is` is not one of `present` / `fresh` / `equals`, a `column` the board does not have); `workflowColumns` is the board's columns in dependency order, so the node ids are the state and no built-in list exists, and a node carrying `column: false` draws no column at all while a definition that states none keeps the node id as its column (#1265); `layoutWorkflow` is the flowchart (one box per node carrying its role and rails, one edge per `edges[]` row, ranks left to right); `enterNode` / `inputStates` / `renderRefusal` answer whether a card may start and, when it may not, say which input is missing or stale with both revisions, in the node's own `refusal` wording when it has one and in the guard's own wording when that guard carries one; both read a fact by the same parsed key the rest of the engine uses, so the spelling on the card and the spelling on the producer cannot disagree (#1298); `holderOf` is the node's role, which is the card's assignee. #1291's four debts are closed here: one word for one thing (`role`, never `bot`), an unknown guard `is` refuses instead of being read as `present`, `producersOf` / `consumersOf` key on the parsed spec so `text: draft` and `text:draft` are one fact, and `resolvedEdges` reports the artifact it carries once. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-blank.js` | The `blank` preset (#1272): three nodes (`draft` / `review` / `publish`, roles `Writer` / `Editor` / `Publisher`), no guards, artifacts `text:topic -> text:draft -> text:edited -> link:published`. Data, not logic — the same JSON is what a workspace `workflows/blank.json` holds — and the proof that the engine special-cases no node id. Tests: `core/test/workflow.test.js`. | | `core/src/shared/workflow-repo-dev.js` | The `repo-dev` preset (#1272 step 2): one repository change as data — `triage` → `build` → `verify` → `review` → `gate` → `merge` → `release`, roles `Triage` / `Dev` / `SelfTest` / `Reviewer` / `system` / `Release`, and the artifacts `issue` → `issue.ready` → `pr` → `check:tests-ok` → `verdict` → `risk:tier` → `merge-commit` → `release`. Two roles say what they are rather than who: `verify` is `SelfTest` **and** CI, the run being the `check:tests-ok` fact it hands out, and `gate` is held by the Reviewer that judged the pull request, read from the verdict's own `Written-by` through a guard. The gate's guards refuse in the merge gate's own words against `.github/scripts/fixtures/workflow-cases.json`, which no file imports and both sides read. `column` per node: `not-started` / `in-progress` / `done`, with `review` on `false` — a review is the pull request's state, not a third lane (#1265). Tests: `core/test/workflow-repo-dev.test.js`. | +| `core/src/shared/workflow-doc.js` | The definition as a file (#1272 step 3): `workflowBytes(def)` is canonical — fixed key order, two spaces, a trailing newline, a field a definition does not state left out — so a load → save round trip is byte-identical, a definition diffs in git, and `column: false` is written while an absent column is not (they are two different decisions, #1265). `isWorkflowId` is the file-name rule (lowercase, digits, hyphens) and `PRESET_IDS` names the two ids a workspace file may not claim; `copyOfPreset(preset, { id, name })` is what *Customize* writes, and `differingFields(a, b)` is how the copy is held to "differs only in id/name" (acceptance 6). No I/O. Tests: `core/test/workflow-doc.test.js`. | +| `core/src/shared/workflow-board.js` | The columns a definition draws (#1272 step 3, acceptance 1, 2 and 8). `columnPlan(def)` folds `workflowColumns` into the lanes a board draws, in the definition's order, and renames a lane two nodes share after its id rather than after one of them (`build` + `verify` + `gate` are one `in-progress` lane); `placeCard(def, card)` answers the lane a card sits in — `null` for a card naming another workflow, no node, or a node with `column: false`, so nothing is placed by a guess; `boardSections(definitions, cards)` returns one section per workflow that has a card (`{ id, name, columns: [{ id, label, cards }], unplaced }`) and the `legacy` cards that name no workflow, which keep the five columns they had. Tests: `core/test/workflow-board.test.js`. | +| `core/src/shared/workflow-preview.js` | The guard preview the editor shows (#1272 step 3, acceptance 5): `previewNode(def, nodeId, answer)` asks `workflow.js`'s `guardRows` for one row per guard in the definition's order — `holds` (read and passing, no sentence), `refuse` (the producer's own sentence) or `unreadable` — and when the reader answered nothing (`{ ok: false, why }`) every row says it could not read the fact, with the reason named and no pass anywhere. It reads the same function `enterNode` reads, so the preview and a card's refusal cannot disagree. Tests: `core/test/workflow-preview.test.js`. | +| `core/src/host/workflow-store.js` | Where a definition lives (#1272 step 3): the presets read from their modules plus the owner's `workflows/.json` in the workspace, and the only writer. `save` validates with the engine's own `validateWorkflow` (the roster's roles are the team; an empty roster is no team to check), refuses a preset id (`workflow/preset-read-only` — *Customize* is the only way to start from one), writes `workflow-doc.js`'s bytes only when they changed, and answers `changed: false` for an unedited definition (acceptance 7). `customize`, `setDefault` (the project's `workflows/project.json`), `list`, `open` (definition + bytes + layout + preview) and `definitions()` (what the board reads its columns from) round it out; `install` wires `rt.workflowEndpoint` for `workflows` (read) and `workflow-save` (write, in `authz.js`'s `TASK_ENDPOINTS`), so the client writes a file through the host and never to GitHub. A file that does not parse is skipped with one warning instead of breaking the list. Tests: `core/test/workflow-store.test.js`. | | `core/src/host/workflow-facts.js` | The fact providers (#1272 step 2, acceptance 10): one function per fact a guard asks about — `github.labels`, `github.checkAtSha`, `github.verdictAtTip`, `github.mergeable`, `github.prBodyTip`, `task.progress`, `manual.tick` — each taking the answer a reader already got and returning one artifact (spec, revision, value), never fetching. No `GITHUB_TOKEN`, no answer, or an answer missing the field is `{ ok: false, reason: 'unreadable', why }`, never a guess: a fact nobody could read refuses the node it feeds instead of passing it. `workflowFact(name, args)` is the one spelling for a caller that has the name as data, and an unknown name is unreadable with the name in the reason. Tests: `core/test/workflow-repo-dev.test.js`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `friendBotTitle(bot)` / `friendBotOwner(bot)` name a friend's chat from the roster (`Chief (SelfB1233)`) and its owner (`SelfB1233`, the person an approval card names) with one formatter for every client surface (#498); `friendRecord` reads a published roster record (`roster.remoteBots`, name/owner/home at the top level) as well as a chat's `remote`, so the name does not change when the chat appears (#967). `remoteMentionLabel(record, others)` is the one name a mention wears — that title, and on a title two different handles share the home too (`Chief (Bob) · bob2.home`), so the menu, the bubble, the preview and the host's match cannot drift apart (#967). `remoteBotName` / `isAutoOwnerName` / `seatOwnerName` keep group and face-room labels in that format too (#526): Bot Social's ` · ` member label is not a Bot name, and an owner name made up from an install id (`FlyBot 682b8a43`) gives way to the username in the home handle (`Reviewer (flybot682b8a43)`). `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the **发给** picker, mentions, and `list_bots` friends lines: a share that ended is not a place to send to. The sidebar asks a different question through `isShownRemoteChat` (#956): a chat whose Bot left the published list keeps its row, marked *no longer shared*, while only a hide the user set (`bot.hidden`) takes a row out. History stays on disk either way. | @@ -103,6 +107,16 @@ imports the other (#1272 acceptance 9). The facts those guards read come from `unreadable` and never a guess (acceptance 10) — a check run that ran but reported no conclusion included, since "still running" is not a reading (#1298). +**Step 3 is the surface** (#1299). A definition is a file — `workflows/.json` in the workspace — +that the host reads and writes (`workflows`, `workflow-save`) and the page edits; the two presets are +copied by *Customize* and never written, and `workflowBytes` is canonical, so saving an unedited +definition produces identical bytes. The board's columns come from the definitions in play: a card +names its `workflow` and its `node` with two labels (`workflow:`, `node:`), `workflow-board.js` +lays it in that definition's lane, and the view draws one section per workflow with a header naming +the workflow — so two workflows never share a column. A card that names no workflow keeps the five +columns (`raw` / `ready` / `working` / `review` / `done-today`) it has always had, which is what makes +this additive: no existing card moves, and no second card schema is introduced. + ### Default harness and usage ledger (#903) Step 0 of #874 Option B is behavior-neutral: one declarative harness and five usage fields that every `calls` row must carry. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 4fb595edd..e54b778f6 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -567,6 +567,39 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js diff --git a/src/client/i18n.js b/src/client/i18n.js --- a/src/client/i18n.js +++ b/src/client/i18n.js +@@ -5,6 +5,32 @@ + export const NAMESPACE = 'ds-bot' + + const ZH = { ++ // Workflows (#1272 step 3) ++ Workflows: '工作流', ++ 'A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.': ++ '工作流就是一个文件:workflows/.json。列、流程图和拒绝语都从它画出来。', ++ 'Factory presets': '出厂预设', ++ Yours: '你自己的', ++ 'project default': '项目默认', ++ 'Reading workflows…': '正在读取工作流…', ++ Customize: '定制一份', ++ Save: '保存', ++ 'Use for this project': '本项目使用', ++ 'This node has no guards, so nothing refuses it but its inputs.': '这个节点没有 guard,除了输入之外没有别的东西会拒绝它。', ++ Guards: 'Guard', ++ Edges: '边', ++ 'Guards of {node}': '{node} 的 Guard', ++ holds: '成立', ++ 'cannot read': '读不到', ++ refuses: '拒绝', ++ 'The flowchart, drawn from the node table': '流程图,按节点表绘制', ++ 'This host could not read the facts, so these guards say what they could not read — none of them is a pass.': ++ '这台主机读不到事实,所以这些 guard 只说读不到什么——没有一条算通过。', ++ 'Saved workflows/{id}.json': '已保存 workflows/{id}.json', ++ 'Could not open this workflow': '打不开这个工作流', ++ 'This definition cannot be saved': '这个定义不能保存', ++ 'This definition cannot be copied': '这个定义不能复制', ++ '{count} card(s) name a node this workflow draws no column for.': '有 {count} 张卡指向这个工作流不画列的节点。', + // Shared + 'Main Bot': '主 Bot', + Working: '工作中', @@ -12,6 +12,67 @@ const ZH = { 'Unread activity': '有未读', Search: '搜索', @@ -1144,6 +1177,14 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'No Bot': '没有 Bot', 'Paste or type the key': '粘贴或输入密钥', 'Only {name}': '只给 {name}', +@@ -501,7 +501,6 @@ const ZH = { + 'At least {count} characters.': '至少 {count} 个字符。', + 'Replace value': '替换值', + 'Saving…': '保存中…', +- Save: '保存', + 'Allowing…': '允许中…', + Allow: '允许', + '{bot} asked for {name}': '{bot} 请求了 {name}', @@ -528,7 +528,7 @@ const ZH = { 'NAME, e.g. GITHUB_TOKEN': '名字,例如 GITHUB_TOKEN', 'What it is for': '用途', diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index e6fbbbd95..3ce1a61b3 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -25,7 +25,7 @@ diff --git a/index.js b/index.js import * as chatIndex from './src/host/chat-index.js' import * as groups from './src/host/groups.js' import * as approvals from './src/host/approvals.js' -@@ -24,11 +24,16 @@ import * as images from './src/host/images.js' +@@ -24,11 +24,17 @@ import * as images from './src/host/images.js' import * as browser from './src/host/browser.js' import * as secrets from './src/host/secrets.js' import * as connectors from './src/host/connectors.js' @@ -39,6 +39,7 @@ diff --git a/index.js b/index.js +import * as hooks from './src/host/hooks.js' +import * as remoteChat from './src/host/remote-chat.js' +import * as deliveryBoard from './src/host/delivery-board.js' ++import * as workflowStore from './src/host/workflow-store.js' import * as channel from './src/host/channel.js' export { COLORS } from './src/host/constants.js' @@ -47,7 +48,7 @@ diff --git a/index.js b/index.js // 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, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel] -+const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, channel] ++const MODULES = [diagnostics, store, harness, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, decisions, chatIndex, groups, roomMembership, reach, tasks, taskDrive, approvals, resume, memory, parts, images, secrets, connectors, connectorAccess, capabilities, schedules, automationDefault, browser, usage, update, dshUpdate, hooks, remoteChat, deliveryBoard, workflowStore, channel] export function apply(ctx, config = {}) { - createRuntime(ctx, config) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index d6d12f032..55d30e250 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1183,6 +1183,15 @@ diff --git a/src/client/icons.js b/src/client/icons.js export function PlusIcon() { return h('svg', { width: 18, height: 18, viewBox: '0 0 20 20', 'aria-hidden': true }, h('path', { d: 'M10 3.5v13M3.5 10h13', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' })) +@@ -59,6 +59,8 @@ export const DownloadIcon = lineIcon(['M10 3.5v9', 'M6.5 9.5L10 13l3.5-3.5', 'M4 + export const GaugeIcon = lineIcon(['M3.5 13.5a6.5 6.5 0 1 1 13 0', 'M10 13.5l3-4', 'M10 13.5h.01']) + export const PlugIcon = lineIcon(['M7 3v4M13 3v4', 'M5 7h10v2.5a5 5 0 0 1-10 0z', 'M10 14.5V17']) + export const CalendarIcon = lineIcon(['M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z', 'M4 8.75h12', 'M7.25 3.5v3M12.75 3.5v3']) ++export const WorkflowIcon = lineIcon(['M4.5 4h4v3.5h-4z', 'M11.5 12.5h4V16h-4z', 'M6.5 7.5v3.5a2 2 0 0 0 2 2h3']) ++ + export const ConnectorIcon = lineIcon(['M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z', 'M7 10h6']) + export const SwitchIcon = lineIcon(['M4 7h9.5M10.5 3.5L14 7l-3.5 3.5', 'M16 13H6.5M9.5 9.5L6 13l3.5 3.5']) + export const PaperclipIcon = lineIcon(['M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5']) @@ -81,11 +81,31 @@ export const CopyIcon = lineIcon(['M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1 export const HideIcon = lineIcon(['M3 3l14 14', 'M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4', 'M8.5 8.5a2 2 0 0 0 3 3']) export const TrashIcon = lineIcon(['M3.5 5.5h13', 'M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5', 'M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10', 'M8.5 9v4.5M11.5 9v4.5']) @@ -1521,6 +1530,17 @@ diff --git a/src/client/index.js b/src/client/index.js actions.signInEntry = () => ctx.slots.entries?.('settings.models.sign-in')?.[0] ?? null actions.canSignIn = () => Boolean(actions.signInEntry()) actions.signIn = () => ui.set(state => ({ ...state, signIn: (state.signIn ?? 0) + 1 })) +@@ -647,6 +647,10 @@ export function apply(ctx) { + wakeUpdate() + } + actions.repoStars = () => call('repo-stars') ++ // A workflow definition is read and written through the host (#1272 step 3): the page never writes ++ // a file or GitHub itself, and workflow-save is the endpoint authz allows. ++ actions.workflows = (payload) => call('workflows', payload) ++ actions.workflowSave = (payload) => call('workflow-save', payload) + actions.noteTurnTime = (sessionId, turn, key, time) => { + if (sessionId === undefined || !Number.isFinite(turn) || !Number.isFinite(time)) return + turnClock.set((state) => { @@ -690,6 +690,17 @@ export function apply(ctx) { const browserPages = browsers?.pages ?? createSource({}) const browserTabs = browsers?.tabs ?? createSource({}) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 5661127e6..2a2ed9938 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -496,7 +496,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + // auth-whoami is a read of the already-resolved actor; invite/redeem/revoke/remove-member write. + // delivery-board serves the cached snapshot and delivery-probe only GETs GitHub (#1225); both were + // missing, so a window that only reads the team could not open the board at all. -+ const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'delivery-board', 'delivery-probe']) ++ const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', 'room-history', 'exchange', 'usage', 'schedules', 'memory-view', 'browser-wait', 'browser-result', 'diagnostics', 'diagnostics-log', 'diagnostics-clear', 'update-check', 'update-install', 'dsh-update-install', 'repo-stars', 'approval-answer', 'auth-whoami', 'delivery-board', 'delivery-probe', 'workflows']) + + // A chat reference chip names a Session whose snapshot rides to every recipient of + // the message, so the author must be able to read what they share (#53, #106). @@ -826,7 +826,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,32 +319,120 @@ export function install(rt) { +@@ -319,32 +319,126 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -849,6 +849,12 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'delivery-probe': + await rt.load() + return ok(await rt.deliveryProbe(body)) ++ // A workflow definition is read by the page and written by `workflow-save`, which is in ++ // authz's TASK_ENDPOINTS: the client writes a file through the host, never to GitHub ++ // (#1272 step 3, acceptance 7). ++ case 'workflows': ++ case 'workflow-save': ++ return ok(await rt.workflowEndpoint(endpoint, body)) + case 'share-brief': + await rt.load() + await rt.setBriefShare(String(body.id ?? ''), String(body.botId ?? ''), body.share === true, actor) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index ae560484b..c467560c4 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,6 +3,37 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js +@@ -42,6 +42,30 @@ module.exports = __toCommonJS(index_exports); + // src/client/i18n.js + var NAMESPACE = "ds-bot"; + var ZH = { ++ // Workflows (#1272 step 3) ++ Workflows: "工作流", ++ "A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.": "工作流就是一个文件:workflows/.json。列、流程图和拒绝语都从它画出来。", ++ "Factory presets": "出厂预设", ++ Yours: "你自己的", ++ "project default": "项目默认", ++ "Reading workflows…": "正在读取工作流…", ++ Customize: "定制一份", ++ Save: "保存", ++ "Use for this project": "本项目使用", ++ "This node has no guards, so nothing refuses it but its inputs.": "这个节点没有 guard,除了输入之外没有别的东西会拒绝它。", ++ Guards: "Guard", ++ Edges: "边", ++ "Guards of {node}": "{node} 的 Guard", ++ holds: "成立", ++ "cannot read": "读不到", ++ refuses: "拒绝", ++ "The flowchart, drawn from the node table": "流程图,按节点表绘制", ++ "This host could not read the facts, so these guards say what they could not read — none of them is a pass.": "这台主机读不到事实,所以这些 guard 只说读不到什么——没有一条算通过。", ++ "Saved workflows/{id}.json": "已保存 workflows/{id}.json", ++ "Could not open this workflow": "打不开这个工作流", ++ "This definition cannot be saved": "这个定义不能保存", ++ "This definition cannot be copied": "这个定义不能复制", ++ "{count} card(s) name a node this workflow draws no column for.": "有 {count} 张卡指向这个工作流不画列的节点。", + // Shared + "Main Bot": "主 Bot", + Working: "工作中", @@ -49,6 +49,67 @@ var ZH = { "Unread activity": "有未读", Search: "搜索", @@ -577,6 +608,14 @@ diff --git a/client.js b/client.js "No Bot": "没有 Bot", "Paste or type the key": "粘贴或输入密钥", "Only {name}": "只给 {name}", +@@ -555,7 +555,6 @@ var ZH = { + "At least {count} characters.": "至少 {count} 个字符。", + "Replace value": "替换值", + "Saving…": "保存中…", +- Save: "保存", + "Allowing…": "允许中…", + Allow: "允许", + "{bot} asked for {name}": "{bot} 请求了 {name}", @@ -582,7 +582,7 @@ var ZH = { "NAME, e.g. GITHUB_TOKEN": "名字,例如 GITHUB_TOKEN", "What it is for": "用途", @@ -1392,12 +1431,12 @@ diff --git a/client.js b/client.js } -var eyeMarkup = ([cx, cy, w, h34, tilt]) => ``; -var happyEyeMarkup = ([cx, cy, w, h34, tilt]) => ``; -+var eyeMarkup = ([cx, cy, w, h48, tilt]) => ``; -+var happyEyeMarkup = ([cx, cy, w, h48, tilt]) => ``; ++var eyeMarkup = ([cx, cy, w, h49, tilt]) => ``; ++var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,162 +1242,16 @@ function maskOf(shape) { +@@ -1242,220 +1242,96 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1420,7 +1459,16 @@ diff --git a/client.js b/client.js - for (const listener of [...listeners2]) listener(); - } - }; --} ++// src/client/cluster-order.js ++function clusterOrder(people2) { ++ const list = Array.isArray(people2) ? people2 : []; ++ const remote = (bot) => Boolean(bot?.remote); ++ if (!list.some(remote)) return list; ++ const locals = list.filter((bot) => !remote(bot)); ++ const remotes = list.filter(remote); ++ if (list.length <= 4) return [...remotes, ...locals]; ++ return [...locals, ...remotes]; + } -var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], agents: [], exchangeTurns: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; -var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); -function indexRoster(value) { @@ -1435,24 +1483,87 @@ diff --git a/client.js b/client.js - for (const agent of value.agents ?? []) { - agentsById[agent.id] = agent; - for (const sessionId of agent.sessions ?? []) agentOf[sessionId] = agent.id; -- } ++ ++// src/shared/mentions.js ++var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; ++var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; ++var ADDRESS_BEFORE = /[a-z0-9_./]/i; ++var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; ++var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); ++var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); ++function nameProblem(name) { ++ const text = String(name ?? "").trim(); ++ if (text === "") return "A Bot needs a name"; ++ if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; ++ if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; ++ return void 0; ++} ++function referenceSpans(text, labels = []) { ++ const spans = []; ++ for (const label of [...labels].sort((a, b) => b.length - a.length)) { ++ const mention = `@${label}`; ++ for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { ++ const end = at + mention.length; ++ if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; ++ spans.push({ start: at, end, label }); ++ break; ++ } + } - return { ...value, agents: value.agents ?? [], byId, roomsById, agentsById, agentOf, ready: true }; --} ++ return spans.sort((a, b) => a.start - b.start); + } -function isAgentSession(roster, cache, sessionId) { - if (sessionId === void 0 || sessionId === null || sessionId === "") return false; - if (roster.ready) return roster.agentOf[sessionId] !== void 0; - return cache.has(sessionId); --} ++function mentionSpans(text, bots, { references = [] } = {}) { ++ const value = String(text ?? ""); ++ const skip = [ ++ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ++ ...referenceSpans(value, references) ++ ]; ++ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); ++ const spans = []; ++ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { ++ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; ++ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); ++ const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word)); ++ if (!bot && !all) continue; ++ const end = at + 1 + (bot ? bot.name.length : all.length); ++ spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); ++ at = end - 1; ++ } ++ return spans; + } -function countCards(state, sessionId, callIds, delta) { - const own = { ...state[sessionId] }; - for (const callId of callIds) { - const count = (own[callId] ?? 0) + delta; - if (count > 0) own[callId] = count; - else delete own[callId]; -- } ++function unmatchedMentions(text, bots, { references = [] } = {}) { ++ const value = String(text ?? ""); ++ const skip = [ ++ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), ++ ...referenceSpans(value, references) ++ ]; ++ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); ++ const missing = []; ++ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { ++ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; ++ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); ++ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); ++ if (bot || all) { ++ at += 1 + (bot ? bot.name.length : all.length) - 1; ++ continue; ++ } ++ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; ++ if (word) missing.push(`@${word}`); + } - return { ...state, [sessionId]: own }; --} -- ++ return missing; + } + -// src/client/themes.js -var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; -var THEMES = { @@ -1555,59 +1666,80 @@ diff --git a/client.js b/client.js - else delete customThemes[id]; - registry.set((value) => value + 1); - }; -+// src/client/cluster-order.js -+function clusterOrder(people2) { -+ const list = Array.isArray(people2) ? people2 : []; -+ const remote = (bot) => Boolean(bot?.remote); -+ if (!list.some(remote)) return list; -+ const locals = list.filter((bot) => !remote(bot)); -+ const remotes = list.filter(remote); -+ if (list.length <= 4) return [...remotes, ...locals]; -+ return [...locals, ...remotes]; - } +-} -var allThemes = () => ({ ...THEMES, ...customThemes }); - - // src/shared/mentions.js - var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; -@@ -1445,14 +1445,36 @@ function mentionSpans(text, bots, { references = [] } = {}) { - } - return spans; - } - -+function unmatchedMentions(text, bots, { references = [] } = {}) { -+ const value = String(text ?? ""); -+ const skip = [ -+ ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), -+ ...referenceSpans(value, references) -+ ]; -+ const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); -+ const missing = []; -+ for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { -+ if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; -+ const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); -+ const all = bot ? void 0 : ALL_WORDS.find((word2) => fitsAt(value, at + 1, word2)); -+ if (bot || all) { -+ at += 1 + (bot ? bot.name.length : all.length) - 1; -+ continue; -+ } -+ const word = /^[^\s@,,。.!?!?::;;、()()"'“”]{1,40}/.exec(value.slice(at + 1))?.[0]; -+ if (word) missing.push(`@${word}`); -+ } -+ return missing; -+} -+ - // src/client/text.js - var MENTION_ORIGIN = "https://bots.local/"; - function linkMentions(text, bots, selfId) { - if (!text || !text.includes("@")) return text; -+ const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); - let out = ""; - let cursor = 0; +-// src/shared/mentions.js +-var ALL_WORDS = ["all", "everyone", "所有人", "全体"]; +-var NAME_GOES_ON = /^(?:[a-z0-9_\-/\\]|\.[a-z0-9_-])/i; +-var ADDRESS_BEFORE = /[a-z0-9_./]/i; +-var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g; +-var fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text.slice(at + word.length)); +-var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end); +-function nameProblem(name) { +- const text = String(name ?? "").trim(); +- if (text === "") return "A Bot needs a name"; +- if (/[@`\r\n]/.test(text)) return "A Bot name cannot contain @, ` or a line break"; +- if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`; +- return void 0; +-} +-function referenceSpans(text, labels = []) { +- const spans = []; +- for (const label of [...labels].sort((a, b) => b.length - a.length)) { +- const mention = `@${label}`; +- for (let at = text.indexOf(mention); at !== -1; at = text.indexOf(mention, at + 1)) { +- const end = at + mention.length; +- if (at > 0 && ADDRESS_BEFORE.test(text[at - 1]) || NAME_GOES_ON.test(text.slice(end)) || overlaps(spans, at, end)) continue; +- spans.push({ start: at, end, label }); +- break; +- } +- } +- return spans.sort((a, b) => a.start - b.start); +-} +-function mentionSpans(text, bots, { references = [] } = {}) { +- const value = String(text ?? ""); +- const skip = [ +- ...[...value.matchAll(LITERAL)].map((match) => ({ start: match.index, end: match.index + match[0].length })), +- ...referenceSpans(value, references) +- ]; +- const byLength = bots.filter((bot) => bot?.name).sort((a, b) => b.name.length - a.name.length); +- const spans = []; +- for (let at = value.indexOf("@"); at !== -1; at = value.indexOf("@", at + 1)) { +- if (at > 0 && ADDRESS_BEFORE.test(value[at - 1]) || overlaps(skip, at, at + 1)) continue; +- const bot = byLength.find((candidate) => fitsAt(value, at + 1, candidate.name)); +- const all = bot ? void 0 : ALL_WORDS.find((word) => fitsAt(value, at + 1, word)); +- if (!bot && !all) continue; +- const end = at + 1 + (bot ? bot.name.length : all.length); +- spans.push(bot ? { start: at, end, bot } : { start: at, end, all: true }); +- at = end - 1; +- } +- return spans; +-} +- +-// src/client/text.js +-var MENTION_ORIGIN = "https://bots.local/"; +-function linkMentions(text, bots, selfId) { +- if (!text || !text.includes("@")) return text; +- let out = ""; +- let cursor = 0; - for (const span of mentionSpans(text, bots)) { +- if (!span.bot || span.bot.id === selfId) continue; +- out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; +- cursor = span.end; ++// src/client/text.js ++var MENTION_ORIGIN = "https://bots.local/"; ++function linkMentions(text, bots, selfId) { ++ if (!text || !text.includes("@")) return text; ++ const ordered = [...bots ?? []].sort((a, b) => Number(Boolean(a?.remote)) - Number(Boolean(b?.remote))); ++ let out = ""; ++ let cursor = 0; + for (const span of mentionSpans(text, ordered)) { - if (!span.bot || span.bot.id === selfId) continue; - out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; - cursor = span.end; ++ if (!span.bot || span.bot.id === selfId) continue; ++ out += `${text.slice(cursor, span.start)}[${text.slice(span.start + 1, span.end).replace(/[[\]\\]/g, "\\$&")}](${MENTION_ORIGIN}${span.bot.id})`; ++ cursor = span.end; + } + return cursor === 0 ? text : out + text.slice(cursor); + } @@ -1463,6 +1463,20 @@ function mentionTarget(event) { const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null; return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null; @@ -1724,7 +1856,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,6970 @@ function usageOf(list, ids) { +@@ -1624,835 +1624,1788 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -1985,27 +2117,11 @@ diff --git a/client.js b/client.js - const animation = node.animate([{ height: `${from}px` }, { height: `${to}px` }], { duration: SPRING_SHAPE.duration, easing: SPRING_SHAPE.easing, id: "bt-morph" }); - animation.onfinish = () => { - delete node.dataset.morph; -- }; -- animation.oncancel = () => { -- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; -- }; -- }); +function isAgentSession(roster, cache, sessionId) { + if (sessionId === void 0 || sessionId === null || sessionId === "") return false; + if (roster.ready) return roster.agentOf[sessionId] !== void 0; + return cache.has(sessionId); - } --function useLinger(value, ms) { -- const [kept, setKept] = (0, import_react.useState)(value); -- (0, import_react.useEffect)(() => { -- if (value) { -- setKept(value); -- return void 0; -- } -- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); -- return () => clearTimeout(timer); -- }, [value]); -- return [value || kept, !value && Boolean(kept)]; ++} +function countCards(state, sessionId, callIds, delta) { + const own = { ...state[sessionId] }; + for (const callId of callIds) { @@ -2014,108 +2130,8 @@ diff --git a/client.js b/client.js + else delete own[callId]; + } + return { ...state, [sessionId]: own }; - } - --// src/client/styles.js --var SHELL_CSS = ` --body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} --body[data-ds-dark-theme]{--bt-ink:#f2f2f2;--bt-ink-2:rgba(242,242,242,.6);--bt-ink-3:rgba(242,242,242,.36);--bt-bubble:#262626;--bt-sidebar:#1b1b1b;--bt-main:#141414;--bt-line:rgba(255,255,255,.08);--bt-line-2:rgba(255,255,255,.15);--bt-line-weak:rgba(255,255,255,.1);--bt-hover:rgba(160,160,160,.12);--bt-active:rgba(160,160,160,.2);--bt-card:#1f1f1f;--bt-user:#f2f2f2;--bt-user-ink:#141414;--bt-tag-bg:rgba(255,255,255,.09);--bt-tag-ink:rgba(242,242,242,.62)} --${Object.entries(INKS).map(([name, [light]]) => `body{--bt-ink-${name}:${light}}`).join("")} --${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt-ink-${name}:${dark}}`).join("")} --.bt-mark{display:inline-block;flex:none;position:relative;line-height:0} --.bt-mark svg{width:100%;height:100%;overflow:visible} --.bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} --.bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} --.bt-mark .bt-b{transform-origin:50% 100%} --.bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} --/* Motion levels (Settings → Bots → Animation) set body[data-bt-motion]: quiet moves a -- mark only for its state; normal, the default, adds petting, pokes and eyes that follow -- the pointer; lively adds blinks and fidgets at rest and a question riding on the head. */ --body[data-bt-motion=lively] .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{animation:bt-blink-idle 5s linear infinite;animation-delay:var(--bt-delay,0s)} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle]:is([data-fidget=hop],[data-fidget=sway],[data-fidget=stretch]) :is(.bt-b,.bt-mark-img){animation:var(--bt-fidget) 9s ease-in-out var(--bt-fidget-delay,0s) infinite} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=hop]{--bt-fidget:bt-fidget-hop} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=sway]{--bt-fidget:bt-fidget-sway} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=stretch]{--bt-fidget:bt-fidget-stretch} --body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=look] .bt-eyes{animation:bt-fidget-look 9s ease-in-out var(--bt-fidget-delay,0s) infinite} --@keyframes bt-fidget-hop{0%,8%,100%{transform:none}3%{transform:translateY(-10%) scale(.97,1.04)}5.5%{transform:translateY(0) scale(1.05,.95)}} --@keyframes bt-fidget-sway{0%,10%,100%{transform:none}2.5%{transform:rotate(-8deg)}5%{transform:rotate(7deg)}7.5%{transform:rotate(-3deg)}} --@keyframes bt-fidget-stretch{0%,9%,100%{transform:none}4%{transform:scale(.94,1.08)}7%{transform:scale(1.03,.97)}} --@keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} --body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} --body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} --@keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} --/* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its -- size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes -- closed in a happy arc, and springs back when the pointer leaves. */ --:is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster)::before{content:'';position:absolute;inset:-20%;border-radius:50%} --.bt-mark[data-live] .bt-b,.bt-mark[data-live] .bt-mark-img{transition:rotate .5s cubic-bezier(.34,1.56,.64,1),translate .5s cubic-bezier(.34,1.56,.64,1),scale .3s ease-out} --.bt-mark[data-live] .bt-eye{transition:scale .14s ease-in} --.bt-mark[data-live] .bt-eye-happy{transition:opacity .12s ease-out} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]):not(.bt-mark-poke) :is(.bt-b,.bt-mark-img){rotate:8deg;translate:5% -1%;animation:bt-nuzzle .78s ease-in-out .32s infinite} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{scale:1 0;animation:none} --body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye-happy{opacity:1;transition-delay:.1s} --@keyframes bt-nuzzle{50%{rotate:14deg;translate:8% -3%;scale:1.03 .97}} --.bt-mark[data-gaze] .bt-b{translate:calc(var(--bt-gx,0) * 5%) calc(var(--bt-gy,0) * 4%);transition:translate .18s ease-out} --.bt-mark[data-gaze] .bt-gaze{transform:translate(calc(var(--bt-gx,0) * 12%),calc(var(--bt-gy,0) * 11%));transition:transform .18s ease-out} --body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-b{translate:none} --body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} --.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} --.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} --.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} --.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} --.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} --.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} --.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} --.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} --/* A question for you hops twice when it arrives and the badge keeps it in view after; -- a lively mark keeps hopping. */ --.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} --.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} --body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} --.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} --.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} --@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} --.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} --.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} --.bt-mark[data-poke]{cursor:pointer} --body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} --body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} --@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} --@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} --@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} --@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} --@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} --@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} --@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} --@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} --@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} --@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} --@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} --@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} --@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} --/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ --@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} --@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} --@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} --.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} --.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} --.bt-badge-admin svg{width:7px;height:7px} --.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} --.bt-badge-main svg{width:100%;height:100%;overflow:visible} --.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} --.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} --.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} --.bt-cluster>*{position:absolute} --.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} --.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} --.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} --.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} --.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} ++} ++ +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -2134,11 +2150,14 @@ diff --git a/client.js b/client.js + const clamp = (value) => Math.max(-1, Math.min(1, value)); + node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); + node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -+ }; + }; +- animation.oncancel = () => { +- if (!node.getAnimations().some((other) => other.id === "bt-morph" && other !== animation)) delete node.dataset.morph; + const onMove = (event) => { + point = { x: event.clientX, y: event.clientY }; + if (!frame) frame = requestAnimationFrame(place); -+ }; + }; +- }); + const onLeave = () => { + point = null; + ref.current?.style.setProperty("--bt-gx", "0"); @@ -2152,7 +2171,9 @@ diff --git a/client.js b/client.js + document.documentElement.removeEventListener("pointerleave", onLeave); + }; + }, [enabled]); -+} + } +-function useLinger(value, ms) { +- const [kept, setKept] = (0, import_react.useState)(value); +var FIDGETS = ["hop", "sway", "stretch", "look"]; +function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { + const look = given ?? (bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot)); @@ -2232,17 +2253,22 @@ diff --git a/client.js b/client.js +function useSettled(state) { + const prev = (0, import_react.useRef)(state); + const [settled, setSettled] = (0, import_react.useState)(false); -+ (0, import_react.useEffect)(() => { + (0, import_react.useEffect)(() => { +- if (value) { +- setKept(value); + const was = prev.current; + prev.current = state; + if (state !== "idle") { + setSettled(false); -+ return void 0; -+ } + return void 0; + } +- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); + if (was === "idle" || was === "alert") return void 0; + setSettled(true); + const timer = setTimeout(() => setSettled(false), 1800); -+ return () => clearTimeout(timer); + return () => clearTimeout(timer); +- }, [value]); +- return [value || kept, !value && Boolean(kept)]; + }, [state]); + return settled; +} @@ -2335,13 +2361,114 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); -+} + } --.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} --.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} --.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} --.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} +-// src/client/styles.js +-var SHELL_CSS = ` +-body{--bt-ink:#141414;--bt-ink-2:rgba(20,20,20,.6);--bt-ink-3:rgba(20,20,20,.36);--bt-bubble:#eeeeee;--bt-sidebar:#f7f7f7;--bt-main:#fcfcfc;--bt-line:rgba(20,20,20,.08);--bt-line-2:rgba(20,20,20,.15);--bt-line-weak:rgba(20,20,20,.1);--bt-hover:rgba(119,119,119,.09);--bt-active:rgba(119,119,119,.17);--bt-card:#ffffff;--bt-accent:#4d6bfe;--bt-green:#00c972;--bt-star:#ff9800;--bt-user:#070707;--bt-user-ink:#fcfcfc;--bt-tag-bg:rgba(20,20,20,.06);--bt-tag-ink:rgba(20,20,20,.56)} +-body[data-ds-dark-theme]{--bt-ink:#f2f2f2;--bt-ink-2:rgba(242,242,242,.6);--bt-ink-3:rgba(242,242,242,.36);--bt-bubble:#262626;--bt-sidebar:#1b1b1b;--bt-main:#141414;--bt-line:rgba(255,255,255,.08);--bt-line-2:rgba(255,255,255,.15);--bt-line-weak:rgba(255,255,255,.1);--bt-hover:rgba(160,160,160,.12);--bt-active:rgba(160,160,160,.2);--bt-card:#1f1f1f;--bt-user:#f2f2f2;--bt-user-ink:#141414;--bt-tag-bg:rgba(255,255,255,.09);--bt-tag-ink:rgba(242,242,242,.62)} +-${Object.entries(INKS).map(([name, [light]]) => `body{--bt-ink-${name}:${light}}`).join("")} +-${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt-ink-${name}:${dark}}`).join("")} +-.bt-mark{display:inline-block;flex:none;position:relative;line-height:0} +-.bt-mark svg{width:100%;height:100%;overflow:visible} +-.bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} +-.bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} +-.bt-mark .bt-b{transform-origin:50% 100%} +-.bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} +-/* Motion levels (Settings → Bots → Animation) set body[data-bt-motion]: quiet moves a +- mark only for its state; normal, the default, adds petting, pokes and eyes that follow +- the pointer; lively adds blinks and fidgets at rest and a question riding on the head. */ +-body[data-bt-motion=lively] .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{animation:bt-blink-idle 5s linear infinite;animation-delay:var(--bt-delay,0s)} +-body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle]:is([data-fidget=hop],[data-fidget=sway],[data-fidget=stretch]) :is(.bt-b,.bt-mark-img){animation:var(--bt-fidget) 9s ease-in-out var(--bt-fidget-delay,0s) infinite} +-body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=hop]{--bt-fidget:bt-fidget-hop} +-body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=sway]{--bt-fidget:bt-fidget-sway} +-body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=stretch]{--bt-fidget:bt-fidget-stretch} +-body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=look] .bt-eyes{animation:bt-fidget-look 9s ease-in-out var(--bt-fidget-delay,0s) infinite} +-@keyframes bt-fidget-hop{0%,8%,100%{transform:none}3%{transform:translateY(-10%) scale(.97,1.04)}5.5%{transform:translateY(0) scale(1.05,.95)}} +-@keyframes bt-fidget-sway{0%,10%,100%{transform:none}2.5%{transform:rotate(-8deg)}5%{transform:rotate(7deg)}7.5%{transform:rotate(-3deg)}} +-@keyframes bt-fidget-stretch{0%,9%,100%{transform:none}4%{transform:scale(.94,1.08)}7%{transform:scale(1.03,.97)}} +-@keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} +-body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} +-body[data-bt-motion=lively] :is(.bt-row,.bt-tile)>.bt-mark:hover .bt-badge-alert{animation:none} +-@keyframes bt-q-ride{0%,55%,100%{transform:translateY(0)}20%{transform:translateY(calc(var(--bt-size,36px) * -.14))}40%{transform:translateY(0)}} +-/* Petting: with the pointer on a sidebar avatar or just around it (a ring a fifth of its +- size wide), a resting mark leans into the pointer and keeps nuzzling it, its eyes +- closed in a happy arc, and springs back when the pointer leaves. */ +-:is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster)::before{content:'';position:absolute;inset:-20%;border-radius:50%} +-.bt-mark[data-live] .bt-b,.bt-mark[data-live] .bt-mark-img{transition:rotate .5s cubic-bezier(.34,1.56,.64,1),translate .5s cubic-bezier(.34,1.56,.64,1),scale .3s ease-out} +-.bt-mark[data-live] .bt-eye{transition:scale .14s ease-in} +-.bt-mark[data-live] .bt-eye-happy{transition:opacity .12s ease-out} +-body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]):not(.bt-mark-poke) :is(.bt-b,.bt-mark-img){rotate:8deg;translate:5% -1%;animation:bt-nuzzle .78s ease-in-out .32s infinite} +-body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye{scale:1 0;animation:none} +-body:not([data-bt-motion=quiet]) :is(.bt-row,.bt-tile)>:is(.bt-mark,.bt-cluster):hover .bt-mark[data-live]:is([data-state=idle],[data-state=alert]) .bt-eye-happy{opacity:1;transition-delay:.1s} +-@keyframes bt-nuzzle{50%{rotate:14deg;translate:8% -3%;scale:1.03 .97}} +-.bt-mark[data-gaze] .bt-b{translate:calc(var(--bt-gx,0) * 5%) calc(var(--bt-gy,0) * 4%);transition:translate .18s ease-out} +-.bt-mark[data-gaze] .bt-gaze{transform:translate(calc(var(--bt-gx,0) * 12%),calc(var(--bt-gy,0) * 11%));transition:transform .18s ease-out} +-body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-b{translate:none} +-body[data-bt-motion=quiet] .bt-mark[data-gaze] .bt-gaze{transform:none} +-.bt-mark[data-state=thinking] .bt-b{animation:bt-ponder 2.2s ease-in-out infinite} +-.bt-mark[data-state=thinking] .bt-eyes{animation:bt-squint 2.2s ease-in-out infinite} +-.bt-mark[data-state=searching] .bt-b{animation:bt-seek 1s ease-in-out infinite} +-.bt-mark[data-state=searching] .bt-eyes{animation:bt-scan 1.6s ease-in-out infinite} +-.bt-mark[data-state=working] .bt-b{animation:bt-bob 1.6s ease-in-out infinite} +-.bt-mark[data-state=working]:is([data-shape=star],[data-shape=hex],[data-shape=plus],[data-shape=gem],[data-shape=magnet],[data-shape=ring]) .bt-b{animation:bt-wiggle 1.4s ease-in-out infinite;transform-origin:50% 50%} +-.bt-mark[data-state=working]:is([data-shape=gumdrop],[data-shape=peanut],[data-shape=cushion],[data-shape=squircle],[data-shape=moon],[data-shape=wave]) .bt-b{animation:bt-squash 1.2s ease-in-out infinite} +-.bt-mark[data-state=working] .bt-eye{animation:bt-blink 2.4s ease-in-out infinite} +-.bt-mark[data-state=sending] .bt-b{animation:bt-dash 1.1s cubic-bezier(.4,0,.2,1) infinite} +-.bt-mark[data-state=orbit] .bt-b{animation:bt-sway 2.6s ease-in-out infinite;transform-origin:50% 50%} +-.bt-mark[data-state=orbit] .bt-eyes{animation:bt-roll 2.6s linear infinite} +-/* A question for you hops twice when it arrives and the badge keeps it in view after; +- a lively mark keeps hopping. */ +-.bt-mark[data-state=alert] .bt-b{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} +-.bt-mark[data-shape=image]:not([data-state=idle]) .bt-mark-img{animation:bt-bob 1.6s ease-in-out infinite} +-.bt-mark[data-shape=image][data-state=alert] .bt-mark-img{animation:bt-hop 1.3s cubic-bezier(.3,.7,.4,1) 2} +-body[data-bt-motion=lively] .bt-mark[data-state=alert] :is(.bt-b,.bt-mark-img){animation-iteration-count:infinite} +-.bt-mark[data-state=done] .bt-b{animation:bt-done 1.8s linear 1} +-.bt-mark[data-shape=image][data-state=done] .bt-mark-img{animation:bt-done 1.8s linear 1;transform-origin:50% 100%} +-@keyframes bt-done{0%{transform:none;animation-timing-function:cubic-bezier(.4,0,.2,1)}13.3%{transform:scale(1.12,.88);animation-timing-function:cubic-bezier(.4,0,.2,1)}25.6%{transform:scale(.93,1.09);animation-timing-function:cubic-bezier(.4,0,.2,1)}37.8%{transform:scale(1.04,.97);animation-timing-function:cubic-bezier(.4,0,.2,1)}51.1%,100%{transform:none}} +-.bt-mark-poke .bt-b{animation:bt-poke .8s cubic-bezier(.3,.7,.4,1) 1!important;transform-origin:50% 60%!important} +-.bt-mark-poke .bt-mark-img{animation:bt-poke-img .6s ease-out 1!important} +-.bt-mark[data-poke]{cursor:pointer} +-body[data-bt-motion=quiet] .bt-mark-poke :is(.bt-b,.bt-mark-img){animation:none!important} +-body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} +-@keyframes bt-bob{0%,100%{transform:translateY(0) rotate(0)}30%{transform:translateY(-4%) rotate(-5deg)}65%{transform:translateY(0) rotate(4deg)}} +-@keyframes bt-wiggle{0%,100%{transform:rotate(0) scale(1)}25%{transform:rotate(-12deg) scale(1.04)}75%{transform:rotate(12deg) scale(1.04)}} +-@keyframes bt-squash{0%,100%{transform:scale(1,1)}30%{transform:scale(1.08,.9)}55%{transform:translateY(-4%) scale(.94,1.08)}80%{transform:scale(1.02,.98)}} +-@keyframes bt-ponder{0%,100%{transform:translateY(0) rotate(4deg)}50%{transform:translateY(-3%) rotate(7deg)}} +-@keyframes bt-squint{0%,100%{transform:translate(0,-4%) scaleY(.7)}50%{transform:translate(5%,-5%) scaleY(.78)}} +-@keyframes bt-seek{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-5%) rotate(-1deg)}} +-@keyframes bt-scan{0%,100%{transform:translateX(-12%)}50%{transform:translateX(12%)}} +-@keyframes bt-dash{0%,100%{transform:translateX(0) rotate(0)}35%{transform:translateX(9%) rotate(7deg)}55%{transform:translateX(-2%) rotate(-3deg)}} +-@keyframes bt-sway{0%,100%{transform:rotate(-10deg)}50%{transform:rotate(10deg)}} +-@keyframes bt-roll{0%,100%{transform:translate(12%,0)}25%{transform:translate(0,9%)}50%{transform:translate(-12%,0)}75%{transform:translate(0,-9%)}} +-@keyframes bt-hop{0%,55%,100%{transform:translateY(0) scale(1,1)}20%{transform:translateY(-14%) scale(.96,1.05)}40%{transform:translateY(0) scale(1.06,.94)}} +-@keyframes bt-poke{0%{transform:none}55%{transform:translateY(-16%) rotate(360deg)}78%{transform:translateY(0) scale(1.08,.92) rotate(360deg)}100%{transform:rotate(360deg)}} +-@keyframes bt-poke-img{0%,100%{transform:scale(1)}40%{transform:scale(1.14) rotate(-6deg)}} +-/* Idle clip: two .26s blinks (lid to 3%, then a 6% overshoot) at 1.48s and 3.46s. */ +-@keyframes bt-blink-idle{0%,29.6%,35%,69.2%,74.6%,100%{transform:scaleY(1)}32.2%,71.8%{transform:scaleY(.03)}33.8%,73.4%{transform:scaleY(1.06)}} +-@keyframes bt-blink{0%,42%,50%,100%{transform:scaleY(1)}46%{transform:scaleY(.03)}48%{transform:scaleY(1.06)}} +-@media (prefers-reduced-motion:reduce){.bt-mark *{animation:none!important;transition:none!important}} +-.bt-badge{position:absolute;right:0;bottom:0;width:12px;height:12px;border-radius:50%;corner-shape:round;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-sidebar)} +-.bt-badge-admin{background:color-mix(in srgb,var(--bt-star) 82%,var(--bt-sidebar))} +-.bt-badge-admin svg{width:7px;height:7px} +-.bt-badge-main{right:calc(var(--bt-size,36px) * -.07);bottom:calc(var(--bt-size,36px) * -.07);width:clamp(14px,calc(var(--bt-size,36px) * .36),22px);height:clamp(14px,calc(var(--bt-size,36px) * .36),22px);border-radius:0;box-shadow:none} +-.bt-badge-main svg{width:100%;height:100%;overflow:visible} +-.bt-badge-main path{fill:var(--bt-star);stroke:var(--bt-sidebar);stroke-width:2.6;stroke-linejoin:round;paint-order:stroke} +-.bt-badge-alert{background:color-mix(in srgb,var(--bt-accent) 78%,var(--bt-sidebar));color:var(--bt-accent-ink,#fff);font-size:8.5px;line-height:12px;font-weight:600;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-badge-working{background:color-mix(in srgb,var(--bt-green) 82%,var(--bt-sidebar));width:8px;height:8px;right:1px;bottom:1px} +-.bt-cluster{position:relative;display:inline-block;flex:none;line-height:0} +-.bt-cluster>*{position:absolute} +-.bt-cluster-more,.bt-cluster-you{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:var(--bt-active);color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;font-weight:500;line-height:1;font-variant-numeric:tabular-nums} +-.bt-cluster-you{background:color-mix(in srgb,var(--bt-ink) 12%,transparent)} +-.bt-stack{display:inline-flex;align-items:center;flex:none;line-height:0} +-.bt-stack>*+*{margin-left:calc(var(--bt-stack-size) * -.3)} +-.bt-stack-more{font-size:11px;line-height:16px;color:var(--bt-ink-3);margin-left:3px!important} +- +-.bt-side{display:flex;flex-direction:column;min-height:0;flex:1 1 auto;gap:2px;padding:0 12px 8px 0;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-side-top{display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:6px 5px 0 0} +-.bt-brand{display:inline-flex;align-items:center;gap:6px;margin-right:auto;padding:4px 8px 4px 4px;border:0;border-radius:8px;background:none;cursor:pointer;font-family:inherit;font-size:15px;line-height:20px;font-weight:600;letter-spacing:-.01em;color:var(--bt-ink);white-space:nowrap} +-.bt-brand-whale{display:inline-flex;color:var(--bt-accent)} +-.bt-round{width:36px;height:36px;border-radius:50%;border:1px solid var(--bt-line);background:var(--bt-main);color:var(--bt-ink);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:background-color .12s ease,transform .12s ease} -.bt-round:hover{background:var(--bt-hover)} -.bt-round:active{transform:scale(.94)} -.bt-list{display:flex;flex-direction:column;gap:3px;overflow-y:auto;min-height:0;flex:1 1 auto} @@ -2412,29 +2539,29 @@ diff --git a/client.js b/client.js + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} -+function RoomInvitePreview({ h: h48, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { -+ if (!preview || typeof h48 !== "function") return null; ++function RoomInvitePreview({ h: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h49 !== "function") return null; + const at = `${kind}`; + const child = (name) => `${at}-${name}`; + const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); -+ return h48( ++ return h49( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, -+ h48("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), -+ preview.lead ? h48("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, -+ preview.people ? h48("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, -+ preview.expiresAt ? h48("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, -+ onJoin || onClear ? h48( ++ h49("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h49("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h49("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h49("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h49( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, -+ showClear && onClear ? h48("button", { ++ showClear && onClear ? h49("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, -+ onJoin ? h48("button", { ++ onJoin ? h49("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), @@ -2663,6 +2790,28 @@ diff --git a/client.js b/client.js -.bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} -.bt-send:disabled{opacity:.35;cursor:default} -.bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} +- +-.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} +-.bt-dialog .bt-head{height:62px} +-.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} +-.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} +-.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} +-.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} +-@keyframes bt-sheet-in{from{transform:translateY(24px)}} +-@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} +-.bt-exchange .bt-dialog{position:relative} +-.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} +-.bt-exchange .bt-tools{display:none} +-.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} +-.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} +-.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} +-@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} +-@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} +-@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} +-.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} +-.bt-soft:hover{background:var(--bt-active)} +-.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} +// src/client/flybot-slots.js +var import_react2 = require("react"); +var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); @@ -2921,28 +3070,6 @@ diff --git a/client.js b/client.js +.bt-file-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 0 30px; } +.bt-file-chip { font-size: 12px; padding: 3px 8px; border-radius: 8px; border: 1px solid var(--bt-line, rgba(0,0,0,.15)); background: transparent; cursor: pointer; } --.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} --.bt-dialog .bt-head{height:62px} --.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} --.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} --.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} --.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} --@keyframes bt-sheet-in{from{transform:translateY(24px)}} --@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} --.bt-exchange .bt-dialog{position:relative} --.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} --.bt-exchange .bt-tools{display:none} --.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} --.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} --.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} --@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} --@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} --.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} --.bt-soft:hover{background:var(--bt-active)} --.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} -- -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} -.bt-panel[data-instant]{transition:none} @@ -2977,7 +3104,35 @@ diff --git a/client.js b/client.js -.bt-kv{display:flex;justify-content:space-between;gap:8px;font-size:12px;line-height:20px;color:var(--bt-ink-2)} -.bt-kv span:last-child{color:var(--bt-ink);font-variant-numeric:tabular-nums} -.bt-danger{color:#e02135} -- ++.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } ++/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw ++ the separator: #818's "no border" held for the component but not for the box around it. Keyed on ++ emptiness, not on a section id, so the next null-rendering section is covered too. */ ++.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } ++.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } ++.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } ++.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } ++.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } ++.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } ++.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } ++.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } ++.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } ++.bt-flybot-approve { font-weight: 600; } ++.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } ++.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } ++.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } ++.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } ++.bt-held-head { font-weight: 600; margin-bottom: 2px; } ++.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } ++.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } ++.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } ++.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } ++.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } ++.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } ++.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } ++.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } ++`; + -/* Bot settings: fields, the avatar editor popover, and the details-pane sub-view. */ -.bt-field{display:flex;flex-direction:column;gap:6px} -.bt-field-label{font-size:13px;line-height:18px;font-weight:500} @@ -3028,7 +3183,17 @@ diff --git a/client.js b/client.js -.bt-drawer-model .bt-note{margin-top:6px} -.bt-drawer-permission{display:flex;flex-direction:column} -.bt-seg button.bt-perm-danger,.bt-seg button.bt-perm-danger[aria-pressed=true]{color:#e02135} -- ++// src/shared/turn-failures.js ++var FAILURE_LINES_SHOWN = 3; ++var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { ++ const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; ++ return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ ++ id: `turn-failure-${entry.id}`, ++ text: t2("Could not run: {error}", { error: entry.failed ?? t2("the model call failed") }), ++ ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} ++ })); ++}; + -/* Settings dialog: a 198px page list beside the page. */ -.bt-settings-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:60;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -.bt-scrim{position:absolute;inset:0;background:rgba(0,0,0,.32);animation:bt-fade .22s ease-out} @@ -3088,7 +3253,36 @@ diff --git a/client.js b/client.js -.bt-set-bot{flex:1;display:flex;align-items:center;gap:8px;min-width:0} -.bt-set-paste{flex-direction:column;align-items:stretch;gap:8px} -.bt-chevron{color:var(--bt-ink-3);display:inline-flex;flex:none} -- ++// src/client/turn-failures.js ++var import_react3 = require("react"); ++function TurnFailures({ sessionId, work }) { ++ const [snapshot, setSnapshot] = (0, import_react3.useState)(() => work?.getSnapshot?.()); ++ (0, import_react3.useEffect)(() => { ++ if (typeof work?.subscribe !== "function") return void 0; ++ const read = () => setSnapshot(work.getSnapshot()); ++ const off = work.subscribe(read); ++ read(); ++ return off; ++ }, [work]); ++ const lines = failureLines(snapshot, sessionId, t); ++ if (lines.length === 0) return null; ++ return (0, import_react3.createElement)( ++ "div", ++ { className: "bt-turn-failures", "data-flybot": "turn-failures" }, ++ lines.map((line2) => (0, import_react3.createElement)( ++ "div", ++ { key: line2.id, className: "bt-turn-failure", "data-flybot": "turn-failure" }, ++ (0, import_react3.createElement)("span", { className: "bt-turn-failure-text" }, line2.text), ++ line2.streak === void 0 ? null : (0, import_react3.createElement)("span", { className: "bt-turn-failure-streak" }, ` ${t("{count} turns in a row", { count: line2.streak })}`) ++ )) ++ ); ++} ++var TURN_FAILURES_CSS = ` ++.bt-turn-failures{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} ++.bt-turn-failure{display:flex;gap:6px;align-items:baseline;padding:6px 10px;border-radius:8px;font-size:12px;line-height:1.45;background:color-mix(in srgb,var(--bt-ink) 6%,transparent);color:color-mix(in srgb,var(--bt-ink) 72%,transparent)} ++.bt-turn-failure-streak{opacity:.72} ++`; + -/* Settings → Connectors. */ -.bt-connector{display:flex;flex-direction:column;gap:10px;padding:12px} -.bt-connector-head{display:flex;align-items:center;gap:12px;min-width:0} @@ -3103,35 +3297,7 @@ diff --git a/client.js b/client.js -.bt-connector-form-row{display:flex;align-items:center;gap:6px} -.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} -.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} -+.bt-flybot-section { margin-top: 8px; border-top: 1px solid var(--bt-line, rgba(0,0,0,.08)); padding-top: 6px; } -+/* A section whose component renders null leaves the wrapper empty, and an empty wrapper must not draw -+ the separator: #818's "no border" held for the component but not for the box around it. Keyed on -+ emptiness, not on a section id, so the next null-rendering section is covered too. */ -+.bt-flybot-section:empty { margin-top: 0; border-top: 0; padding-top: 0; } -+.bt-flybot-approvals { position: fixed; right: 16px; bottom: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; max-width: 360px; max-height: calc(100vh - 32px); overflow-y: auto; } -+.bt-flybot-approval-error { color: var(--bt-danger, #d33); font-size: 12px; } -+.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } -+.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } -+.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } -+.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } -+.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } -+.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } -+.bt-flybot-approve { font-weight: 600; } -+.bt-held-reply[data-state=pending] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-2); } -+.bt-held-reply[data-state=rejected] .bt-bubble, .bt-held-reply[data-state=failed] .bt-bubble { background: transparent; border: 1px dashed var(--bt-line-2); color: var(--bt-ink-3); } -+.bt-held-actions { margin: 6px 0 2px; max-width: 420px; } -+.bt-held-card { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 8px 10px; font-size: 13px; } -+.bt-held-head { font-weight: 600; margin-bottom: 2px; } -+.bt-held-note { margin: 2px 0 0; font-size: 12px; color: var(--bt-ink-3); display: flex; gap: 8px; align-items: center; } -+.bt-held-note[data-state=failed] { color: var(--bt-danger, #d33); } -+.bt-held-retry { border: 0; background: transparent; color: var(--bt-accent, #2563eb); font-size: 12px; cursor: pointer; padding: 0; } -+.bt-flybot-pointers { position: fixed; top: 12px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; max-width: 360px; } -+.bt-flybot-pointer { display: flex; align-items: center; gap: 8px; background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 999px; padding: 5px 6px 5px 12px; box-shadow: 0 2px 10px rgba(0,0,0,.1); font-size: 12px; } -+.bt-flybot-pointer-text { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } -+.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } -+.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } -+`; - +- -/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the - shell offers them and CSS-module name suffixes elsewhere. */ -[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important} @@ -3140,16 +3306,110 @@ diff --git a/client.js b/client.js -[class*="_footArea"]>[class*="_settingsArea"]{flex:none;width:auto!important} -[class*="_footArea"] button[aria-label="Settings"]{width:auto!important;padding:0!important;background:none!important;border-radius:50%!important;height:auto!important} -[class*="_widthHandle"]{display:none!important} -+// src/shared/turn-failures.js -+var FAILURE_LINES_SHOWN = 3; -+var failureLines = (snapshot, place, t2, limit = FAILURE_LINES_SHOWN) => { -+ const ended = Array.isArray(snapshot?.ended) ? snapshot.ended : []; -+ return ended.filter((entry) => entry?.silent === true && (entry.roomId ?? entry.sessionId) === place).slice(-limit).map((entry) => ({ -+ id: `turn-failure-${entry.id}`, -+ text: t2("Could not run: {error}", { error: entry.failed ?? t2("the model call failed") }), -+ ...Number(entry.streak) > 1 ? { streak: Number(entry.streak) } : {} -+ })); ++// src/client/themes.js ++var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; ++var THEMES = { ++ deepseek: { ++ label: "DeepSeek", ++ light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, ++ dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } ++ }, ++ mono: { ++ label: "Mono", ++ light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, ++ dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } ++ }, ++ paper: { ++ label: "Paper", ++ light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, ++ dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } ++ }, ++ moonlight: { ++ label: "Moonlight", ++ light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, ++ dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } ++ } +}; ++var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; ++function themeVars(mode, dark, accent = mode.accent) { ++ return { ++ "--bt-ink": mode.text, ++ "--bt-ink-2": rgba(mode.text, 0.6), ++ "--bt-ink-3": rgba(mode.text, 0.36), ++ "--bt-bubble": mode.bubble, ++ "--bt-sidebar": mode.sidebar, ++ "--bt-main": mode.bg, ++ "--bt-card": mode.card, ++ "--bt-line": rgba(mode.text, 0.08), ++ "--bt-line-2": rgba(mode.text, 0.15), ++ "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", ++ "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", ++ "--bt-accent": accent, ++ "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", ++ "--bt-user": mode.user, ++ "--bt-user-ink": mode.userText, ++ "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), ++ "--bt-tag-ink": rgba(mode.text, 0.58) ++ }; ++} ++var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); ++var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} ++html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} ++${theme.css ?? ""}`; ++var shellTokens = ({ light, dark }) => ({ ++ "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, ++ "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, ++ "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, ++ "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, ++ "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, ++ "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } ++}); ++var customThemes = {}; ++var registry3 = createSource(0); ++var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); ++function registerShape(id, shape) { ++ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); ++ if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); ++ const at = shape.eyes?.at; ++ if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); ++ const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; ++ const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; ++ const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); ++ const previous = SHAPES[id]; ++ SHAPES[id] = { ++ label: String(shape.label ?? id), ++ size, ++ body: shape.body, ++ eyes, ++ ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} ++ }; ++ const refresh = () => { ++ markupCache.clear(); ++ maskCache.delete(id); ++ registry3.set((value) => value + 1); ++ }; ++ refresh(); ++ return () => { ++ if (previous) SHAPES[id] = previous; ++ else delete SHAPES[id]; ++ refresh(); ++ }; ++} ++function registerTheme(id, theme) { ++ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); ++ for (const mode of ["light", "dark"]) { ++ for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); ++ } ++ const previous = customThemes[id]; ++ customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; ++ registry3.set((value) => value + 1); ++ return () => { ++ if (previous) customThemes[id] = previous; ++ else delete customThemes[id]; ++ registry3.set((value) => value + 1); ++ }; ++} ++var allThemes = () => ({ ...THEMES, ...customThemes }); -.bt-voice-pane{flex-direction:column;align-items:center;justify-content:center;animation:bt-fade .2s ease-out} -.bt-voice-stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;max-width:560px;text-align:center} @@ -3210,35 +3470,32 @@ diff --git a/client.js b/client.js -button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} -.bt-you-wrap{display:inline-flex;padding:0} -.bt-connect-foot{width:100%;height:36px} -+// src/client/turn-failures.js -+var import_react3 = require("react"); -+function TurnFailures({ sessionId, work }) { -+ const [snapshot, setSnapshot] = (0, import_react3.useState)(() => work?.getSnapshot?.()); -+ (0, import_react3.useEffect)(() => { -+ if (typeof work?.subscribe !== "function") return void 0; -+ const read = () => setSnapshot(work.getSnapshot()); -+ const off = work.subscribe(read); -+ read(); -+ return off; -+ }, [work]); -+ const lines = failureLines(snapshot, sessionId, t); -+ if (lines.length === 0) return null; -+ return (0, import_react3.createElement)( -+ "div", -+ { className: "bt-turn-failures", "data-flybot": "turn-failures" }, -+ lines.map((line2) => (0, import_react3.createElement)( -+ "div", -+ { key: line2.id, className: "bt-turn-failure", "data-flybot": "turn-failure" }, -+ (0, import_react3.createElement)("span", { className: "bt-turn-failure-text" }, line2.text), -+ line2.streak === void 0 ? null : (0, import_react3.createElement)("span", { className: "bt-turn-failure-streak" }, ` ${t("{count} turns in a row", { count: line2.streak })}`) -+ )) -+ ); ++// src/client/mention-style.js ++function chipKey(bot) { ++ if (/\s/.test(bot?.name)) return null; ++ return String(bot?.name ?? "").toLowerCase(); ++} ++function mentionCss(bots) { ++ const list = Array.isArray(bots) ? bots : []; ++ const owner = /* @__PURE__ */ new Map(); ++ for (const bot of list) { ++ const key = chipKey(bot); ++ if (key === null) continue; ++ const prev = owner.get(key); ++ if (prev && !prev.remote && bot?.remote) continue; ++ owner.set(key, bot); ++ } ++ return list.map((bot) => { ++ const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; ++ const key = chipKey(bot); ++ const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; ++ const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); ++ const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; ++ const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; ++ const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; ++ return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; ++ }).join("\n"); +} -+var TURN_FAILURES_CSS = ` -+.bt-turn-failures{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} -+.bt-turn-failure{display:flex;gap:6px;align-items:baseline;padding:6px 10px;border-radius:8px;font-size:12px;line-height:1.45;background:color-mix(in srgb,var(--bt-ink) 6%,transparent);color:color-mix(in srgb,var(--bt-ink) 72%,transparent)} -+.bt-turn-failure-streak{opacity:.72} -+`; -.bt-menu{position:fixed;pointer-events:auto;z-index:60;min-width:200px;padding:6px;border-radius:12px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1),0 0 0 1px rgba(228,228,228,.04);display:flex;flex-direction:column;gap:2px;transform-origin:var(--bt-origin,top left);animation:bt-pop-drop ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box} -.bt-menu button{display:flex;align-items:center;gap:4px;width:100%;min-height:30px;text-align:left;border:0;background:none;border-radius:6px;padding:6px 8px;font:inherit;font-size:13px;line-height:18px;color:var(--bt-ink);cursor:pointer;box-sizing:border-box} @@ -3313,373 +3570,21 @@ diff --git a/client.js b/client.js -.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} -.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} -.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} --.bt-states{display:flex;flex-wrap:wrap;gap:4px} --.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} --.bt-state:hover{color:var(--bt-ink)} --.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} --.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} --.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} --body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} --body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} --.bt-tabs{flex-wrap:wrap} --.bt-soft:disabled{opacity:.4;cursor:default} --.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} --.bt-members{display:flex;flex-direction:column;gap:1px} --.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} --.bt-member:hover{background:var(--bt-hover)} --.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} --.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} --.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} --.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} --.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} --.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} --.bt-add{color:var(--bt-ink-2);margin-top:2px} --.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} --.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} --.bt-motion-seg{width:216px} --.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} --.bt-seg button:disabled{opacity:.4;cursor:default} --.bt-seg button:active:not(:disabled){scale:.96} --.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} --.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} --@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} -+// src/client/themes.js -+var THEME_KEYS = ["bg", "sidebar", "bubble", "card", "text", "user", "userText", "accent"]; -+var THEMES = { -+ deepseek: { -+ label: "DeepSeek", -+ light: { bg: "#fcfcfc", sidebar: "#f7f7f7", bubble: "#eeeeee", card: "#ffffff", text: "#141414", user: "#070707", userText: "#fcfcfc", accent: "#4d6bfe" }, -+ dark: { bg: "#141414", sidebar: "#1b1b1b", bubble: "#262626", card: "#1f1f1f", text: "#f2f2f2", user: "#f2f2f2", userText: "#141414", accent: "#5b7bff" } -+ }, -+ mono: { -+ label: "Mono", -+ light: { bg: "#ffffff", sidebar: "#f9f9f9", bubble: "#f0f0f0", card: "#ffffff", text: "#0d0d0d", user: "#0d0d0d", userText: "#ffffff", accent: "#0d0d0d" }, -+ dark: { bg: "#0f0f0f", sidebar: "#171717", bubble: "#242424", card: "#1c1c1c", text: "#f4f4f4", user: "#f4f4f4", userText: "#0f0f0f", accent: "#f4f4f4" } -+ }, -+ paper: { -+ label: "Paper", -+ light: { bg: "#faf9f5", sidebar: "#f3f0e8", bubble: "#ebe7dc", card: "#ffffff", text: "#1f1e1b", user: "#2b2a26", userText: "#faf9f5", accent: "#d97757" }, -+ dark: { bg: "#1f1e1b", sidebar: "#262521", bubble: "#34322d", card: "#2a2925", text: "#f3f0e8", user: "#f3f0e8", userText: "#1f1e1b", accent: "#e08a6d" } -+ }, -+ moonlight: { -+ label: "Moonlight", -+ light: { bg: "#f7f9fc", sidebar: "#eef2f8", bubble: "#e5ebf5", card: "#ffffff", text: "#121826", user: "#1e3a8a", userText: "#ffffff", accent: "#1e86ff" }, -+ dark: { bg: "#0b1020", sidebar: "#10172a", bubble: "#1a2340", card: "#141c33", text: "#e8edf7", user: "#3b6fd8", userText: "#ffffff", accent: "#4f9dff" } -+ } -+}; -+var rgba = (hex, alpha) => `rgba(${[1, 3, 5].map((index) => parseInt(hex.slice(index, index + 2), 16)).join(",")},${alpha})`; -+function themeVars(mode, dark, accent = mode.accent) { -+ return { -+ "--bt-ink": mode.text, -+ "--bt-ink-2": rgba(mode.text, 0.6), -+ "--bt-ink-3": rgba(mode.text, 0.36), -+ "--bt-bubble": mode.bubble, -+ "--bt-sidebar": mode.sidebar, -+ "--bt-main": mode.bg, -+ "--bt-card": mode.card, -+ "--bt-line": rgba(mode.text, 0.08), -+ "--bt-line-2": rgba(mode.text, 0.15), -+ "--bt-hover": dark ? "rgba(160,160,160,.12)" : "rgba(119,119,119,.09)", -+ "--bt-active": dark ? "rgba(160,160,160,.2)" : "rgba(119,119,119,.17)", -+ "--bt-accent": accent, -+ "--bt-accent-ink": luminance(accent) > 0.45 ? "#141414" : "#ffffff", -+ "--bt-user": mode.user, -+ "--bt-user-ink": mode.userText, -+ "--bt-tag-bg": rgba(mode.text, dark ? 0.09 : 0.06), -+ "--bt-tag-ink": rgba(mode.text, 0.58) ++// src/client/motion.js ++var import_react4 = require("react"); ++var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; ++function springEasing(stiffness, damping) { ++ const w0 = Math.sqrt(stiffness); ++ const zeta = damping / (2 * w0); ++ const at = (t2) => { ++ if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); ++ const wd = w0 * Math.sqrt(1 - zeta * zeta); ++ return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); + }; -+} -+var declarations = (vars) => Object.entries(vars).map(([name, value]) => `${name}:${value}`).join(";"); -+var themeCss = (theme, accent) => `html body{${declarations(themeVars(theme.light, false, accent ?? theme.light.accent))}} -+html body[data-ds-dark-theme]{${declarations(themeVars(theme.dark, true, accent ?? theme.dark.accent))}} -+${theme.css ?? ""}`; -+var shellTokens = ({ light, dark }) => ({ -+ "--dsw-alias-bg-base": { light: light.bg, dark: dark.bg }, -+ "--dsw-specific-sidebar-fill": { light: light.sidebar, dark: dark.sidebar }, -+ "--dsw-specific-input-major": { light: light.bg, dark: dark.sidebar }, -+ "--dsw-alias-label-primary": { light: light.text, dark: dark.text }, -+ "--dsw-alias-label-secondary": { light: rgba(light.text, 0.74), dark: rgba(dark.text, 0.74) }, -+ "--dsw-alias-label-tertiary": { light: rgba(light.text, 0.6), dark: rgba(dark.text, 0.6) } -+}); -+var customThemes = {}; -+var registry3 = createSource(0); -+var isPoint = (point) => Array.isArray(point) && point.length === 2 && point.every(Number.isFinite); -+function registerShape(id, shape) { -+ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Shape ids are lowercase words"); -+ if (typeof shape?.body !== "function" || typeof shape.body("#000") !== "string") throw new Error("A shape needs body(paint) returning SVG markup"); -+ const at = shape.eyes?.at; -+ if (!Array.isArray(at) || at.length !== 2 || !at.every(isPoint)) throw new Error("A shape needs eyes.at: two [x, y] points"); -+ const size = Number.isFinite(shape.size) && shape.size > 0 ? shape.size : 100; -+ const pick2 = (key, fallback) => Number.isFinite(shape.eyes[key]) ? shape.eyes[key] : fallback; -+ const eyes = at.map(([x, y]) => [x, y, pick2("w", size * 0.1), pick2("h", size * 0.23), pick2("tilt", -27)]); -+ const previous = SHAPES[id]; -+ SHAPES[id] = { -+ label: String(shape.label ?? id), -+ size, -+ body: shape.body, -+ eyes, -+ ...typeof shape.wrap === "string" ? { wrap: shape.wrap } : {} -+ }; -+ const refresh = () => { -+ markupCache.clear(); -+ maskCache.delete(id); -+ registry3.set((value) => value + 1); -+ }; -+ refresh(); -+ return () => { -+ if (previous) SHAPES[id] = previous; -+ else delete SHAPES[id]; -+ refresh(); -+ }; -+} -+function registerTheme(id, theme) { -+ if (typeof id !== "string" || !/^[a-z][\w-]{0,31}$/.test(id)) throw new Error("Theme ids are lowercase words"); -+ for (const mode of ["light", "dark"]) { -+ for (const key of THEME_KEYS) if (!isHex(theme?.[mode]?.[key])) throw new Error(`Theme ${mode}.${key} must be a #rrggbb color`); -+ } -+ const previous = customThemes[id]; -+ customThemes[id] = { label: String(theme.label ?? id), light: theme.light, dark: theme.dark, css: typeof theme.css === "string" ? theme.css : "" }; -+ registry3.set((value) => value + 1); -+ return () => { -+ if (previous) customThemes[id] = previous; -+ else delete customThemes[id]; -+ registry3.set((value) => value + 1); -+ }; -+} -+var allThemes = () => ({ ...THEMES, ...customThemes }); - --/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ --.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} --.bt-mem-row:hover{background:var(--bt-hover)} --.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} --.bt-mem-row-copy{flex:1;text-align:left} --.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} --.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-mem-scrim{animation:bt-fade .2s ease-out} --.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} --.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} --.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} --.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} --.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} --.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} --.bt-mem-head-gap{flex:1} --.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} --.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} --.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} --.bt-mem-summary{transition:opacity .3s ease} --.bt-mem-summary[data-stale]{opacity:.55} --.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} --.bt-mem-summary h3:first-child{margin-top:0} --.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} --.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-summary>:nth-child(2){animation-delay:.03s} --.bt-mem-summary>:nth-child(3){animation-delay:.06s} --.bt-mem-summary>:nth-child(4){animation-delay:.09s} --.bt-mem-summary>:nth-child(5){animation-delay:.12s} --.bt-mem-summary>:nth-child(6){animation-delay:.15s} --.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} --@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} --.bt-mem-skel{display:flex;flex-direction:column;gap:24px} --.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} --.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} --.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} --@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} --.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} --.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} --.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} --.bt-mem-empty-mark svg{width:22px;height:22px} --.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} --.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} --.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} --.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} --.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} --.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} --.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} --.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} --.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} --.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} --.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} --.bt-mem-entry[data-busy]{opacity:.4} --.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} --.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} --.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} --.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} --.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} --.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} --.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} --@media (hover:none){.bt-mem-remove{opacity:1}} --.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} --.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} --.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} --.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} --.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} --.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} --.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} --.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} --.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} --.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} --.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} --.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-mem-input::placeholder{color:var(--bt-ink-3)} --.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mem-send:disabled{opacity:.22;cursor:default} --.bt-mem-send:not(:disabled):active{scale:.92} --.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} --@keyframes bt-mem-spin{to{transform:rotate(360deg)}} --@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} --@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} -+// src/client/mention-style.js -+function chipKey(bot) { -+ if (/\s/.test(bot?.name)) return null; -+ return String(bot?.name ?? "").toLowerCase(); -+} -+function mentionCss(bots) { -+ const list = Array.isArray(bots) ? bots : []; -+ const owner = /* @__PURE__ */ new Map(); -+ for (const bot of list) { -+ const key = chipKey(bot); -+ if (key === null) continue; -+ const prev = owner.get(key); -+ if (prev && !prev.remote && bot?.remote) continue; -+ owner.set(key, bot); -+ } -+ return list.map((bot) => { -+ const anchor = `.bt-bubble a[href="${MENTION_ORIGIN}${bot.id}"]`; -+ const key = chipKey(bot); -+ const chip = key !== null && owner.get(key) === bot ? `[class*="_userRow"] [data-ref-chip="file"][title="@${String(bot.name).replace(/["\\]/g, "\\$&")}" i]` : ""; -+ const look = bot?.remote ? lookAvoidingLocals(bot) : lookOf(bot); -+ const marks = chip ? `${anchor}::before,${chip}::before` : `${anchor}::before`; -+ const mark = look.image ? `${marks}{background:center/cover url("${look.image}");-webkit-mask:none;mask:none;border-radius:50%}` : `${marks}{background:${paintCss(look.color)};-webkit-mask-image:${maskOf(look.shape)};mask-image:${maskOf(look.shape)}}`; -+ const chipBase = chip ? `${chip}{color:inherit;font-weight:600}${chip}>svg{display:none}${chip}::before{content:"";display:inline-block;flex:none;width:16px;height:16px;margin-right:3px;vertical-align:-3px;-webkit-mask:${DEFAULT_MASK} center/contain no-repeat;mask:${DEFAULT_MASK} center/contain no-repeat}` : ""; -+ return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; -+ }).join("\n"); -+} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} --.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} --.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} --.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} --:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} --:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} --@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} --`; --var CONVERSATION_CSS = ` --.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} --.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} --.bt-event>span{padding:0 2px} --.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} --[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.bt-group-replies{display:contents} --/* A Bot chat is flat in every work-details mode, without changing the user's mode -- (it is a DSH-wide setting): step groups lose their header and stay open, and what a -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} --[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} --/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -- without its copy and branch row; a tail with nothing in it takes no room. */ --[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -- the shell's new-session hero: no headline, no workspace or mode picker, and the -- composer stays docked at the bottom. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} --[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} --[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} --[data-chat-running]>span:not([role=status]){display:none!important} --[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} --.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} --.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} -+// src/client/motion.js -+var import_react4 = require("react"); -+var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; -+function springEasing(stiffness, damping) { -+ const w0 = Math.sqrt(stiffness); -+ const zeta = damping / (2 * w0); -+ const at = (t2) => { -+ if (zeta >= 1) return 1 - (1 + w0 * t2) * Math.exp(-w0 * t2); -+ const wd = w0 * Math.sqrt(1 - zeta * zeta); -+ return 1 - Math.exp(-zeta * w0 * t2) * (Math.cos(wd * t2) + zeta * w0 / wd * Math.sin(wd * t2)); -+ }; -+ let end = 0.05; -+ while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; -+ const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); -+ return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; ++ let end = 0.05; ++ while (end < 2 && Math.abs(1 - at(end)) > 1e-3) end += 0.01; ++ const points = Array.from({ length: 25 }, (_, index) => (index === 24 ? 1 : at(end * index / 24)).toFixed(4)); ++ return { duration: Math.round(end * 1e3), easing: `linear(${points.join(",")})`, stiffness, damping }; +} +var SPRING_SHAPE = springEasing(400, 31.2); +var SPRING_TAP = springEasing(900, 48); @@ -3699,12 +3604,12 @@ diff --git a/client.js b/client.js + const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); + last = now; + const steps = Math.ceil(dt * 240); -+ const h48 = dt / steps; ++ const h49 = dt / steps; + let moving = false; + for (const value of values.values()) { + for (let index = 0; index < steps; index += 1) { -+ value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h48; -+ value.x += value.v * h48; ++ value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h49; ++ value.x += value.v * h49; + } + if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { + value.x = value.to; @@ -4538,44 +4443,101 @@ diff --git a/client.js b/client.js +.bt-swatch-custom[data-on=true]{border-color:var(--bt-ink)} +.bt-swatch-custom input{position:absolute;inset:-4px;width:calc(100% + 8px);height:calc(100% + 8px);opacity:0;cursor:pointer;border:0;padding:0} +.bt-swatch-default{background:linear-gradient(135deg,var(--bt-card) 0 46%,var(--bt-line-2) 46% 54%,var(--bt-card) 54%);box-shadow:inset 0 0 0 1px var(--bt-line-2)} -+.bt-states{display:flex;flex-wrap:wrap;gap:4px} -+.bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} -+.bt-state:hover{color:var(--bt-ink)} -+.bt-state[aria-pressed=true]{background:var(--bt-active);border-color:var(--bt-line-2);color:var(--bt-ink)} -+.bt-theme-chip{position:relative;width:46px;height:30px;border-radius:8px;box-shadow:inset 0 0 0 1px var(--bt-line-2);background:linear-gradient(90deg,var(--ls) 0 36%,var(--lb) 36%);overflow:hidden} -+.bt-theme-chip i{position:absolute;right:7px;bottom:7px;width:10px;height:10px;border-radius:50%;background:var(--la)} -+body[data-ds-dark-theme] .bt-theme-chip{background:linear-gradient(90deg,var(--ds) 0 36%,var(--db) 36%)} -+body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} -+.bt-tabs{flex-wrap:wrap} -+.bt-soft:disabled{opacity:.4;cursor:default} -+.bt-group-role{margin-left:6px;font-size:11px;line-height:14px;padding:1px 5px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink);vertical-align:1px} -+.bt-members{display:flex;flex-direction:column;gap:1px} -+.bt-member{display:flex;align-items:center;gap:4px;min-height:40px;padding:0 4px 0 6px;border-radius:8px;animation:bt-rise .18s ease-out} -+.bt-member:hover{background:var(--bt-hover)} -+.bt-member-name{flex:1;min-width:0;display:flex;align-items:center;gap:10px;border:0;background:none;padding:6px 0;color:var(--bt-ink);font:inherit;font-size:14px;line-height:20px;cursor:pointer;text-align:left} -+.bt-member-name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-chip-admin{background:var(--bt-tag-bg);color:var(--bt-tag-ink);border-color:transparent} -+.bt-mini{flex:none;border:0;background:none;border-radius:6px;padding:2px 6px;font:inherit;font-size:12px;line-height:18px;color:var(--bt-ink-2);cursor:pointer} -+.bt-mini:hover{background:var(--bt-active);color:var(--bt-ink)} -+.bt-member .bt-mini,.bt-member .bt-x{opacity:0;transition:opacity .15s ease} -+.bt-member:hover .bt-mini,.bt-member:hover .bt-x,.bt-member .bt-mini:focus-visible,.bt-member .bt-x:focus-visible{opacity:1} -+.bt-add{color:var(--bt-ink-2);margin-top:2px} -+.bt-add:hover,.bt-addlist .bt-option:hover{background:var(--bt-hover);color:var(--bt-ink)} -+.bt-addlist{display:flex;flex-direction:column;margin-top:4px;padding:4px;border:1px solid var(--bt-line);border-radius:10px;background:var(--bt-card);animation:bt-rise .16s ease-out} -+.bt-seg{position:relative;display:flex;gap:2px;padding:2px;border-radius:10px;background:var(--bt-hover)} -+.bt-motion-seg{width:216px} -+.bt-seg button{position:relative;z-index:1;flex:1;height:30px;border:0;border-radius:8px;background:none;font:inherit;font-size:13px;color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,box-shadow .15s ease,${spring(SPRING_TAP, "scale")}} -+.bt-seg button[aria-pressed=true]{background:var(--bt-card);color:var(--bt-ink);box-shadow:0 1px 3px rgba(0,0,0,.08)} -+.bt-seg button:disabled{opacity:.4;cursor:default} -+.bt-seg button:active:not(:disabled){scale:.96} -+.bt-seg>.bt-thumb{top:2px;bottom:2px;border-radius:8px;background:var(--bt-card);box-shadow:0 1px 3px rgba(0,0,0,.08)} -+.bt-seg[data-glide] button[aria-pressed=true]{background:transparent;box-shadow:none} -+@keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} -+@media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} -+ -+/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ -+.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} -+.bt-mem-row:hover{background:var(--bt-hover)} + .bt-states{display:flex;flex-wrap:wrap;gap:4px} + .bt-state{border:1px solid var(--bt-line);background:none;border-radius:999px;font:inherit;font-size:11px;line-height:16px;padding:2px 8px;color:var(--bt-ink-2);cursor:pointer} + .bt-state:hover{color:var(--bt-ink)} +@@ -2488,2292 +2488,5195 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} + @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} + @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} + +-/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ +-.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} +-.bt-mem-row:hover{background:var(--bt-hover)} +-.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} +-.bt-mem-row-copy{flex:1;text-align:left} +-.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} +-.bt-mem-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:61;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-mem-scrim{animation:bt-fade .2s ease-out} +-.bt-mem{position:relative;width:min(680px,calc(100vw - 48px));height:min(760px,calc(100vh - 2*max(32px,var(--dsh-frame-overlay-top,32px))));background:var(--bt-main);border-radius:20px;border:.5px solid var(--bt-line-2);box-shadow:0 24px 80px rgba(0,0,0,.24);display:flex;flex-direction:column;overflow:hidden;color:var(--bt-ink);animation:bt-dialog-rise ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +-.bt-mem-head{position:relative;display:flex;align-items:center;gap:8px;height:60px;flex:none;padding:0 14px 0 24px;border-bottom:.5px solid var(--bt-line);box-sizing:border-box} +-.bt-mem-head .bt-icon-btn{width:32px;height:32px;flex:none} +-.bt-mem-head [aria-haspopup] svg{width:18px;height:18px;stroke-width:2.6} +-.bt-mem-head>.bt-icon-btn:first-child{margin-left:-10px} +-.bt-mem-head h2{margin:0;font-size:17px;line-height:24px;font-weight:600;white-space:nowrap} +-.bt-mem-status{font-size:13px;line-height:18px;color:var(--bt-ink-3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;font-variant-numeric:tabular-nums} +-.bt-mem-head-gap{flex:1} +-.bt-mem-menu{position:absolute;top:52px;right:50px;min-width:220px;--bt-origin:top right} +-.bt-mem-menu button:disabled{opacity:.4;cursor:default;background:none} +-.bt-mem-body{flex:1;overflow-y:auto;padding:22px 28px 24px;scrollbar-width:thin;display:flex;flex-direction:column;gap:12px} +-.bt-mem-summary{transition:opacity .3s ease} +-.bt-mem-summary[data-stale]{opacity:.55} +-.bt-mem-summary h3{margin:20px 0 6px;font-size:17px;line-height:26px;font-weight:600} +-.bt-mem-summary h3:first-child{margin-top:0} +-.bt-mem-summary p{margin:0;font-size:15px;line-height:26px} +-.bt-mem-summary>*{animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-summary>:nth-child(2){animation-delay:.03s} +-.bt-mem-summary>:nth-child(3){animation-delay:.06s} +-.bt-mem-summary>:nth-child(4){animation-delay:.09s} +-.bt-mem-summary>:nth-child(5){animation-delay:.12s} +-.bt-mem-summary>:nth-child(6){animation-delay:.15s} +-.bt-mem-summary>:nth-child(n+7){animation-delay:.18s} +-@keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} +-.bt-mem-skel{display:flex;flex-direction:column;gap:24px} +-.bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} +-.bt-mem-skel-block span{display:block;height:12px;border-radius:6px;background:linear-gradient(90deg,var(--bt-hover) 25%,var(--bt-active) 50%,var(--bt-hover) 75%);background-size:200% 100%;animation:bt-mem-skel 1.4s ease-in-out infinite} +-.bt-mem-skel-block .bt-mem-skel-head{width:30%;height:16px;margin-bottom:2px} +-@keyframes bt-mem-skel{from{background-position:150% 0}to{background-position:-50% 0}} +-.bt-mem-skel-label{font-size:13px;line-height:18px;color:var(--bt-ink-3)} +-.bt-mem-empty{margin:auto;max-width:400px;display:flex;flex-direction:column;align-items:center;gap:8px;text-align:center;padding:24px 0;animation:bt-mem-line .45s cubic-bezier(.16,1,.3,1) both} +-.bt-mem-empty-mark{width:48px;height:48px;border-radius:50%;background:var(--bt-hover);display:flex;align-items:center;justify-content:center;color:var(--bt-ink-2);margin-bottom:4px} +-.bt-mem-empty-mark svg{width:22px;height:22px} +-.bt-mem-empty-title{font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-empty-text{font-size:13px;line-height:20px;color:var(--bt-ink-2)} +-.bt-mem-failed{display:flex;align-items:center;gap:12px;padding:10px 12px;border-radius:12px;background:var(--bt-hover);font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-mem-failed>span{flex:1;min-width:0;overflow-wrap:anywhere} +-.bt-mem-entries{display:flex;flex-direction:column;gap:28px;animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .22s ease-out} +-.bt-mem-section-head{display:flex;align-items:baseline;gap:8px;margin-bottom:4px} +-.bt-mem-section-head h3{margin:0;font-size:15px;line-height:22px;font-weight:600} +-.bt-mem-section-head span{font-size:12px;line-height:16px;color:var(--bt-ink-3)} +-.bt-mem-section-head .bt-mem-size{margin-left:auto;font-variant-numeric:tabular-nums;white-space:nowrap} +-.bt-mem-topic{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin:14px 0 2px 4px;font-weight:500} +-.bt-mem-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +-.bt-mem-entry{display:flex;align-items:center;gap:10px;padding:9px 4px;border-bottom:.5px solid var(--bt-line);transition:opacity .2s ease} +-.bt-mem-entry[data-busy]{opacity:.4} +-.bt-mem-entry-copy{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +-.bt-mem-entry-text{font-size:14px;line-height:20px;overflow-wrap:anywhere} +-.bt-mem-entry-meta{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +-.bt-mem-remove{flex:none;height:28px;min-width:28px;border-radius:999px;border:0;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:0 6px;font:inherit;font-size:12px;opacity:0;transition:opacity .12s ease,background-color .12s ease,color .12s ease} +-.bt-mem-entry:hover .bt-mem-remove,.bt-mem-remove:focus-visible,.bt-mem-remove[data-confirm]{opacity:1} +-.bt-mem-remove:hover{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-mem-remove[data-confirm]{background:rgba(224,33,53,.1);color:#e02135;padding:0 10px} +-@media (hover:none){.bt-mem-remove{opacity:1}} +-.bt-mem-none{font-size:13px;line-height:18px;color:var(--bt-ink-3);padding:8px 4px} +-.bt-mem-foot{flex:none;padding:8px 20px 20px;display:flex;flex-direction:column;gap:10px} +-.bt-mem-reply{border-radius:16px;background:var(--bt-hover);padding:8px 8px 12px 16px;display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow-y:auto;scrollbar-width:thin;transform-origin:50% 100%;animation:bt-pop-lift ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +-.bt-mem-reply-head{display:flex;align-items:center;gap:8px;min-height:24px} +-.bt-mem-reply-asked{flex:1;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-mem-reply .bt-icon-btn{width:24px;height:24px;flex:none} +-.bt-mem-reply-text{font-size:14px;line-height:21px;padding-right:8px;overflow-wrap:anywhere} +-.bt-mem-changes{list-style:none;margin:2px 0 0;padding:0 8px 0 0;display:flex;flex-direction:column;gap:4px} +-.bt-mem-changes li{display:flex;align-items:flex-start;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2);overflow-wrap:anywhere} +-.bt-mem-changes li svg{flex:none;margin-top:1px;color:#e02135;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} .12s backwards} +-.bt-mem-changes li[data-ok] svg{color:var(--bt-green)} +-.bt-mem-ask{display:flex;align-items:center;gap:8px;height:52px;padding:0 8px 0 22px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-card);box-shadow:0 4px 16px rgba(0,0,0,.06);box-sizing:border-box;transition:border-color .15s ease,box-shadow .15s ease} +-.bt-mem-ask:focus-within{border-color:var(--bt-ink-3);box-shadow:0 4px 20px rgba(0,0,0,.1)} +-.bt-mem-input{flex:1;min-width:0;border:0;background:none;outline:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} +-.bt-mem-input::placeholder{color:var(--bt-ink-3)} +-.bt-mem-send{flex:none;width:36px;height:36px;border-radius:50%;border:0;background:var(--bt-ink);color:var(--bt-main);display:flex;align-items:center;justify-content:center;cursor:pointer;padding:0;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mem-send:disabled{opacity:.22;cursor:default} +-.bt-mem-send:not(:disabled):active{scale:.92} +-.bt-mem-send>*{animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-mem-spin{width:14px;height:14px;border-radius:50%;border:2px solid currentColor;border-right-color:transparent;box-sizing:border-box;animation:bt-mem-spin .7s linear infinite} +-@keyframes bt-mem-spin{to{transform:rotate(360deg)}} +-@media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} +-@media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} ++/* Memory dialog: a summary of every entry, the entries behind the menu, "Ask or update" below. */ ++.bt-mem-row{display:flex;align-items:center;gap:8px;width:100%;height:36px;padding:0 8px 0 10px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;box-sizing:border-box;transition:background-color .12s ease} ++.bt-mem-row:hover{background:var(--bt-hover)} +.bt-mem-row>svg{flex:none;color:var(--bt-ink-2)} +.bt-mem-row-copy{flex:1;text-align:left} +.bt-mem-row-go{display:inline-flex;align-items:center;gap:2px;color:var(--bt-ink-3);font-size:12px} @@ -5171,6 +5133,7 @@ diff --git a/client.js b/client.js +var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); ++var WorkflowIcon = lineIcon(["M4.5 4h4v3.5h-4z", "M11.5 12.5h4V16h-4z", "M6.5 7.5v3.5a2 2 0 0 0 2 2h3"]); +var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); @@ -5279,7 +5242,7 @@ diff --git a/client.js b/client.js + }; + useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); + const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -+ const field = ownAnswer ? (0, import_react6.createElement)("textarea", { ++ const field2 = ownAnswer ? (0, import_react6.createElement)("textarea", { + ref: ownRef, + rows: 1, + value: own, @@ -5318,12 +5281,12 @@ diff --git a/client.js b/client.js + selected && !raised ? (0, import_react6.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react6.createElement)(CheckIcon)) : null + ); + }); -+ if (raised && field) { ++ if (raised && field2) { + rows.push((0, import_react6.createElement)( + "label", + { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, + ownLetter ? (0, import_react6.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -+ (0, import_react6.createElement)("span", { className: "bt-q-body" }, field) ++ (0, import_react6.createElement)("span", { className: "bt-q-body" }, field2) + )); + } + let foot = null; @@ -5360,10 +5323,10 @@ diff --git a/client.js b/client.js + (0, import_react6.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react6.createElement)(CloseIcon)) + ), + rows.length > 0 ? (0, import_react6.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -+ field && !raised ? (0, import_react6.createElement)( ++ field2 && !raised ? (0, import_react6.createElement)( + "div", + { className: "bt-q-custom" }, -+ (0, import_react6.createElement)("label", { className: "bt-q-field" }, field), ++ (0, import_react6.createElement)("label", { className: "bt-q-field" }, field2), + !multi && canSubmit ? (0, import_react6.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null + ) : null, + foot @@ -5456,7 +5419,108 @@ diff --git a/client.js b/client.js + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); +} -+ + +-.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} +-.bt-thumb[data-hidden]{opacity:0} +-[data-morph]{overflow:hidden!important} +-.bt-veil{animation:bt-veil-in .22s ease-out} +-.bt-with-icon{display:inline-flex;align-items:center;gap:4px} +-.bt-with-icon svg{width:14px;height:14px} +-.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} +-.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} +-.bt-create-inline .bt-create-card{max-width:none} +-.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} +-.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} +-.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} +-.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} +-.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} +-:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} +-:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} +-@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} +-`; +-var CONVERSATION_CSS = ` +-.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} +-.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} +-.bt-think summary::-webkit-details-marker{display:none} +-.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} +-.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} +-.bt-event>span{padding:0 2px} +-.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} +-.bt-event button:hover{background:var(--bt-hover)} +-.bt-event button:active{background:var(--bt-active)} +-.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} +-.bt-event-whole>*{flex:none} +-.bt-event-whole>span:first-child{margin-right:2px} +-/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ +-.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} +-[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} +-.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} +-.bt-group-replies{display:contents} +-/* A Bot chat is flat in every work-details mode, without changing the user's mode +- (it is a DSH-wide setting): step groups lose their header and stay open, and what a +- mode folds away in a finished turn stays shown. TurnProcessCell also opens that +- fold; these rules cover the first frame before it does. */ +-[data-step-process]>div:first-child{display:none!important} +-[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} +-[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} +-[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} +-/* The transcript runs under a floating header pill. */ +-header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} +-header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} +-header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} +-header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +-[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} +-/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), +- without its copy and branch row; a tail with nothing in it takes no room. */ +-[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} +-[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} +-/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not +- the shell's new-session hero: no headline, no workspace or mode picker, and the +- composer stays docked at the bottom. */ +-[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} +-[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} +-[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} +-[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} +-/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ +-[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} +-/* The shell's running status only hosts the activity row (.bt-typing). */ +-[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} +-[data-chat-running]>span:not([role=status]){display:none!important} +-[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} +-.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} +-.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} +-.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} +-.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} +-.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} +-.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} +-.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} +-.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} +-.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} +-.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} +-@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} +-@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} +-@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} +-/* Shimmer: a stepped sweep clipped to the text. */ +-.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} +-@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} +-/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ +-[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} +-[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} +-@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} +-@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} +-@media (prefers-reduced-motion:reduce){ +-.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} +-.bt-typing[data-exiting]{animation-name:bt-fade-out} +-.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} +-.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} +-[data-bt-enter="new"]{animation:none} +-[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} +// src/client/secret-card.js +var import_react7 = require("react"); +var MIN_VALUE = 4; @@ -5633,82 +5697,6 @@ diff --git a/client.js b/client.js + return (0, import_react7.createElement)("div", { className: "bt-event" }, (0, import_react7.createElement)("span", { className: "bt-secret-event" }, (0, import_react7.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); + } + return null; -+} -+function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -+ const roster = useRoster((value) => value); -+ const shown = useCards?.((state) => state[sessionId]) ?? NONE; -+ const loading = useSession?.(historyLoading) === true; -+ const bot = roster.byId[sessionId]; -+ if (!bot || currentPart(bot) !== sessionId) return null; -+ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -+ const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -+ if (waiting.length === 0) return null; -+ return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+} -+var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; -+function ScopeEditor({ secret, roster, onSave }) { -+ const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); -+ const [picked, setPicked] = (0, import_react7.useState)(secret.scope === "all" ? [] : secret.scope); -+ const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -+ const next = mode === "all" ? "all" : mode === "none" ? [] : picked; -+ const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit-block" }, -+ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -+ [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text]) => (0, import_react7.createElement)( -+ "label", -+ { key: id, className: "bt-secret-radio" }, -+ (0, import_react7.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -+ text -+ )) -+ ), -+ mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { -+ key: bot.id, -+ type: "button", -+ className: "bt-secret-bot", -+ "aria-pressed": picked.includes(bot.id), -+ onClick: () => toggle(bot.id) -+ }, (0, import_react7.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react7.createElement)(CheckIcon) : null))) : null, -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-row-actions" }, -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) -+ ) -+ ); -+} -+function ValueEditor({ secret, onSave }) { -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const [replaced, setReplaced] = (0, import_react7.useState)(false); -+ const long = value.trim().length >= MIN_VALUE; -+ const save = () => { -+ if (!long) return; -+ void Promise.resolve(onSave(value)).then((ok) => { -+ if (ok) { -+ setValue(""); -+ setReplaced(true); -+ } -+ }); -+ }; -+ const edit = (next) => { -+ setValue(next); -+ setReplaced(false); -+ }; -+ return (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-edit-block" }, -+ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -+ (0, import_react7.createElement)( -+ "div", -+ { className: "bt-secret-inline" }, -+ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -+ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -+ ), -+ value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ ); } -@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} -@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} @@ -5817,6 +5805,82 @@ diff --git a/client.js b/client.js -.bt-earlier .bt-event{margin:4px 0} -.bt-part-line{display:flex;align-items:center;gap:10px;margin:10px 0 6px;font-size:11px;line-height:14px;color:var(--bt-ink-3)} -.bt-part-line::before,.bt-part-line::after{content:"";flex:1;height:1px;background:var(--bt-line)} ++function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { ++ const roster = useRoster((value) => value); ++ const shown = useCards?.((state) => state[sessionId]) ?? NONE; ++ const loading = useSession?.(historyLoading) === true; ++ const bot = roster.byId[sessionId]; ++ if (!bot || currentPart(bot) !== sessionId) return null; ++ const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); ++ const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); ++ if (waiting.length === 0) return null; ++ return (0, import_react7.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++} ++var scopeMode = (scope) => scope === "all" ? "all" : scope.length === 0 ? "none" : "some"; ++function ScopeEditor({ secret, roster, onSave }) { ++ const [mode, setMode] = (0, import_react7.useState)(scopeMode(secret.scope)); ++ const [picked, setPicked] = (0, import_react7.useState)(secret.scope === "all" ? [] : secret.scope); ++ const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); ++ const next = mode === "all" ? "all" : mode === "none" ? [] : picked; ++ const same2 = next === "all" ? secret.scope === "all" : secret.scope !== "all" && [...next].sort().join() === [...secret.scope].sort().join(); ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-secret-edit-block" }, ++ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, ++ [["all", t("All my Bots")], ["some", t("Chosen Bots")], ["none", t("No Bot")]].map(([id, text]) => (0, import_react7.createElement)( ++ "label", ++ { key: id, className: "bt-secret-radio" }, ++ (0, import_react7.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), ++ text ++ )) ++ ), ++ mode === "some" ? (0, import_react7.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react7.createElement)("button", { ++ key: bot.id, ++ type: "button", ++ className: "bt-secret-bot", ++ "aria-pressed": picked.includes(bot.id), ++ onClick: () => toggle(bot.id) ++ }, (0, import_react7.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react7.createElement)(CheckIcon) : null))) : null, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-secret-row-actions" }, ++ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) ++ ) ++ ); ++} ++function ValueEditor({ secret, onSave }) { ++ const [value, setValue] = (0, import_react7.useState)(""); ++ const [replaced, setReplaced] = (0, import_react7.useState)(false); ++ const long = value.trim().length >= MIN_VALUE; ++ const save = () => { ++ if (!long) return; ++ void Promise.resolve(onSave(value)).then((ok) => { ++ if (ok) { ++ setValue(""); ++ setReplaced(true); ++ } ++ }); ++ }; ++ const edit = (next) => { ++ setValue(next); ++ setReplaced(false); ++ }; ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-secret-edit-block" }, ++ (0, import_react7.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-secret-inline" }, ++ (0, import_react7.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react7.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), ++ (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) ++ ), ++ value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null ++ ); ++} +function SecretRow({ secret, roster, actions, run, open, onToggle }) { + const [confirm, setConfirm] = (0, import_react7.useState)(false); + (0, import_react7.useEffect)(() => { @@ -5982,6 +6046,16 @@ diff --git a/client.js b/client.js - style.textContent = text; - document.head.appendChild(style); - return style; +-} +-var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; +-var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; +-function sheetClassNames(sheet) { +- const names = []; +- const visit = (rules) => { +- for (const rule of rules) { +- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); +- if (rule.cssRules) visit(rule.cssRules); +- } + +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { @@ -5996,13 +6070,25 @@ diff --git a/client.js b/client.js + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) + }; -+ }; + }; +- try { +- visit(sheet.cssRules); +- } catch { + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; + if (!bot || seats.some((item) => item.local?.id === id)) continue; + seats.push({ key: id, local: bot, record: { id }, person: cleanPeerName(roster.self?.displayName, ""), bot: bot.name, mine: true }); -+ } + } +- return names; +-} +-function compileFragmentSelectors(text, names) { +- const compiled = /* @__PURE__ */ new Map(); +- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { +- if (!compiled.has(fragment)) { +- const exact = []; +- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); +- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); + if (!left) return seats; + const gone = records.filter((p) => p.leftSeq !== void 0 && !seats.some((item) => item.key === (p.handle || p.id))).map((item) => ({ ...seat(item), left: true })); + return [...seats, ...gone]; @@ -6033,11 +6119,26 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; -+ } + } +- return compiled.get(fragment); +- }); + people2.push({ key, name: seat.person || (seat.mine ? cleanPeerName(roster.self?.displayName, "") : "") || t("Someone"), mine: seat.mine, seats: [seat] }); + } + return [...people2.filter((person) => person.mine), ...people2.filter((person) => !person.mine)]; -+} + } +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); +- } +- for (const name of list) names.add(name); +function facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; +} @@ -6087,8 +6188,28 @@ diff --git a/client.js b/client.js + setError(failure?.message ?? String(failure)); + } finally { + setBusy(false); -+ } -+ }; + } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +- }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); +- }; +- const observer = new MutationObserver(schedule); +- observer.observe(document.head, { childList: true }); +- document.addEventListener("load", onLoad, true); +- return () => { +- observer.disconnect(); +- document.removeEventListener("load", onLoad, true); +- clearTimeout(timer); +- style.remove(); + }; + if (!waiting) { + const failed = held.status === "failed"; + return (0, import_react8.createElement)( @@ -6120,15 +6241,29 @@ diff --git a/client.js b/client.js + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain + ); } --var FRAGMENT_SELECTOR = /\[class\*="(_[\w-]+)"\]/g; --var CLASS_TOKEN = /\.((?:\\.|[\w-])+)/g; --function sheetClassNames(sheet) { -- const names = []; -- const visit = (rules) => { -- for (const rule of rules) { -- if (rule.selectorText) for (const match of rule.selectorText.matchAll(CLASS_TOKEN)) names.push(match[1]); -- if (rule.cssRules) visit(rule.cssRules); -+ + +-// src/client/mark.js +-var import_react2 = require("react"); +-var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; +-var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; +-var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; +-function useGaze(ref, enabled) { +- (0, import_react2.useEffect)(() => { +- if (!enabled) return void 0; +- let frame = 0; +- let point = null; +- const place = () => { +- frame = 0; +- const node = ref.current; +- if (!node || !point) return; +- const box = node.getBoundingClientRect(); +- const clamp = (value) => Math.max(-1, Math.min(1, value)); +- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); +- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); +- }; +- const onMove = (event) => { +- point = { x: event.clientX, y: event.clientY }; +- if (!frame) frame = requestAnimationFrame(place); +// src/client/turn-reasoning.js +function turnReasoning(steps, current) { + const members = steps.length ? steps : [current]; @@ -6206,21 +6341,56 @@ diff --git a/client.js b/client.js + const onDown = (event) => { + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); -+ }; + }; +- const onLeave = () => { +- point = null; +- ref.current?.style.setProperty("--bt-gx", "0"); +- ref.current?.style.setProperty("--bt-gy", "0"); + const onKey = (event) => { + if (event.key === "Escape") { + event.stopPropagation(); + close(); + } -+ }; + }; +- window.addEventListener("pointermove", onMove, { passive: true }); +- document.documentElement.addEventListener("pointerleave", onLeave); + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); -+ return () => { + return () => { +- cancelAnimationFrame(frame); +- window.removeEventListener("pointermove", onMove); +- document.documentElement.removeEventListener("pointerleave", onLeave); + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey, true); -+ }; + }; +- }, [enabled]); + }, [open]); -+} + } +-var FIDGETS = ["hop", "sway", "stretch", "look"]; +-function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { +- const look = given ?? lookOf(bot); +- const ref = (0, import_react2.useRef)(null); +- const maskId = (0, import_react2.useRef)(""); +- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; +- const [poked, setPoked] = (0, import_react2.useState)(false); +- useGaze(ref, gaze); +- const common = { +- ref, +- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", +- "data-shape": look.image ? "image" : shapeId(look.shape), +- "data-state": STATES.includes(state) ? state : "idle", +- "data-live": live ? "" : void 0, +- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, +- "data-gaze": gaze ? "" : void 0, +- "data-poke": pokeable ? "" : void 0, +- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, +- role: label ? "img" : void 0, +- "aria-label": label, +- "aria-hidden": label ? void 0 : true, +- onClick: pokeable ? () => setPoked(true) : void 0, +- onAnimationEnd: poked ? (event) => { +- if (/^bt-poke/.test(event.animationName)) setPoked(false); +- } : void 0 +function MessageTools({ text, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react9.useState)(null); + const [place, setPlace] = (0, import_react9.useState)(null); @@ -6233,15 +6403,15 @@ diff --git a/client.js b/client.js + if (panel === kind) { + close(); + return; - } ++ } + const rect = event.currentTarget.getBoundingClientRect(); + const below = rect.bottom + 220 < window.innerHeight; + setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); + setPanel(kind); }; -- try { -- visit(sheet.cssRules); -- } catch { +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); + const copy2 = () => { + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); @@ -6279,7 +6449,12 @@ diff --git a/client.js b/client.js + item(t("Download"), DownloadIcon, download2) + ), document.body) : null + ); -+} + } +-function StarIcon({ size = 8 }) { +- return (0, import_react2.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +function ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react9.createElement)( @@ -6287,8 +6462,10 @@ diff --git a/client.js b/client.js + { className: "bt-think" }, + (0, import_react9.createElement)("summary", null, (0, import_react9.createElement)("span", { className: "bt-think-caret", "aria-hidden": true }, "›"), t("Thought it through")), + (0, import_react9.createElement)("div", { className: "bt-think-text" }, text) -+ ); -+} + ); + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -6298,7 +6475,12 @@ diff --git a/client.js b/client.js + if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; + return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; + }, [fileMentions, openFile, seq, tail, turn]); -+} + } +-function MainStar() { +- return (0, import_react2.createElement)( +- "svg", +- { viewBox: "0 0 20 20", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) +function useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; @@ -6347,8 +6529,24 @@ diff --git a/client.js b/client.js + }), + reasoning ? (0, import_react9.createElement)(ThinkingRow, { text: reasoning }) : null, + held && bubbles ? (0, import_react9.createElement)(HeldReply, { held, roster, sessionId, actions, closing }, bubbles) : bubbles -+ ); -+} + ); + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +- } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -6359,7 +6557,19 @@ diff --git a/client.js b/client.js + children, + closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null + ); -+} + } +-function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { +- const settled = useSettled(state); +- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); +- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, +- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), +- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, +- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, +- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, +- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null +function PersonMessage({ name, texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi }) { + const lead = (0, import_react9.createElement)("span", { className: "bt-lead", title: name }, (0, import_react9.createElement)(PersonMark, { name })); + return (0, import_react9.createElement)( @@ -6367,8 +6577,15 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg bt-person-msg", "data-person": name }, + (0, import_react9.createElement)("span", { className: "bt-group-name" }, name), + (0, import_react9.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, sessionId, msgKey, useUi, lead }) -+ ); -+} + ); + } +-function clusterSeats(count, frame) { +- if (count === 1) return [[0, 0, frame]]; +- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; +- if (count === 3) { +- const size = frame * 5 / 9; +- const rest = frame - size; +- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { + if (whole && bots.length === 1) { + return (0, import_react9.createElement)( @@ -6383,7 +6600,8 @@ diff --git a/client.js b/client.js + ) + ); } -- return names; +- const half = frame / 2; +- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; + const parts = []; + parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); + bots.forEach((bot, index) => { @@ -6409,7 +6627,18 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); -+} + } +-function RoomAvatar({ room: room2, roster, size = 36 }) { +- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); +- const people = members.length === 1 ? [members[0], null] : members; +- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); +- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); +- if (size <= 20 && people.length > 1) { +- return (0, import_react2.createElement)( +- "span", +- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, +- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), +- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null +var HELD_SENDING_MS = 6e4; +function heldLineState(room2, { seq, botId, text } = {}, now = Date.now()) { + if (room2?.origin !== "face-to-face" || !Array.isArray(room2.heldLines)) return void 0; @@ -6429,8 +6658,13 @@ diff --git a/client.js b/client.js + { className: "bt-event bt-not-sent", "data-flybot": "held-not-sent", role: "status" }, + (0, import_react9.createElement)(BotMark, { bot, size: 16 }), + (0, import_react9.createElement)("span", null, t("{name} · Not sent", { name: bot.name })) -+ ); -+ } + ); + } +- const seats = clusterSeats(Math.min(people.length, 4), size); +- const overflow = people.length > 4 ? people.length - 3 : 0; +- const gap = size / 14; +- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); +- return (0, import_react2.createElement)( + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react9.createElement)( + "button", @@ -6438,18 +6672,55 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(BotMark, { bot, size: 22 }) + ); + const name = (0, import_react9.createElement)( -+ "span", + "span", +- { className: "bt-cluster", style: { width: size, height: size } }, +- seats.map(([x, y, s], index) => { +- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); +- const style = { left: x, top: y, width: s, height: s }; +- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); +- if (overflow > 0 && index === 3) { +- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); +- } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); +- }) + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + faceBotLabel(room2, roster, bot) ?? bot.name, + room2?.admin === bot.id ? (0, import_react9.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -+ ); + ); +-} +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) + return (0, import_react9.createElement)( + "div", + { className: "bt-group-msg" }, + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+ ); -+} + ); + } +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +-} +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6517,45 +6788,42 @@ diff --git a/client.js b/client.js + ), + (0, import_react9.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); + ); } --function compileFragmentSelectors(text, names) { -- const compiled = /* @__PURE__ */ new Map(); -- return text.replace(FRAGMENT_SELECTOR, (whole, fragment) => { -- if (!compiled.has(fragment)) { -- const exact = []; -- for (const name of names) if (name.includes(fragment)) exact.push(`.${name}`); -- compiled.set(fragment, exact.length ? `:is(${exact.join(",")})` : whole); -- } -- return compiled.get(fragment); -- }); +-function WaveIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) +- ); +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); } --function installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); +-function ShareIcon() { +- return (0, import_react3.createElement)( +function GlobeMark() { + return (0, import_react9.createElement)( -+ "svg", + "svg", +- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) -+ ); -+} + ); + } +-function CloseIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); +-var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); +-var lineIcon = (paths) => function LineIcon() { +- return (0, import_react3.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 }, +- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); + const [error, setError] = (0, import_react9.useState)(""); @@ -6574,28 +6842,8 @@ diff --git a/client.js b/client.js + setError(String(reason?.message ?? reason)); + } finally { + setBusy(""); - } -- const next = compileFragmentSelectors(text, names); -- if (style.textContent !== next) style.textContent = next; -- }; -- render(); -- let timer; -- const schedule = () => { -- clearTimeout(timer); -- timer = setTimeout(render, 120); -- }; -- const onLoad = (event) => { -- if (event.target instanceof HTMLLinkElement) schedule(); -- }; -- const observer = new MutationObserver(schedule); -- observer.observe(document.head, { childList: true }); -- document.addEventListener("load", onLoad, true); -- return () => { -- observer.disconnect(); -- document.removeEventListener("load", onLoad, true); -- clearTimeout(timer); -- style.remove(); - }; ++ } ++ }; + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -6605,88 +6853,49 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null -+ ); + ); +-}; +-var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; +-function CloudDownloadIcon({ done = false }) { +- return (0, import_react3.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 }, +- (0, import_react3.createElement)("path", { d: CLOUD }), +- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) ++} ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react9.createElement)("span", null, prefix), ++ room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null + ); } -- --// src/client/mark.js --var import_react2 = require("react"); --var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; --var botState = (running, activity, alert) => running ? STATES.includes(activity) ? activity : "working" : alert ? "alert" : "idle"; --var toolState = (name) => /^(message_bot|stop_bot|create_bot|update_bot|create_group|update_group|delete_group|post_to_group)$/.test(name) ? "sending" : /search|fetch|browse|read|grep|glob|list|recall/i.test(name) ? "searching" : "working"; --function useGaze(ref, enabled) { -- (0, import_react2.useEffect)(() => { -- if (!enabled) return void 0; -- let frame = 0; -- let point = null; -- const place = () => { -- frame = 0; -- const node = ref.current; -- if (!node || !point) return; -- const box = node.getBoundingClientRect(); -- const clamp = (value) => Math.max(-1, Math.min(1, value)); -- node.style.setProperty("--bt-gx", clamp((point.x - box.left - box.width / 2) / 160).toFixed(2)); -- node.style.setProperty("--bt-gy", clamp((point.y - box.top - box.height / 2) / 160).toFixed(2)); -- }; -- const onMove = (event) => { -- point = { x: event.clientX, y: event.clientY }; -- if (!frame) frame = requestAnimationFrame(place); -- }; -- const onLeave = () => { -- point = null; -- ref.current?.style.setProperty("--bt-gx", "0"); -- ref.current?.style.setProperty("--bt-gy", "0"); -- }; -- window.addEventListener("pointermove", onMove, { passive: true }); -- document.documentElement.addEventListener("pointerleave", onLeave); -- return () => { -- cancelAnimationFrame(frame); -- window.removeEventListener("pointermove", onMove); -- document.documentElement.removeEventListener("pointerleave", onLeave); -- }; -- }, [enabled]); --} --var FIDGETS = ["hop", "sway", "stretch", "look"]; --function BotMark({ bot, look: given, size = 36, state = "idle", live = false, gaze = false, pokeable = false, label }) { -- const look = given ?? lookOf(bot); -- const ref = (0, import_react2.useRef)(null); -- const maskId = (0, import_react2.useRef)(""); -- if (maskId.current === "") maskId.current = `bt-mark-${nextMarkSerial()}`; -- const [poked, setPoked] = (0, import_react2.useState)(false); -- useGaze(ref, gaze); -- const common = { -- ref, -- className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -- "data-shape": look.image ? "image" : shapeId(look.shape), -- "data-state": STATES.includes(state) ? state : "idle", -- "data-live": live ? "" : void 0, -- "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, -- "data-gaze": gaze ? "" : void 0, -- "data-poke": pokeable ? "" : void 0, -- style: { width: size, height: size, ...live ? { "--bt-delay": `${-(fnv1a(String(bot?.id ?? "")) % 5e3) / 1e3}s`, "--bt-fidget-delay": `${-(fnv1a(`${bot?.id ?? ""}:when`) % 9e3) / 1e3}s` } : {} }, -- role: label ? "img" : void 0, -- "aria-label": label, -- "aria-hidden": label ? void 0 : true, -- onClick: pokeable ? () => setPoked(true) : void 0, -- onAnimationEnd: poked ? (event) => { -- if (/^bt-poke/.test(event.animationName)) setPoked(false); -- } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); -+function GroupEvent({ prefix, room: room2, name, roster, actions }) { -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)("span", null, prefix), -+ room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null -+ ); - } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( +-var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); +-var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); +-var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); +-var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); +-var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); +-var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); +-var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); +-var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); +-var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); +-var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); +-var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); +-var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); +-var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); +-var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); +-var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); +-function PinMarkIcon() { +- return (0, import_react3.createElement)( - "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) +- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, +- (0, import_react3.createElement)( +- "g", +- { transform: "rotate(45 10 10)" }, +- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), +- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) +- ) +function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -6701,16 +6910,45 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) ); } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); +-var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); +-var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); +-var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); +-var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); +-var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); +-var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); +-var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); +-var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); +-var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); +-var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); +-var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); +-var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); +-var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; +- +-// src/client/questions.js +-var import_react4 = require("react"); +-var keyLetter = (index) => String.fromCharCode(65 + index); +-var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); +-var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; +-function useOptionKeys(count, onPick, ownRef) { +- const pick2 = (0, import_react4.useRef)(onPick); +- pick2.current = onPick; +- (0, import_react4.useEffect)(() => { +- if (count === 0) return void 0; +- const onKey = (event) => { +- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; +- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; +- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; +- const index = event.key.toUpperCase().charCodeAt(0) - 65; +- if (index >= 0 && index < count) { +- event.preventDefault(); +- pick2.current(index); +- } else if (index === count && ownRef.current) { +- event.preventDefault(); +- ownRef.current.focus(); +- } +function PartLine({ time }) { + return (0, import_react9.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react9.createElement)("span", null, dayLabel(time)) : null); - } --function MainStar() { -- return (0, import_react2.createElement)( -- "svg", -- { viewBox: "0 0 20 20", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) ++} +var scrollParent = (node) => { + for (let at = node.parentElement; at; at = at.parentElement) { + if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; @@ -6743,7 +6981,10 @@ diff --git a/client.js b/client.js + load(void 0); + return () => { + alive.current = false; -+ }; + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + }, [sessionId]); + (0, import_react9.useLayoutEffect)(() => { + const pending = restore.current; @@ -6803,17 +7044,21 @@ diff --git a/client.js b/client.js + } + }), + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) - ); ++ ); } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); +- return; +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6848,7 +7093,30 @@ diff --git a/client.js b/client.js + if (source.remote?.handle) { + const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; -+ } + } +- onAnswer({ selected: [label] }); +- }; +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); +- }; +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); + if (source.kind === "bot") { + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; @@ -6886,83 +7154,47 @@ diff --git a/client.js b/client.js + whole: true + }); } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; --} --function BotAvatar({ bot, size, state = "idle", main, admin, badge = true, live = true, gaze = false, pokeable = false }) { -- const settled = useSettled(state); -- if (bot === void 0) return (0, import_react2.createElement)(BotMark, { size }); -- const badgeKind = !badge ? null : state === "alert" ? "alert" : state !== "idle" ? "working" : admin ? "admin" : main ? "main" : null; -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-mark", style: { width: size, height: size, "--bt-size": `${size}px` } }, -- (0, import_react2.createElement)(BotMark, { bot, size, state: settled ? "done" : state, live, gaze, pokeable }), -- badgeKind === "working" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-working", "aria-label": t("Working"), role: "img" }) : null, -- badgeKind === "alert" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-alert", "aria-label": t("Waiting for your answer"), role: "img" }, "?") : null, -- badgeKind === "admin" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-admin", "aria-label": t("Admin"), role: "img" }, (0, import_react2.createElement)(StarIcon)) : null, -- badgeKind === "main" ? (0, import_react2.createElement)("span", { className: "bt-badge bt-badge-main", "aria-label": t("Main Bot"), role: "img" }, (0, import_react2.createElement)(MainStar)) : null -- ); --} --function clusterSeats(count, frame) { -- if (count === 1) return [[0, 0, frame]]; -- if (count === 2) return [[0, 0, frame * 2 / 3], [frame / 3, frame / 3, frame * 2 / 3]]; -- if (count === 3) { -- const size = frame * 5 / 9; -- const rest = frame - size; -- return [[rest / 2, 0, size], [0, rest, size], [rest, rest, size]]; +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( +- "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); + if (source.kind === "schedule") { + const prompts = reminderPrompts(contentText(data.content)); + return (0, import_react9.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + } + if (source.kind === "user-question-reply") return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); - } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; - } --function RoomAvatar({ room: room2, roster, size = 36 }) { -- const members = room2.members.map((id) => roster.byId[id]).filter(Boolean); -- const people = members.length === 1 ? [members[0], null] : members; -- const seat = (bot, seatSize, key) => bot ? (0, import_react2.createElement)(BotMark, { key, bot, size: seatSize }) : (0, import_react2.createElement)("span", { key, className: "bt-cluster-you", style: { width: seatSize * 0.92, height: seatSize * 0.92, margin: seatSize * 0.04 } }); -- if (people.length === 0) return (0, import_react2.createElement)(BotMark, { size }); -- if (size <= 20 && people.length > 1) { -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-stack", style: { "--bt-stack-size": `${size}px` } }, -- people.slice(0, 3).map((bot, index) => seat(bot, size, bot?.id ?? `you-${index}`)), -- people.length > 3 ? (0, import_react2.createElement)("span", { className: "bt-stack-more" }, `+${people.length - 3}`) : null -- ); -- } -- const seats = clusterSeats(Math.min(people.length, 4), size); -- const overflow = people.length > 4 ? people.length - 3 : 0; -- const gap = size / 14; -- const rings = seats.map(([x, y, s]) => ({ cx: x + s / 2, cy: y + s / 2, r: s * 0.48 + gap })); -- return (0, import_react2.createElement)( -- "span", -- { className: "bt-cluster", style: { width: size, height: size } }, -- seats.map(([x, y, s], index) => { -- const holes2 = rings.slice(index + 1).filter((ring) => Math.hypot(ring.cx - x - s / 2, ring.cy - y - s / 2) < ring.r + s * 0.46).map((ring) => `radial-gradient(circle at ${ring.cx - x}px ${ring.cy - y}px,transparent ${ring.r}px,#000 ${ring.r + 0.5}px)`); -- const style = { left: x, top: y, width: s, height: s }; -- if (holes2.length > 0) Object.assign(style, { WebkitMaskImage: holes2.join(","), maskImage: holes2.join(","), WebkitMaskComposite: "source-in", maskComposite: "intersect" }); -- if (overflow > 0 && index === 3) { -- return (0, import_react2.createElement)("span", { key: "more", className: "bt-cluster-more", style: { ...style, width: s * 0.92, height: s * 0.92, margin: s * 0.04, fontSize: Math.max(8, Math.round(s * 0.36)) } }, `+${overflow}`); -- } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); -- }) -- ); ++ } ++} +function NewDivider() { + return (0, import_react9.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react9.createElement)("span", null, t("New"))); - } -- --// src/client/icons.js --var import_react3 = require("react"); --function PlusIcon() { -- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++} +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react9.useEffect)(() => { @@ -6980,20 +7212,10 @@ diff --git a/client.js b/client.js + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; - } --function SearchIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); ++} +function Nothing() { + return null; - } --function PeopleIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -- ); ++} +var HIDDEN_REMOVED_TOOLS = /* @__PURE__ */ new Set(["ask_user_question"]); +var isConnectorTool = (name) => typeof name === "string" && /^mcp__(github|composio)__/.test(name); +var SILENT_REGISTRATION_TOOLS = /* @__PURE__ */ new Set([ @@ -7042,12 +7264,20 @@ diff --git a/client.js b/client.js + if (isHiddenNotice(block)) continue; + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); -+ } + } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); - } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); ++} +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -7058,28 +7288,61 @@ diff --git a/client.js b/client.js + if (days === 1) return t("Yesterday {time}", { time: clock4 }); + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; - } --function MicIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), -- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) -- ); ++} +function nextTurnStart(turns, turn) { + let next; + for (const key in turns) { + const candidate = Number(key); + if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; -+ } + } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); + return next === void 0 ? void 0 : turns[next].first; } --function WaveIcon({ size = 16 }) { -- return (0, import_react3.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- [[3, 7.5, 5], [6.5, 4.5, 11], [10, 2.5, 15], [13.5, 5.5, 9], [17, 8, 4]].map(([x, y, height]) => (0, import_react3.createElement)("rect", { key: x, x: x - 1, y, width: 2, height, rx: 1 })) -- ); +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +function openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -7088,19 +7351,28 @@ diff --git a/client.js b/client.js + const listed = firstTurns.split(",").map(Number); + const earlier = listed.filter((entry) => entry < turn); + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) ++} +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; -+ } + } +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) + return false; +} +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; @@ -7128,58 +7400,26 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : null ); } --function CloseIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M5 5l10 10M15 5L5 15", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; +function Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); - } --var ChevronLeftIcon = () => (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M12.5 4.5L7 10l5.5 5.5" })); --var ChevronRightIcon = () => (0, import_react3.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M7.5 4.5L13 10l-5.5 5.5" })); --var lineIcon = (paths) => function LineIcon() { -- return (0, import_react3.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 }, -- paths.map((d, index) => (0, import_react3.createElement)("path", { key: index, d })) -- ); ++} +var PHRASES = { + thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], + working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] - }; --var CLOUD = "M6.5 15.25H5.75a3.25 3.25 0 0 1-.55-6.45 4.75 4.75 0 0 1 9.2-1.1 3.75 3.75 0 0 1 .1 7.55H13.5"; --function CloudDownloadIcon({ done = false }) { -- return (0, import_react3.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 }, -- (0, import_react3.createElement)("path", { d: CLOUD }), -- done ? (0, import_react3.createElement)("path", { d: "M7.75 13.25l1.75 1.75 3-3.25" }) : (0, import_react3.createElement)("g", { className: "bt-cloud-arrow" }, (0, import_react3.createElement)("path", { d: "M10 9.75v7" }), (0, import_react3.createElement)("path", { d: "M7.75 14.5L10 16.75l2.25-2.25" })) -- ); --} --var SmileIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M7.3 11.6a3.4 3.4 0 0 0 5.4 0", "M7.75 8.25h.01M12.25 8.25h.01"]); --var ReplyIcon = lineIcon(["M8 5L4 9l4 4", "M4 9h7.5a4.5 4.5 0 0 1 4.5 4.5V15"]); --var DotsIcon = lineIcon(["M5 10h.01M10 10h.01M15 10h.01"]); --var CheckIcon = lineIcon(["M4.5 10.5l3.5 3.5 7.5-8"]); --var ArrowDownIcon = lineIcon(["M10 4v12", "M5 11l5 5 5-5"]); --var DownloadIcon = lineIcon(["M10 3.5v9", "M6.5 9.5L10 13l3.5-3.5", "M4 16h12"]); --var GaugeIcon = lineIcon(["M3.5 13.5a6.5 6.5 0 1 1 13 0", "M10 13.5l3-4", "M10 13.5h.01"]); --var PlugIcon = lineIcon(["M7 3v4M13 3v4", "M5 7h10v2.5a5 5 0 0 1-10 0z", "M10 14.5V17"]); --var CalendarIcon = lineIcon(["M5.5 5h9A1.5 1.5 0 0 1 16 6.5v8a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 4 14.5v-8A1.5 1.5 0 0 1 5.5 5z", "M4 8.75h12", "M7.25 3.5v3M12.75 3.5v3"]); --var ConnectorIcon = lineIcon(["M7 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M18 10a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z", "M7 10h6"]); --var SwitchIcon = lineIcon(["M4 7h9.5M10.5 3.5L14 7l-3.5 3.5", "M16 13H6.5M9.5 9.5L6 13l3.5 3.5"]); --var PaperclipIcon = lineIcon(["M15.5 9.5l-5.6 5.6a3.4 3.4 0 0 1-4.8-4.8l6.1-6.1a2.25 2.25 0 0 1 3.2 3.2l-6 6a1.1 1.1 0 0 1-1.6-1.6l5.5-5.5"]); --var ClockIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 6.5V10l2.5 1.5"]); --var GearIcon = lineIcon(["M10 7.4a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2z", "M7.85 4.4l.29-1.87a7.7 7.7 0 0 1 3.72 0l.29 1.87a6 6 0 0 1 1.63.94l1.76-.69a7.7 7.7 0 0 1 1.86 3.23l-1.47 1.18a6 6 0 0 1 0 1.88l1.47 1.18a7.7 7.7 0 0 1-1.86 3.23l-1.76-.69a6 6 0 0 1-1.63.94l-.29 1.87a7.7 7.7 0 0 1-3.72 0l-.29-1.87a6 6 0 0 1-1.63-.94l-1.76.69a7.7 7.7 0 0 1-1.86-3.23l1.47-1.18a6 6 0 0 1 0-1.88L2.6 7.88a7.7 7.7 0 0 1 1.86-3.23l1.76.69a6 6 0 0 1 1.63-.94z"]); --var PinIcon = lineIcon(["M12.5 3l4.5 4.5-3 1.5-2.5 2.5.5 3.5-1.5 1.5L7 13 3.5 16.5", "M7 13l-3.5-3.5L5 8l3.5.5L11 6l1.5-3"]); --function PinMarkIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 12, height: 12, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -- (0, import_react3.createElement)( -- "g", -- { transform: "rotate(45 10 10)" }, -- (0, import_react3.createElement)("path", { d: "M8 2.5h4a.9.9 0 0 1 0 1.8h-.4v4.1c0 1.4 1.1 2.6 2.6 3.1l.7.3v1.5H5.1v-1.5l.7-.3c1.5-.5 2.6-1.7 2.6-3.1V4.3H8a.9.9 0 0 1 0-1.8z" }), -- (0, import_react3.createElement)("path", { d: "M9.35 13.3h1.3v3.6L10 18l-.65-1.1z" }) -- ) -- ); ++}; +var TOOL_PHRASES = [ + [/web_?search|search_?web/i, "Searching online"], + [/fetch|browse|browser|web/i, "Reading a page"], @@ -7205,7 +7445,10 @@ diff --git a/client.js b/client.js + case "message_bot": { + const target = findBot(arg("to")); + return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -+ } + } +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + case "stop_bot": { + const target = findBot(arg("bot")); + return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; @@ -7233,42 +7476,7 @@ diff --git a/client.js b/client.js + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } - } --var UnreadIcon = lineIcon(["M10 3.5a4.5 4.5 0 0 0-4.5 4.5v3L4 13.5h12L14.5 11V8", "M8.3 16a1.8 1.8 0 0 0 3.4 0", "M15.5 3.2a1.6 1.6 0 1 1 0 3.2 1.6 1.6 0 0 1 0-3.2z"]); --var PencilIcon = lineIcon(["M9 4H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-3", "M14.3 3.7a1.6 1.6 0 0 1 2.3 2.3L10.5 12l-3 .8.8-3z"]); --var InfoIcon = lineIcon(["M10 3.5a6.5 6.5 0 1 1 0 13 6.5 6.5 0 0 1 0-13z", "M10 9.25v4", "M10 6.6h.01"]); --var StarLineIcon = lineIcon(["M10 3l2.1 4.3 4.7.7-3.4 3.3.8 4.7L10 13.8 5.8 16l.8-4.7L3.2 8l4.7-.7z"]); --var CopyIcon = lineIcon(["M8.5 7h6A1.5 1.5 0 0 1 16 8.5v6a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 7 14.5v-6A1.5 1.5 0 0 1 8.5 7z", "M4 12.5V5.5A1.5 1.5 0 0 1 5.5 4H13"]); --var HideIcon = lineIcon(["M3 3l14 14", "M8.6 5.2A7.6 7.6 0 0 1 10 5c4.5 0 7 5 7 5a12.6 12.6 0 0 1-2 2.6M12.2 14.5A6.8 6.8 0 0 1 10 15c-4.5 0-7-5-7-5a12.4 12.4 0 0 1 3-3.4", "M8.5 8.5a2 2 0 0 0 3 3"]); --var TrashIcon = lineIcon(["M3.5 5.5h13", "M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5", "M5 5.5l.7 10A1.5 1.5 0 0 0 7.2 17h5.6a1.5 1.5 0 0 0 1.5-1.5l.7-10", "M8.5 9v4.5M11.5 9v4.5"]); --var DuplicateIcon = lineIcon(["M7 7h8.5v9.5H7z", "M4.5 13V3.5H13", "M11.25 9.5v4.5M9 11.75h4.5"]); --var SlidersIcon = lineIcon(["M4 6.5h5M12.5 6.5H16M4 13.5h4M11.5 13.5H16", "M10.5 4.5v4M9.5 11.5v4"]); --var MemoryIcon = lineIcon(["M5.5 3.5h8A1.5 1.5 0 0 1 15 5v10a1.5 1.5 0 0 1-1.5 1.5h-8z", "M5.5 3.5v13", "M8.5 7.5h3.5M8.5 10.5h2.5", "M12 3.5v4l1-.8 1 .8"]); --var FolderIcon = lineIcon(["M3 6A1.5 1.5 0 0 1 4.5 4.5h3.2l1.8 2h6A1.5 1.5 0 0 1 17 8v6.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 3 14.5z"]); --var ChatsIcon = lineIcon(["M3.5 5.5A1.5 1.5 0 0 1 5 4h7a1.5 1.5 0 0 1 1.5 1.5V10A1.5 1.5 0 0 1 12 11.5H8L5 14v-2.5A1.5 1.5 0 0 1 3.5 10z", "M13.5 7.5H15A1.5 1.5 0 0 1 16.5 9v4.5A1.5 1.5 0 0 1 15 15v2l-2.5-2h-3A1.5 1.5 0 0 1 8 13.5V13"]); --var SETTINGS_ICONS = { general: GearIcon, bots: PeopleIcon, groups: ChatsIcon, usage: GaugeIcon }; -- --// src/client/questions.js --var import_react4 = require("react"); --var keyLetter = (index) => String.fromCharCode(65 + index); --var optionsOf = (question) => (question?.options ?? []).map((option) => typeof option === "string" ? { label: option } : option); --var isTypingTarget = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement || node instanceof HTMLElement && node.isContentEditable; --function useOptionKeys(count, onPick, ownRef) { -- const pick2 = (0, import_react4.useRef)(onPick); -- pick2.current = onPick; -- (0, import_react4.useEffect)(() => { -- if (count === 0) return void 0; -- const onKey = (event) => { -- if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.key.length !== 1) return; -- if (isTypingTarget(event.target) || isTypingTarget(document.activeElement)) return; -- if (document.querySelector('[role="dialog"], [aria-modal="true"]') !== null) return; -- const index = event.key.toUpperCase().charCodeAt(0) - 65; -- if (index >= 0 && index < count) { -- event.preventDefault(); -- pick2.current(index); -- } else if (index === count && ownRef.current) { -- event.preventDefault(); -- ownRef.current.focus(); ++} +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); @@ -7299,50 +7507,9 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); - } ++ } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); --} --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++ }; + void tick(); + return () => { + stop = true; @@ -7355,57 +7522,8 @@ diff --git a/client.js b/client.js + replies = JSON.parse(contentText(content)).replies ?? []; + } catch { + replies = []; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); - } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); ++ } ++ } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( @@ -7431,28 +7549,7 @@ diff --git a/client.js b/client.js + ), + room2?.origin === "face-to-face" ? (0, import_react9.createElement)("span", { className: "bt-lead", title: who || void 0 }, (0, import_react9.createElement)(PersonMark, { name: who || void 0 })) : null + ); - } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot ++ } + if (reply.kind === "failed") { + return (0, import_react9.createElement)( + "div", @@ -7511,39 +7608,18 @@ diff --git a/client.js b/client.js + } + return (0, import_react9.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); + }) - ); ++ ); } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) -- ); -- } -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) -- ); +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +var DRAWN = /* @__PURE__ */ new Set(["message_bot", "stop_bot", "create_bot", "update_bot", "group_relay", "create_group", "update_group", "delete_group", "post_to_group", "ask_user", "request_secret", "ask_user_question", "read_group_chat", "remember", "forget"]); +var CARDS = /* @__PURE__ */ new Set(["ask_user", "request_secret"]); +function callTree(root) { @@ -7555,23 +7631,21 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); -+ }; + }; +- return (0, import_react4.createElement)( +- "div", +- { className: inline ? "bt-q-inline" : "bt-q-dock" }, +- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) +- ); + visit(root); + return calls; } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); -- return; +-function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const question = useRoster((value) => value.questions?.[sessionId]); +- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; +- const loading = useSession?.(historyLoading) === true; +- if (question === void 0 || question.callId && (drawn || loading)) return null; +- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); +function useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7580,7 +7654,12 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); -+} + } +-function AnsweredCell({ node }) { +- const data = node.data ?? {}; +- const questions = Array.isArray(data.questions) ? data.questions : []; +- if (questions.length === 0) { +- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7591,10 +7670,7 @@ diff --git a/client.js b/client.js + const target = findBot(arg("to")) ?? { name: arg("to") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't message {name}", { name: target.name })); + return (0, import_react9.createElement)(EventLine, { prefix: t("Messaged"), bots: [target], onOpen: (bot) => bot.id && actions.openExchange(self?.id ?? sessionId, bot.id), whole: true }); - } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ } + case "stop_bot": { + const target = findBot(arg("bot")) ?? { name: arg("bot") ?? "…", color: "gray" }; + if (done && call4.isError) return (0, import_react9.createElement)("div", { className: "bt-event" }, t("Couldn't stop {name}", { name: target.name })); @@ -7659,24 +7735,14 @@ diff --git a/client.js b/client.js + return done && !call4.isError ? (0, import_react9.createElement)(MemoryEvent, { sessionId, scope: arg("scope"), result: contentText(call4.content), roster, actions, findBot }) : null; + default: + return null; -+ } - } --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); -- }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); -- }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); + } +- const answers = Array.isArray(data.answers) ? data.answers : []; +- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { +- key: question.id, +- question, +- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } +- }))); ++} +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { + const roster = useRoster((value) => value); + const root = node.data.root; @@ -7693,40 +7759,6 @@ diff --git a/client.js b/client.js + const env = { sessionId, roster, actions, useUi, findBot }; + return (0, import_react9.createElement)(import_react9.Fragment, null, drawn.map((call4, index) => (0, import_react9.createElement)(import_react9.Fragment, { key: call4.callId ?? index }, renderCall(call4, env)))); } --function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const question = useRoster((value) => value.questions?.[sessionId]); -- const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; -- const loading = useSession?.(historyLoading) === true; -- if (question === void 0 || question.callId && (drawn || loading)) return null; -- return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+ -+// src/client/awaiting.js -+var AWAIT_REPLY_MS = 10 * 60 * 1e3; -+function awaitingReply(roster, sessionId, now = Date.now()) { -+ const at = roster?.remoteAwaiting?.[sessionId]; -+ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; -+ return now - at < AWAIT_REPLY_MS; - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -- } -- const answers = Array.isArray(data.answers) ? data.answers : []; -- return (0, import_react4.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react4.createElement)(SettledQuestion, { -- key: question.id, -- question, -- answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } -- }))); -+ -+// src/client/remote-approval-wait.js -+var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; -+function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { -+ if (!remote) return null; -+ if (tool && inputAt <= (tool.at ?? 0)) return null; -+ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; - } -// src/client/secret-card.js -var import_react5 = require("react"); @@ -7756,6 +7788,22 @@ diff --git a/client.js b/client.js - if (scope === "all") return t("All Bots"); - const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); - return names.length === 0 ? t("No Bot") : names.join(t(" and ")); ++// src/client/awaiting.js ++var AWAIT_REPLY_MS = 10 * 60 * 1e3; ++function awaitingReply(roster, sessionId, now = Date.now()) { ++ const at = roster?.remoteAwaiting?.[sessionId]; ++ if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; ++ return now - at < AWAIT_REPLY_MS; ++} ++ ++// src/client/remote-approval-wait.js ++var REMOTE_APPROVAL_LABEL = "Waiting for their approval"; ++function remoteApprovalPhrase(remote, { tool, inputAt = 0 } = {}) { ++ if (!remote) return null; ++ if (tool && inputAt <= (tool.at ?? 0)) return null; ++ return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; ++} ++ +// src/client/activity-row.js +var import_react10 = require("react"); +var import_react_dom2 = require("react-dom"); @@ -7829,24 +7877,7 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; - } -- }); --} --function ScopeSwitch({ value, onChange, bot }) { -- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, -- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), -- options.map(([id, text]) => (0, import_react5.createElement)("button", { -- key: id, -- type: "button", -- role: "radio", -- "aria-checked": value === id, -- className: "bt-secret-seg", -- onClick: () => onChange(id) -- }, text)) -- ); ++ } + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); @@ -7868,20 +7899,7 @@ diff --git a/client.js b/client.js + }; + }, [remote, sending]); + return replied; - } --function SecretHead({ bot, title, detail, name, onClose }) { -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head bt-secret-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), -- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") -- ), -- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null -- ); ++} +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react10.useState)(null); + (0, import_react10.useEffect)(() => { @@ -7906,65 +7924,7 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; - } --function SecretCard({ bot, request, roster, actions }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [scope, setScope] = (0, import_react5.useState)("all"); -- const [replacing, setReplacing] = (0, import_react5.useState)(false); -- const [busy, setBusy] = (0, import_react5.useState)(false); -- const [error, setError] = (0, import_react5.useState)(""); -- const noteId = (0, import_react5.useId)(); -- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); -- const run = (promise) => { -- setBusy(true); -- setError(""); -- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -- }; -- const cancel = () => run(actions.secretCancel(request.id)); -- const long = value.trim().length >= MIN_VALUE; -- const save = () => { -- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); -- }; -- const allow = () => { -- if (!busy) void run(actions.secretAllow(request.id)); -- }; -- const filling = request.kind === "fill" || replacing; -- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, -- (0, import_react5.createElement)(SecretHead, { -- bot, -- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), -- detail: request.purpose, -- name: request.name, -- onClose: busy ? void 0 : cancel -- }), -- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, -- filling ? (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-body" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), -- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null -- ) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-note", id: noteId }, -- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), -- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), -- ".", -- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" -- ), -- warn, -- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-foot" }, -- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), -- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) -- ) -- ); ++} +function useRowEntrances(replacing) { + const check = (0, import_react10.useRef)(replacing); + check.current = replacing; @@ -8011,11 +7971,7 @@ diff --git a/client.js b/client.js + observer.disconnect(); + }; + }, []); - } --function SettledSecret({ bot, request, roster }) { -- const canceled = request.status === "canceled"; -- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); -- return (0, import_react5.createElement)( ++} +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -8054,7 +8010,12 @@ diff --git a/client.js b/client.js + if (wait <= 0) { + make(); + return void 0; -+ } + } +- }); +-} +-function ScopeSwitch({ value, onChange, bot }) { +- const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; +- return (0, import_react5.createElement)( + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -8112,18 +8073,17 @@ diff --git a/client.js b/client.js + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react10.createElement)( "div", -- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), -- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( -- "p", -- { className: "bt-q-title" }, -- text, -- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) -- )), -- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- { className: "bt-secret-scope", role: "radiogroup", "aria-label": t("Who may use it") }, +- (0, import_react5.createElement)("span", { className: "bt-secret-glide", "data-at": value === "all" ? 0 : 1, "aria-hidden": true }), +- options.map(([id, text]) => (0, import_react5.createElement)("button", { +- key: id, +- type: "button", +- role: "radio", +- "aria-checked": value === id, +- className: "bt-secret-seg", +- onClick: () => onChange(id) +- }, text)) +- ); + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react10.createElement)( @@ -8135,12 +8095,22 @@ diff --git a/client.js b/client.js + (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + ) - ) -- ); ++ ) + ), host); } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +-function SecretHead({ bot, title, detail, name, onClose }) { +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head bt-secret-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 28 }), (0, import_react5.createElement)("span", { className: "bt-secret-lock" }, (0, import_react5.createElement)(LockIcon))), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react5.createElement)("p", { className: "bt-q-title" }, title), +- (0, import_react5.createElement)("div", { className: "bt-q-detail" }, (0, import_react5.createElement)("code", { className: "bt-secret-name" }, name), detail ? ` · ${detail}` : "") +- ), +- onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null +- ); + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -8151,15 +8121,64 @@ diff --git a/client.js b/client.js + if (dsh.kind === "npm") return true; + return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -- } -- return null; +-function SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) +- ) +- ); + +// src/client/overlay-hooks.js +var import_react11 = require("react"); @@ -8179,16 +8198,25 @@ diff --git a/client.js b/client.js + }; + }, []); } --function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { -- const roster = useRoster((value) => value); -- const shown = useCards?.((state) => state[sessionId]) ?? NONE; -- const loading = useSession?.(historyLoading) === true; -- const bot = roster.byId[sessionId]; -- if (!bot || currentPart(bot) !== sessionId) return null; -- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); -- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); -- if (waiting.length === 0) return null; -- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); +-function SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) +- ); +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -8197,38 +8225,8 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } --function ScopeEditor({ secret, roster, onSave }) { -- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); -- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); -- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); -- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, -- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( -- "label", -- { key: id, className: "bt-secret-radio" }, -- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), -- text -- )) -- ), -- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { -- key: bot.id, -- type: "button", -- className: "bt-secret-bot", -- "aria-pressed": picked.includes(bot.id), -- onClick: () => toggle(bot.id) -- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) -- ) -- ); +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); + +// src/client/member-status.js +var import_react12 = require("react"); @@ -8250,6 +8248,73 @@ diff --git a/client.js b/client.js + const doing = workIn(room2, work, id); + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; ++ }); + } +-function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { +- const bot = roster.byId[sessionId]; +- if (!bot) return null; +- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); +- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); +- if (done && /already saved/.test(contentText(content))) { +- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); +- } +- return null; ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); + } +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +- const roster = useRoster((value) => value); +- const shown = useCards?.((state) => state[sessionId]) ?? NONE; +- const loading = useSession?.(historyLoading) === true; +- const bot = roster.byId[sessionId]; +- if (!bot || currentPart(bot) !== sessionId) return null; +- const drawn = (entry) => entry.callId && entry.sessionId === sessionId && (loading || shown[entry.callId] > 0); +- const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); +- if (waiting.length === 0) return null; +- return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); ++function splitBusy(busy) { ++ const here = busy.filter((entry) => entry.state === "work" && entry.here); ++ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); ++ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; + } +-function ScopeEditor({ secret, roster, onSave }) { +- const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); +- const [picked, setPicked] = (0, import_react5.useState)(secret.scope === "all" ? [] : secret.scope); +- const toggle = (id) => setPicked((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); +- const same = mode === "all" ? secret.scope === "all" : secret.scope !== "all" && [...picked].sort().join() === [...secret.scope].sort().join(); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Who may use it")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-radios", role: "radiogroup", "aria-label": t("Who may use {name}", { name: secret.name }) }, +- [["all", t("All Bots")], ["some", t("Chosen Bots")]].map(([id, text]) => (0, import_react5.createElement)( +- "label", +- { key: id, className: "bt-secret-radio" }, +- (0, import_react5.createElement)("input", { type: "radio", name: `scope-${secret.name}`, checked: mode === id, onChange: () => setMode(id) }), +- text +- )) +- ), +- mode === "some" ? (0, import_react5.createElement)("div", { className: "bt-secret-bots" }, roster.bots.map((bot) => (0, import_react5.createElement)("button", { +- key: bot.id, +- type: "button", +- className: "bt-secret-bot", +- "aria-pressed": picked.includes(bot.id), +- onClick: () => toggle(bot.id) +- }, (0, import_react5.createElement)(BotAvatar, { bot, size: 20, badge: false, live: false }), bot.name, picked.includes(bot.id) ? (0, import_react5.createElement)(CheckIcon) : null))) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) +- ) +- ); ++function botGroupWork(botId, work, roster) { ++ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { ++ const entry = entries2.find((item) => item.botId === botId); ++ const room2 = roster.roomsById[roomId]; ++ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); } -function ValueEditor({ secret, onSave }) { @@ -8280,10 +8345,21 @@ diff --git a/client.js b/client.js - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) - ), - value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -- ); -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); ++function summaryText(busy) { ++ const asks = busy.filter((entry) => entry.state === "ask"); ++ const workers = busy.filter((entry) => entry.state === "work"); ++ const names = asks.slice(0, 2).map((entry) => entry.bot.name); ++ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); ++ const first = workers[0]; ++ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; ++ return { waiting, doing }; ++} ++function Face({ entry, size }) { ++ return (0, import_react12.createElement)( ++ "span", ++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, ++ (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) + ); } -function SecretRow({ secret, roster, actions, run, open, onToggle }) { - const [confirm, setConfirm] = (0, import_react5.useState)(false); @@ -8310,7 +8386,32 @@ diff --git a/client.js b/client.js - { className: "bt-set-control" }, - secret.scope === "all" ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("All Bots")) : allowed.length === 0 ? (0, import_react5.createElement)("span", { className: "bt-tag" }, t("No Bot")) : (0, import_react5.createElement)("span", { className: "bt-secret-faces", title: allowed.map((bot) => bot.name).join(", ") }, allowed.slice(0, 5).map((bot) => (0, import_react5.createElement)(BotAvatar, { key: bot.id, bot, size: 20, badge: false, live: false }))) - ) -- ), ++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; ++function WorkRow({ bot, tool, step, label, at, now, onOpen }) { ++ const text = `${label} · ${workText(tool, step)}`; ++ const elapsed = elapsedSince(now, at); ++ return (0, import_react12.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), ++ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ++ ); ++} ++function SummaryLine({ busy, now, onOpen }) { ++ const { waiting, doing } = summaryText(busy); ++ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; ++ return (0, import_react12.createElement)( ++ "button", ++ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), ++ (0, import_react12.createElement)( ++ "span", ++ { className: "bt-mw-row-text" }, ++ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, ++ waiting && doing ? " · " : null, ++ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null + ), - open ? (0, import_react5.createElement)( - "div", - { className: "bt-secret-edit" }, @@ -8326,32 +8427,7 @@ diff --git a/client.js b/client.js - ] : (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => setConfirm(true) }, (0, import_react5.createElement)(TrashIcon), t("Delete")) - ) - ) : null -+function splitBusy(busy) { -+ const here = busy.filter((entry) => entry.state === "work" && entry.here); -+ const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); -+ return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; -+} -+function botGroupWork(botId, work, roster) { -+ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { -+ const entry = entries2.find((item) => item.botId === botId); -+ const room2 = roster.roomsById[roomId]; -+ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; -+ }); -+} -+function summaryText(busy) { -+ const asks = busy.filter((entry) => entry.state === "ask"); -+ const workers = busy.filter((entry) => entry.state === "work"); -+ const names = asks.slice(0, 2).map((entry) => entry.bot.name); -+ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); -+ const first = workers[0]; -+ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; -+ return { waiting, doing }; -+} -+function Face({ entry, size }) { -+ return (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, -+ (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } -function AddSecret({ actions, run, onDone }) { @@ -8367,210 +8443,6 @@ diff --git a/client.js b/client.js - onDone(); - } - }); -- }; -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-set-row bt-set-paste bt-secret-add" }, -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), -- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), -- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) -- ) -+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -+function WorkRow({ bot, tool, step, label, at, now, onOpen }) { -+ const text = `${label} · ${workText(tool, step)}`; -+ const elapsed = elapsedSince(now, at); -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row", "aria-label": [text, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), -+ (0, import_react12.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react12.createElement)(Shimmer, null, text)), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } --function SecretsPage({ roster, actions, run, Section }) { -- const [open, setOpen] = (0, import_react5.useState)(null); -- const [adding, setAdding] = (0, import_react5.useState)(false); -- const secrets = roster.secrets ?? []; -- return [ -- (0, import_react5.createElement)( -- "p", -- { key: "about", className: "bt-secret-about" }, -- t("Keys your Bots asked for. Bots never see the values: they use them as "), -- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), -- t(" in their shell, and every value is replaced by "), -- (0, import_react5.createElement)("code", null, "[secret:NAME]"), -- t(" before anything goes to a model.") -- ), -- (0, import_react5.createElement)( -- Section, -- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, -- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, -- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), -- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null -+function SummaryLine({ busy, now, onOpen }) { -+ const { waiting, doing } = summaryText(busy); -+ const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; -+ return (0, import_react12.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row bt-mw-line", "aria-label": [waiting, doing, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react12.createElement)("span", { className: "bt-mw-stack", "aria-hidden": true }, busy.slice(0, 4).map((entry) => (0, import_react12.createElement)(Face, { key: entry.id, entry, size: 16 }))), -+ (0, import_react12.createElement)( -+ "span", -+ { className: "bt-mw-row-text" }, -+ waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, -+ waiting && doing ? " · " : null, -+ doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null - ), -- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) -- ]; --} --var SECRET_CSS = ` --.bt-secret-head{align-items:center;gap:10px} --.bt-secret-who{position:relative;flex:none;display:inline-flex} --.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} --.bt-secret-lock svg{width:11px;height:11px} --.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} --.bt-secret-body{display:flex;flex-direction:column;gap:8px} --.bt-secret-field{align-items:center;min-height:36px} --.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} --.bt-secret-input[data-masked]{-webkit-text-security:disc} --.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} --.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} --.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} --.bt-secret-glide[data-at="1"]{transform:translateX(100%)} --.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} --.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} --.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --/* A key card asks the user to act, so the process group carrying it stays open in every -- work-details mode (same override as .bt-group-replies in styles.js). */ --[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} --[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} --.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} --@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} --.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} --.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} --.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} --.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} --.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} --.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} --.bt-secret-done .bt-q-head{align-items:center} --.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} --.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} --.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} --@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} --@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} --.bt-secret-event{display:inline-flex;align-items:center;gap:5px} --.bt-secret-dock{flex-direction:column;align-items:center} --.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} --.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} --.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} --.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} --.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} --.bt-secret-faces{display:inline-flex} --.bt-secret-faces>*+*{margin-left:-6px} --.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} --.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} --.bt-secret-radios{display:flex;gap:16px;font-size:13px} --.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} --.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} --.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;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:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} --.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} --.bt-secret-bot svg{width:13px;height:13px} --.bt-secret-inline{display:flex;gap:8px;align-items:center} --.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} --.bt-secret-row-actions .bt-set-hint{flex:1} --.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} --.bt-secret-danger svg{width:13px;height:13px} --.bt-secret-add .bt-input{height:34px} --@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} --`; -- --// src/client/turn-reasoning.js --function turnReasoning(steps, current) { -- const members = steps.length ? steps : [current]; -- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; -- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); --} --var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); --var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); --var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); -- --// src/client/cells.js --var import_react6 = require("react"); --var import_react_dom = require("react-dom"); --var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); --var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); --function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { -- const onClick = (event) => { -- const target = mentionTarget(event); -- if (target === null) return; -- event.preventDefault(); -- event.stopPropagation(); -- actions.openSession(target); -- }; -- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, -- texts2.map((text, index) => { -- const key = msgKey ? `${msgKey}:${index}` : void 0; -- const picked = key ? reactions?.[index] ?? [] : []; -- const last = index === texts2.length - 1; -- const live = streaming && last; -- return (0, import_react6.createElement)( -- "div", -- { key: index, className: "bt-msg" }, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-msg-line" }, -- lead && last ? lead : null, -- (0, import_react6.createElement)( -- "div", -- { className: "bt-bubble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) -- ), -- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) -- ), -- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { -- key: emoji, -- type: "button", -- className: "bt-react", -- "aria-label": t("Remove reaction {emoji}", { emoji }), -- onClick: () => actions.toggleReaction(msgKey, index, emoji) -- }, emoji))) : null -- ); -- }) -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { -- (0, import_react6.useEffect)(() => { -- if (!open) return void 0; -- const onDown = (event) => { -- if (refs.some((ref) => ref.current?.contains(event.target))) return; -- close(); -- }; -- const onKey = (event) => { -- if (event.key === "Escape") { -- event.stopPropagation(); -- close(); -- } -- }; -- window.addEventListener("mousedown", onDown); -- window.addEventListener("keydown", onKey, true); -- return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -8615,43 +8487,13 @@ diff --git a/client.js b/client.js + const column = flow.getBoundingClientRect(); + node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; + node.style.width = `${Math.round(column.width)}px`; - }; -- }, [open]); ++ }; + place(); + const observer = new ResizeObserver(place); + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); - } --function MessageTools({ text, name, sessionId, react, actions }) { -- const [panel, setPanel] = (0, import_react6.useState)(null); -- const [place, setPlace] = (0, import_react6.useState)(null); -- const [copied, setCopied] = (0, import_react6.useState)(false); -- const tools = (0, import_react6.useRef)(null); -- const pop = (0, import_react6.useRef)(null); -- const close = () => setPanel(null); -- useDismiss(panel !== null, close, [tools, pop]); -- const open = (kind, event) => { -- if (panel === kind) { -- close(); -- return; -- } -- const rect = event.currentTarget.getBoundingClientRect(); -- const below = rect.bottom + 220 < window.innerHeight; -- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); -- setPanel(kind); -- }; -- const copy2 = () => { -- void navigator.clipboard?.writeText(text).then(() => { -- setCopied(true); -- setTimeout(() => setCopied(false), 1e3); -- }); -- }; -- const download2 = () => { -- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); -- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); -- link.click(); -- setTimeout(() => URL.revokeObjectURL(url), 1e3); ++} +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -8672,18 +8514,14 @@ diff --git a/client.js b/client.js + setBusy(true); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); }; -- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { -- close(); -- run(); -- } }, (0, import_react6.createElement)(Icon), label); -- return (0, import_react6.createElement)( +- return (0, import_react5.createElement)( + return (0, import_react12.createElement)( "div", -- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, -- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, -- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, -- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), -- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- { className: "bt-set-row bt-set-paste bt-secret-add" }, +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("Key name"), placeholder: t("NAME, e.g. GITHUB_TOKEN"), value: name, spellCheck: false, autoComplete: "off", autoFocus: true, onChange: (event) => setName(event.target.value.toUpperCase()) }), +- (0, import_react5.createElement)("input", { className: "bt-input", "aria-label": t("What it is for"), placeholder: t("What it is for"), value: purpose, autoComplete: "off", onChange: (event) => setPurpose(event.target.value) }), +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value") })), +- (0, import_react5.createElement)( + { className: "bt-mw-card", role: "group", "aria-label": t("{name} is waiting for your approval", { name: bot.name }) }, + (0, import_react12.createElement)("div", { className: "bt-mw-tool" }, approval.tool), + approval.detail?.text ? (0, import_react12.createElement)("div", { className: "bt-mw-text" }, approval.detail.text) : null, @@ -8691,34 +8529,131 @@ diff --git a/client.js b/client.js + approval.detail?.code ? (0, import_react12.createElement)("code", { className: "bt-mw-code", title: approval.detail.code }, approval.detail.code) : null, + approval.answerable ? (0, import_react12.createElement)( "div", -- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, -- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { -- close(); -- react(emoji); -- } }, emoji)) -- ), document.body) : null, -- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- { className: "bt-secret-row-actions" }, +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("New keys are for all Bots; narrow it down afterwards.")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), +- (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => answer("reject") }, t("Reject")), + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: () => answer("allow") }, t("Allow once")), + ...alwaysChoices(approval, bot).map(([choice, label]) => (0, import_react12.createElement)("button", { key: choice, type: "button", className: "bt-q-btn", "data-choice": choice, disabled: busy, onClick: () => answer(choice) }, t(label))) + ) : (0, import_react12.createElement)( - "div", -- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, -- item(t("Copy"), CopyIcon, copy2), -- item(t("Download"), DownloadIcon, download2) -- ), document.body) : null ++ "div", + { className: "bt-mw-actions" }, + (0, import_react12.createElement)("button", { type: "button", className: "bt-q-btn", onClick: () => actions.openSession(bot.id) }, t("Open {name}'s chat", { name: bot.name })) -+ ) + ) ); } --function ThinkingRow({ text }) { -- return (0, import_react6.createElement)( -- "details", -- { className: "bt-think" }, -- (0, import_react6.createElement)("summary", null, t("Thought it through")), -- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +-function SecretsPage({ roster, actions, run, Section }) { +- const [open, setOpen] = (0, import_react5.useState)(null); +- const [adding, setAdding] = (0, import_react5.useState)(false); +- const secrets = roster.secrets ?? []; +- return [ +- (0, import_react5.createElement)( +- "p", +- { key: "about", className: "bt-secret-about" }, +- t("Keys your Bots asked for. Bots never see the values: they use them as "), +- (0, import_react5.createElement)("code", null, "$DSH_SECRET_NAME"), +- t(" in their shell, and every value is replaced by "), +- (0, import_react5.createElement)("code", null, "[secret:NAME]"), +- t(" before anything goes to a model.") +- ), +- (0, import_react5.createElement)( +- Section, +- { key: "list", title: t("Secrets · {count}", { count: secrets.length }) }, +- secrets.length === 0 && !adding ? (0, import_react5.createElement)("div", { className: "bt-set-row" }, (0, import_react5.createElement)("span", { className: "bt-set-hint" }, t("No keys yet. When a Bot needs one, it shows a card in its chat."))) : null, +- secrets.map((secret) => (0, import_react5.createElement)(SecretRow, { key: secret.name, secret, roster, actions, run, open: open === secret.name, onToggle: () => setOpen(open === secret.name ? null : secret.name) })), +- adding ? (0, import_react5.createElement)(AddSecret, { actions, run, onDone: () => setAdding(false) }) : null +- ), +- adding ? null : (0, import_react5.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setAdding(true) }, t("Add a key"))) +- ]; +-} +-var SECRET_CSS = ` +-.bt-secret-head{align-items:center;gap:10px} +-.bt-secret-who{position:relative;flex:none;display:inline-flex} +-.bt-secret-lock{position:absolute;right:-5px;bottom:-4px;width:16px;height:16px;border-radius:50%;background:var(--bt-main);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 0 0 1.5px var(--bt-bubble)} +-.bt-secret-lock svg{width:11px;height:11px} +-.bt-secret-name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:16px;padding:1px 6px;border-radius:5px;background:var(--bt-hover);color:var(--bt-ink)} +-.bt-secret-body{display:flex;flex-direction:column;gap:8px} +-.bt-secret-field{align-items:center;min-height:36px} +-.bt-secret-input{flex:1;width:100%;min-width:0;margin:0;padding:0;border:0;outline:none;background:transparent;color:var(--bt-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;line-height:20px;letter-spacing:.04em} +-.bt-secret-input[data-masked]{-webkit-text-security:disc} +-.bt-secret-input::placeholder{color:var(--bt-ink-3);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;letter-spacing:0} +-.bt-secret-scope{position:relative;display:inline-grid;grid-template-columns:1fr 1fr;align-self:flex-start;padding:2px;border-radius:999px;background:var(--bt-hover)} +-.bt-secret-glide{position:absolute;top:2px;bottom:2px;left:2px;width:calc(50% - 2px);border-radius:999px;background:var(--bt-main);box-shadow:0 1px 3px rgba(20,20,20,.08),inset 0 0 0 .5px color-mix(in srgb,var(--bt-ink) 6%,transparent);transition:${spring(SPRING_SHAPE, "transform")}} +-.bt-secret-glide[data-at="1"]{transform:translateX(100%)} +-.bt-secret-seg{position:relative;z-index:1;height:28px;padding:0 14px;border:0;border-radius:999px;background:none;color:var(--bt-ink-2);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .16s ease} +-.bt-secret-seg[aria-checked=true]{color:var(--bt-ink);font-weight:500} +-.bt-secret-seg:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-/* A key card asks the user to act, so the process group carrying it stays open in every +- work-details mode (same override as .bt-group-replies in styles.js). */ +-[data-step-process]:has(.bt-secret)>div:first-child{display:none!important} +-[data-step-process]:has(.bt-secret)>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} +-.bt-secret-ok{display:flex;align-items:center;gap:4px;margin-top:6px;animation:bt-secret-in .22s cubic-bezier(.22,1,.36,1)} +-.bt-secret-ok svg{width:13px;height:13px;color:#16a34a} +-@keyframes bt-secret-in{from{opacity:0;transform:translateY(-2px)}} +-.bt-secret-saved{display:flex;align-items:center;gap:6px;font-size:13px;line-height:18px;color:var(--bt-ink-2)} +-.bt-secret-note{font-size:12px;line-height:17px;color:var(--bt-ink-3)} +-.bt-secret-note code,.bt-secret-about code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px} +-.bt-secret-warn{display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;font-size:12px;line-height:16px;color:#b25e09;background:color-mix(in srgb,#f59e0b 12%,transparent)} +-.bt-secret-error{font-size:12px;line-height:16px;color:#e02135} +-.bt-secret .bt-q-btn:disabled{opacity:.5;cursor:default} +-.bt-secret-done .bt-q-head{align-items:center} +-.bt-secret-done .bt-q-title{font-size:13px;line-height:18px;color:var(--bt-ink-2);font-weight:500} +-.bt-secret-scope-tag{font-weight:400;color:var(--bt-ink-3)} +-.bt-secret-check{color:var(--bt-green,#22a06b);animation:bt-secret-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-secret-done{animation:bt-secret-settle .32s cubic-bezier(.22,1,.36,1) backwards} +-@keyframes bt-secret-pop{from{opacity:0;transform:scale(.4)}} +-@keyframes bt-secret-settle{from{opacity:.4;transform:scale(.985)}} +-.bt-secret-event{display:inline-flex;align-items:center;gap:5px} +-.bt-secret-dock{flex-direction:column;align-items:center} +-.bt-secret-dock .bt-q{max-width:min(550px,calc(100% - 32px))} +-.bt-secret-about{margin:0 0 12px;padding:0 4px;font-size:12px;line-height:17px;color:var(--bt-ink-2)} +-.bt-secret-title{display:flex;align-items:center;gap:6px;min-width:0;color:var(--bt-ink)} +-.bt-secret-title svg{flex:none;color:var(--bt-ink-2)} +-.bt-secret-purpose{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--bt-ink-2)} +-.bt-secret-faces{display:inline-flex} +-.bt-secret-faces>*+*{margin-left:-6px} +-.bt-secret-edit{display:flex;flex-direction:column;gap:14px;padding:4px 12px 14px;animation:bt-q-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} +-.bt-secret-edit-block{display:flex;flex-direction:column;gap:8px} +-.bt-secret-edit-label{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-2)} +-.bt-secret-radios{display:flex;gap:16px;font-size:13px} +-.bt-secret-radio{display:inline-flex;align-items:center;gap:6px;cursor:pointer} +-.bt-secret-bots{display:flex;flex-wrap:wrap;gap:6px} +-.bt-secret-bot{display:inline-flex;align-items:center;gap:6px;height:30px;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:12px;cursor:pointer;transition:background-color .12s ease,color .12s ease,border-color .12s ease} +-.bt-secret-bot[aria-pressed=true]{background:var(--bt-active);color:var(--bt-ink);border-color:var(--bt-line-2)} +-.bt-secret-bot svg{width:13px;height:13px} +-.bt-secret-inline{display:flex;gap:8px;align-items:center} +-.bt-secret-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap} +-.bt-secret-row-actions .bt-set-hint{flex:1} +-.bt-secret-danger{color:#e02135;display:inline-flex;align-items:center;gap:4px} +-.bt-secret-danger svg{width:13px;height:13px} +-.bt-secret-add .bt-input{height:34px} +-@media (prefers-reduced-motion:reduce){.bt-secret-glide{transition:none}.bt-secret-check,.bt-secret-done,.bt-secret-ok,.bt-secret-edit{animation:none}} +-`; +- +-// src/client/turn-reasoning.js +-function turnReasoning(steps, current) { +- const members = steps.length ? steps : [current]; +- if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; +- return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); +-} +-var texts = (step) => step.blocks.filter((block) => block.kind === "text" && block.text.trim() !== ""); +-var thinkingLive = (step) => step.status === "running" && texts(step).length === 0 && step.blocks.some((block) => block.kind === "reasoning"); +-var stepReasoning = (step) => step.blocks.filter((block) => block.kind === "reasoning").map((block) => block.text).join("\n").trim(); +- +-// src/client/cells.js +-var import_react6 = require("react"); +-var import_react_dom = require("react-dom"); +-var import_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives"); +-var mdLabels = () => ({ code: { copyLabel: t("Copy"), copiedLabel: t("Copied") }, footnotes: t("Footnotes") }); +-function Bubbles({ texts: texts2, roster, selfId, actions, streaming, sessionId, msgKey, useUi, lead, mentions }) { +- const onClick = (event) => { +- const target = mentionTarget(event); +- if (target === null) return; +- event.preventDefault(); +- event.stopPropagation(); +- actions.openSession(target); +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react12.useState)(false); + if (entry.state === "ask") { @@ -8733,7 +8668,39 @@ diff --git a/client.js b/client.js + const stop = () => { + setStopping(true); + void Promise.resolve(actions.stopMemberWork(room2.id, bot.id)).catch((error) => actions.reportError(error, "stop")).finally(() => setStopping(false)); -+ }; + }; +- const reactions = useUi ? useUi((state) => msgKey ? state.reactions?.[msgKey] ?? null : null) : null; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-astack", "data-lead": lead ? "" : void 0, onClickCapture: onClick }, +- texts2.map((text, index) => { +- const key = msgKey ? `${msgKey}:${index}` : void 0; +- const picked = key ? reactions?.[index] ?? [] : []; +- const last = index === texts2.length - 1; +- const live = streaming && last; +- return (0, import_react6.createElement)( +- "div", +- { key: index, className: "bt-msg" }, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( +- "div", +- { className: "bt-bubble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: linkMentions(text, roster.bots, selfId), streaming: live, labels: mdLabels(), variant: "compact", fileMentions: mentions }) +- ), +- live ? null : (0, import_react6.createElement)(MessageTools, { text, name: roster.byId[selfId]?.name ?? t("Bot"), sessionId, react: key ? (emoji) => actions.toggleReaction(msgKey, index, emoji) : void 0, actions }) +- ), +- picked.length ? (0, import_react6.createElement)("div", { className: "bt-reacts" }, picked.map((emoji) => (0, import_react6.createElement)("button", { +- key: emoji, +- type: "button", +- className: "bt-react", +- "aria-label": t("Remove reaction {emoji}", { emoji }), +- onClick: () => actions.toggleReaction(msgKey, index, emoji) +- }, emoji))) : null +- ); +- }) + return (0, import_react12.createElement)( + "div", + { className: "bt-mw-member" }, @@ -8745,55 +8712,28 @@ diff --git a/client.js b/client.js + ) ); } --function useFileMentions({ node, openFile, fileMentions, useTurnData }) { -- const tail = useTurnData?.("turn-tail"); -- const location2 = node.location; -- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; -- const seq = node.data.finalNode?.seq; -- return (0, import_react6.useMemo)(() => { -- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; -- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; -- }, [fileMentions, openFile, seq, tail, turn]); +-var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +-function useDismiss(open, close, refs) { +- (0, import_react6.useEffect)(() => { +- if (!open) return void 0; +- const onDown = (event) => { +- if (refs.some((ref) => ref.current?.contains(event.target))) return; +- close(); +- }; +- const onKey = (event) => { +- if (event.key === "Escape") { +- event.stopPropagation(); +- close(); +- } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +function useMemberWork(room2, useWork) { + const work = useWork?.((value) => value) ?? NO_WORK; + const entries2 = room2 ? memberWork(room2, work) : []; + const now = useNow(entries2.some((entry) => entry.state !== "idle")); + return { entries: entries2, now }; - } --function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const steps = useTurnSteps(String(data.turn)); -- const contexts = useTurnContext(String(data.turn)); -- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); -- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); -- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); -- const running = data.status === "running"; -- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); -- if (hidden) return null; -- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); -- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; -- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- relays.map((relay) => { -- const source = relay.source; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(EventLine, { -- key: relay.seq, -- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), -- bots: [sender], -- onOpen: () => { -- if (sender.id) actions.openExchange(selfId, sender.id); -- }, -- whole: true -- }); -- }), -- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, -- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null -- ); ++} +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -8882,27 +8822,39 @@ diff --git a/client.js b/client.js + if (!stopped) timer = setTimeout(load, POLL_MS2); + }); + load(); -+ return () => { + return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); -+ }; + }; +- }, [open]); + }, []); + return tasks; } --function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -- if (whole && bots.length === 1) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -- (0, import_react6.createElement)("span", null, prefix), -- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) -- ) -- ); +-function MessageTools({ text, name, sessionId, react, actions }) { +- const [panel, setPanel] = (0, import_react6.useState)(null); +- const [place, setPlace] = (0, import_react6.useState)(null); +- const [copied, setCopied] = (0, import_react6.useState)(false); +- const tools = (0, import_react6.useRef)(null); +- const pop = (0, import_react6.useRef)(null); +- const close = () => setPanel(null); +- useDismiss(panel !== null, close, [tools, pop]); +- const open = (kind, event) => { +- if (panel === kind) { +- close(); +- return; +- } +- const rect = event.currentTarget.getBoundingClientRect(); +- const below = rect.bottom + 220 < window.innerHeight; +- setPlace(kind === "react" ? { left: rect.left + rect.width / 2, top: rect.top - 6, "--bt-origin": "bottom center" } : { left: Math.min(rect.left, window.innerWidth - 208), ...below ? { top: rect.bottom + 4 } : { bottom: window.innerHeight - rect.top + 4, "--bt-origin": "bottom left" } }); +- setPanel(kind); +- }; +- const copy2 = () => { +- void navigator.clipboard?.writeText(text).then(() => { +- setCopied(true); +- setTimeout(() => setCopied(false), 1e3); +var ownBot = (roster, id) => { + const bot = roster.byId?.[id]; + if (bot === void 0 || bot.remote) return void 0; @@ -8920,17 +8872,7 @@ diff --git a/client.js b/client.js + if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; + if (question.question !== text) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; - } -- const parts = []; -- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); -- bots.forEach((bot, index) => { -- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); -- parts.push((0, import_react6.createElement)( -- "button", -- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- bot.name -- )); ++ } + return void 0; +}; +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; @@ -9014,8 +8956,7 @@ diff --git a/client.js b/client.js + title: count === 1 ? t("1 message waits for your approval") : t("{count} messages wait for your approval", { count }), + hint: t("Approve it in the chat"), + ...extra - }); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); ++ }); + for (const bot of roster.bots ?? []) { + const count = bot.pendingCount ?? 0; + if (count <= 0) continue; @@ -9043,8 +8984,13 @@ diff --git a/client.js b/client.js + place: placeOf(roster, roomId), + chat: roomId ?? bot.id, + ...people(roster, bot.id) -+ }); -+ }; + }); + }; +- const download2 = () => { +- const url = URL.createObjectURL(new Blob([text], { type: "text/markdown" })); +- const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); +- link.click(); +- setTimeout(() => URL.revokeObjectURL(url), 1e3); + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -9139,12 +9085,7 @@ diff --git a/client.js b/client.js + return Promise.resolve(actions.answerApproval(card.approval.id, choice)); + } + return void 0; - } --function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { -- const lead = (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) ++} +var answerSlots = /* @__PURE__ */ new Map(); +var answerSlot = (id) => { + let slot = answerSlots.get(id); @@ -9171,7 +9112,31 @@ diff --git a/client.js b/client.js + const note = slot.text.trim(); + if (busy || note === "") return; + onAnswer(card, { note }); -+ }; + }; +- const item = (label, Icon, run) => (0, import_react6.createElement)("button", { key: label, type: "button", role: "menuitem", onClick: () => { +- close(); +- run(); +- } }, (0, import_react6.createElement)(Icon), label); +- return (0, import_react6.createElement)( +- "div", +- { ref: tools, className: "bt-tools", "data-open": panel !== null || void 0 }, +- react ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Add reaction"), title: t("Add reaction"), "aria-expanded": panel === "react", onClick: (event) => open("react", event) }, (0, import_react6.createElement)(SmileIcon)) : null, +- sessionId ? (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("Reply"), title: t("Reply"), onClick: () => actions.quote(sessionId, text) }, (0, import_react6.createElement)(ReplyIcon)) : null, +- (0, import_react6.createElement)("button", { type: "button", className: "bt-tool", "aria-label": t("More message actions"), title: t("More"), "aria-haspopup": "menu", "aria-expanded": panel === "more", onClick: (event) => open("more", event) }, copied ? (0, import_react6.createElement)(CheckIcon) : (0, import_react6.createElement)(DotsIcon)), +- panel === "react" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-react-strip", role: "menu", "aria-label": t("Reactions"), style: place }, +- REACTIONS.map((emoji) => (0, import_react6.createElement)("button", { key: emoji, type: "button", role: "menuitem", "aria-label": emoji, onClick: () => { +- close(); +- react(emoji); +- } }, emoji)) +- ), document.body) : null, +- panel === "more" ? (0, import_react_dom.createPortal)((0, import_react6.createElement)( +- "div", +- { ref: pop, className: "bt-menu", role: "menu", "aria-label": t("Message actions"), style: place }, +- item(t("Copy"), CopyIcon, copy2), +- item(t("Download"), DownloadIcon, download2) +- ), document.body) : null + return (0, import_react13.createElement)( + "span", + { className: "bt-inbox-result" }, @@ -9198,17 +9163,13 @@ diff --git a/client.js b/client.js + } }, t("Send back")), + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: () => onAnswer(card, "accept") }, t("Accept")) ); + } +-function ThinkingRow({ text }) { - return (0, import_react6.createElement)( -- "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( -- "span", -- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, -- bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -- ), -- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+} +- "details", +- { className: "bt-think" }, +- (0, import_react6.createElement)("summary", null, t("Thought it through")), +- (0, import_react6.createElement)("div", { className: "bt-think-text" }, text) +function AnswerFields({ card, busy, onAnswer }) { + const [, redraw] = (0, import_react13.useState)(0); + const question = card.kind === "question"; @@ -9269,14 +9230,50 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) ); } --function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( -- "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("span", null, prefix), -- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +-function useFileMentions({ node, openFile, fileMentions, useTurnData }) { +- const tail = useTurnData?.("turn-tail"); +- const location2 = node.location; +- const turn = location2?.kind === "turn" || location2?.kind === "step" ? location2.turn : void 0; +- const seq = node.data.finalNode?.seq; +- return (0, import_react6.useMemo)(() => { +- if (typeof fileMentions !== "function" || turn?.status !== "closed" || seq === void 0) return void 0; +- return tail?.closing?.finalNode.seq === seq ? fileMentions({ turn, seq, openFile }) : void 0; +- }, [fileMentions, openFile, seq, tail, turn]); +-} +-function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +function InboxRow({ wide, useRoster, useWork, useUi, actions }) { -+ const roster = useRoster((value) => value); + const roster = useRoster((value) => value); +- const data = node.data; +- const steps = useTurnSteps(String(data.turn)); +- const contexts = useTurnContext(String(data.turn)); +- const mentions = useFileMentions({ node, openFile, fileMentions, useTurnData }); +- const hidden = Boolean(roster.roomsById[sessionId]) || isHiddenTurn(roster, sessionId, data.turn); +- const texts2 = groupPart === "reasoning" ? [] : data.blocks.filter((block) => block.kind === "text" && block.text.trim() !== "").map((block) => block.text); +- const running = data.status === "running"; +- useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts2.length > 0 ? { text: true } : null); +- if (hidden) return null; +- const reasoning = groupPart === "response" ? "" : turnReasoning(steps, data); +- const relays = relayPart(data, groupPart) ? relaysBefore(contexts, steps, data) : []; +- if (texts2.length === 0 && !reasoning && relays.length === 0) return null; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- relays.map((relay) => { +- const source = relay.source; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(EventLine, { +- key: relay.seq, +- prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), +- bots: [sender], +- onOpen: () => { +- if (sender.id) actions.openExchange(selfId, sender.id); +- }, +- whole: true +- }); +- }), +- reasoning ? (0, import_react6.createElement)(ThinkingRow, { text: reasoning }) : null, +- texts2.length ? (0, import_react6.createElement)(Bubbles, { texts: texts2, roster, selfId, actions, streaming: running, sessionId, msgKey: `${sessionId}:${data.turn}:${node.key ?? data.seq ?? ""}`, useUi, mentions }) : null + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -9298,19 +9295,43 @@ diff --git a/client.js b/client.js + count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null ); } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( +-function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { +- if (whole && bots.length === 1) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, +- (0, import_react6.createElement)("span", null, prefix), +- (0, import_react6.createElement)(BotMark, { bot: bots[0], size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, bots[0].name) +- ) +- ); +- } +- const parts = []; +- parts.push((0, import_react6.createElement)("span", { key: "p" }, prefix)); +- bots.forEach((bot, index) => { +- if (index > 0) parts.push((0, import_react6.createElement)("span", { key: `j${index}` }, joiner)); +- parts.push((0, import_react6.createElement)( +- "button", +- { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- bot.name +- )); +- }); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); +function Status({ status }) { + const { label, tone, Icon } = STATUSES[status]; + return (0, import_react13.createElement)("span", { className: "bt-inbox-status", "data-tone": tone }, (0, import_react13.createElement)(Icon), (0, import_react13.createElement)("span", null, t(label))); -+} + } +-function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { +- const lead = (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22 }) +- ); +- return (0, import_react6.createElement)( +function whenText(card, now) { + if (card.status === "running" || card.status === "task") { + return typeof card.since === "number" && now - card.since >= 6e4 ? t("for {time}", { time: elapsedLabel(now - card.since) }) : t("just started"); @@ -9329,12 +9350,15 @@ diff --git a/client.js b/client.js + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react13.createElement)( "div", -- { className: "bt-event" }, -- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) +- { className: "bt-group-msg" }, +- (0, import_react6.createElement)( + { className: "bt-inbox-card", role: "group", "aria-label": `${t(STATUSES[card.status].label)}: ${card.title}`, "data-status": card.status }, + (0, import_react13.createElement)("span", { className: "bt-inbox-ava" }, card.bot ? (0, import_react13.createElement)(BotAvatar, { bot: card.bot, size: 28, badge: false, live: false }) : card.room ? (0, import_react13.createElement)(RoomAvatar, { room: card.room, roster, size: 28 }) : null), + (0, import_react13.createElement)( -+ "span", + "span", +- { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + { className: "bt-inbox-copy" }, + (0, import_react13.createElement)( + "span", @@ -9368,7 +9392,8 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)(AnswerFields, { card, busy, onAnswer }), + (0, import_react13.createElement)(ResultActions, { card, busy, onAnswer }), + error ? (0, import_react13.createElement)("span", { className: "bt-inbox-error", role: "alert" }, error) : null -+ ), + ), +- (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react13.createElement)( + "span", + { className: "bt-inbox-actions" }, @@ -9379,8 +9404,13 @@ diff --git a/client.js b/client.js + ) ); } --function PartLine({ time }) { -- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("span", null, prefix), +- room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- ); +function useNow2(ms) { + const [now, setNow] = (0, import_react13.useState)(Date.now); + (0, import_react13.useEffect)(() => { @@ -9389,32 +9419,15 @@ diff --git a/client.js b/client.js + }, [ms]); + return now; } --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; --}; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); -- }); +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; +- const self = roster.byId[sessionId]; +- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +- const other = team || !scope ? void 0 : findBot(scope); +- const target = other && other.id !== self?.id ? other : self; +- if (!target) return null; +- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( +function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { + const work = useWork((value) => value); + const sessions = useSessions((value) => value); @@ -9430,96 +9443,35 @@ diff --git a/client.js b/client.js + if (!feedback) return void 0; + const timer = setTimeout(() => setFeedback(""), FEEDBACK_MS); + return () => clearTimeout(timer); -+ }, [feedback]); -+ const model = inboxModel({ roster, work, tasks, sessions, now }); -+ const groups = visibleNeeds(model, answered); -+ const answer = (card, choice) => { -+ setBusy(card.id); -+ setErrors((value) => ({ ...value, [card.id]: "" })); -+ const pending = inboxAnswer(actions, card, choice); -+ if (pending === void 0) { -+ setBusy(""); -+ return; -+ } -+ void Promise.resolve(pending).then(() => { -+ setAnswered((value) => ({ ...value, [card.id]: true })); -+ const name = card.bot?.name ?? ""; -+ setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); -+ }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); - }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); -- return () => { -- alive.current = false; -- }; -- }, [sessionId]); -- (0, import_react6.useLayoutEffect)(() => { -- const pending = restore.current; -- restore.current = null; -- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; -- }, [view.items]); -- (0, import_react6.useEffect)(() => { -- const node = top.current; -- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; -- const observer = new IntersectionObserver((entries2) => { -- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); -- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); -- observer.observe(node); -- return () => observer.disconnect(); -- }, [view.cursor, view.loading, view.failed]); -- const selfId = roster.byId[sessionId]?.id; -- const rows = []; -- for (const item of view.items) { -- const last = rows.at(-1); -- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); -- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); -- } -- return (0, import_react6.createElement)( -+ const open = (card) => { -+ actions.closeInbox(); -+ actions.openSession(card.chat); -+ }; -+ return (0, import_react13.createElement)( - "div", -- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, -- (0, import_react6.createElement)( -+ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, -+ (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -+ (0, import_react13.createElement)( - "div", -- { ref: top, className: "bt-earlier-top" }, -- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null -- ), -- rows.map((row, index) => { -- const key = rows.length - index; -- switch (row.kind) { -- case "divider": -- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); -- case "user": -- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, -- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null -- )); -- case "answer": -- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); -- case "bot": -- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); -- case "event": { -- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); -- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; -- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); -- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { -- if (selfId && other.id) actions.openExchange(selfId, other.id); -- }, whole: true }); -- } -- default: -- return null; -- } -- }), -- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) ++ }, [feedback]); ++ const model = inboxModel({ roster, work, tasks, sessions, now }); ++ const groups = visibleNeeds(model, answered); ++ const answer = (card, choice) => { ++ setBusy(card.id); ++ setErrors((value) => ({ ...value, [card.id]: "" })); ++ const pending = inboxAnswer(actions, card, choice); ++ if (pending === void 0) { ++ setBusy(""); ++ return; ++ } ++ void Promise.resolve(pending).then(() => { ++ setAnswered((value) => ({ ...value, [card.id]: true })); ++ const name = card.bot?.name ?? ""; ++ setFeedback(card.kind === "approval" ? choice === "allow" ? t("Allowed once for {name}", { name }) : choice === "always-bot" ? t("Always allowed for {name}", { name }) : choice === "always-room" ? t("Always allowed in this group") : t("Rejected for {name}", { name }) : t("Answered")); ++ }, (failure) => setErrors((value) => ({ ...value, [card.id]: hostText(failure?.message ?? String(failure)) }))).finally(() => setBusy("")); ++ }; ++ const open = (card) => { ++ actions.closeInbox(); ++ actions.openSession(card.chat); ++ }; ++ return (0, import_react13.createElement)( + "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) ++ { className: "bt-mem-layer bt-inbox-layer", "data-leaving": leaving || void 0 }, ++ (0, import_react13.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), ++ (0, import_react13.createElement)( ++ "div", + { className: "bt-mem bt-inbox", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-inbox-heading" }, + (0, import_react13.createElement)( + "div", @@ -9557,77 +9509,14 @@ diff --git a/client.js b/client.js + ) ); } --function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { -- const roster = useRoster((value) => value); -- const data = node.data; -- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; -- const selfId = roster.byId[sessionId]?.id ?? sessionId; -- const deferred = source.kind === "bot" && source.deferred === true; -- const relayed = isRelayContext(data); -- const turn = node.location?.turn?.turn ?? data.turn; -- (0, import_react6.useLayoutEffect)(() => { -- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); -- }, [relayed, sessionId, turn, source.senderSessionId]); -- const order = useSessions((list) => { -- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; -- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; -- }); -- const peers = useTurnClock((state) => { -- if (!relayed) return ""; -- const turns = state[sessionId] ?? {}; -- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const hiddenTurns = roster.exchangeTurns[sessionId]; -- const unreadOpening = useTurnClock((state) => { -- const first = openingTurn(state[sessionId], order, hiddenTurns); -- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; -- }); -- const body = triggerBody(); -- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; -- function triggerBody() { -- if (source.kind === "bot") { -- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); -- if (source.role === "kickoff" || source.role === "room") return null; -- if (source.role === "secret" || deferred) return null; -- if (source.role === "post") { -- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; -- const room2 = roster.roomsById[sessionId]; -- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); -- } -- if (source.role === "report") { -- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); -- } -- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); -- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); -- const run = relayed ? commRunOf( -- order.split(",").filter(Boolean).map(Number), -- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), -- turn, -- hiddenTurns -- ) : null; -- if (run === "folded") return null; -- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; -- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); -- const bots = [sender]; -- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { -- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); -- } -- return (0, import_react6.createElement)(EventLine, { -- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), -- bots, -- onOpen: (bot) => actions.openExchange(selfId, bot.id), -- whole: true -- }); -- } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -- } -- if (source.kind === "user-question-reply") return null; -- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +-function PartLine({ time }) { +- return (0, import_react6.createElement)("div", { className: "bt-part-line", role: "separator" }, Number.isFinite(time) ? (0, import_react6.createElement)("span", null, dayLabel(time)) : null); +-} +-var scrollParent = (node) => { +- for (let at = node.parentElement; at; at = at.parentElement) { +- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; - } +- return null; +var INBOX_CSS = ` +.bt-inbox-row{min-height:40px;padding:6px 8px;gap:10px} +.bt-inbox-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} @@ -9690,7 +9579,27 @@ diff --git a/client.js b/client.js + accepted: "done", + cancelled: "done", + failed: "done" -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +- }); +function columnOf(task) { + return COLUMN_OF[task?.status] ?? "not-started"; +} @@ -9726,9 +9635,7 @@ diff --git a/client.js b/client.js + const value = task?.progress; + if (typeof value !== "number" || !Number.isFinite(value)) return columnOf(task) === "done" && task?.status !== "cancelled" && task?.status !== "failed" ? 100 : 0; + return Math.max(0, Math.min(100, Math.round(value))); - } --function NewDivider() { -- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); ++} + +// src/shared/board-card.js +var CARD_SOURCES = ["github", "task"]; @@ -9761,25 +9668,43 @@ diff --git a/client.js b/client.js + linked: row.linked ?? null, + sha: row.sha ?? null, + url: row.url ?? null -+ }; - } --function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { -- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + }; - (0, import_react6.useEffect)(() => { -- if (!open) turnProcess.setOpen(true); -- }, [open, turnProcess]); -- const turn = node?.location?.turn?.turn ?? node?.data?.turn; -- const start = node?.location?.turn?.start?.time; -- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; +- }; +- }, [sessionId]); +- (0, import_react6.useLayoutEffect)(() => { +- const pending = restore.current; +- restore.current = null; +- if (pending) pending.scroller.scrollTop = pending.scroller.scrollHeight - pending.fromEnd; +- }, [view.items]); - (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "first", start); -- }, [sessionId, turn, start]); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- const own = state[sessionId]?.[turn]; -- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; -- }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +- const node = top.current; +- if (!node || view.loading || view.failed || !view.cursor || typeof IntersectionObserver !== "function") return void 0; +- const observer = new IntersectionObserver((entries2) => { +- if (entries2.some((entry) => entry.isIntersecting)) load(view.cursor); +- }, { root: scrollParent(node), rootMargin: "600px 0px 0px 0px" }); +- observer.observe(node); +- return () => observer.disconnect(); +- }, [view.cursor, view.loading, view.failed]); +- const selfId = roster.byId[sessionId]?.id; +- const rows = []; +- for (const item of view.items) { +- const last = rows.at(-1); +- if (item.kind === "bot" && last?.kind === "bot") last.texts.push(item.text); +- else rows.push(item.kind === "bot" ? { kind: "bot", texts: [item.text] } : item); +- } +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, +- (0, import_react6.createElement)( +- "div", +- { ref: top, className: "bt-earlier-top" }, +- view.loading ? (0, import_react6.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react6.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null ++} +function taskCard(task) { + const key = taskCardKey(task?.id); + if (key === null) return null; @@ -9799,22 +9724,10 @@ diff --git a/client.js b/client.js + sha: null, + url: null + }; - } --function Nothing() { -- return null; ++} +function sameCard(a, b) { + return Boolean(a?.key) && a.key === b?.key; - } --var SEPARATOR_GAP_MS = 30 * 60 * 1e3; --function clockLabel(time, now = Date.now()) { -- const date = new Date(time); -- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); -- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); -- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); -- if (days === 0) return t("Today {time}", { time: clock4 }); -- if (days === 1) return t("Yesterday {time}", { time: clock4 }); -- const sameYear = date.getFullYear() === new Date(now).getFullYear(); -- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; ++} + +// src/client/board-card.js +var import_react14 = require("react"); @@ -9882,7 +9795,36 @@ diff --git a/client.js b/client.js + (0, import_react14.createElement)("span", { className: skin.id }, idText), + topExtra, + badge ? (0, import_react14.createElement)("span", { className: skin.badge, "data-badge": card?.badge ?? void 0 }, badge) : null -+ ), + ), +- rows.map((row, index) => { +- const key = rows.length - index; +- switch (row.kind) { +- case "divider": +- return (0, import_react6.createElement)(PartLine, { key, time: row.time }); +- case "user": +- return (0, import_react6.createElement)("div", { key, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- row.text ? (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: row.text, labels: mdLabels(), variant: "compact" }) : null, +- row.images ? (0, import_react6.createElement)("span", { className: "bt-uimages" }, row.images === 1 ? t("1 image") : t("{count} images", { count: row.images })) : null +- )); +- case "answer": +- return (0, import_react6.createElement)(AnsweredCell, { key, node: { data: row } }); +- case "bot": +- return (0, import_react6.createElement)(Bubbles, { key, texts: row.texts, roster, selfId, actions }); +- case "event": { +- if (row.role === "report") return (0, import_react6.createElement)(GroupEvent, { key, prefix: t("Replies from"), room: roster.roomsById[row.roomId], name: t("a group chat"), roster, actions }); +- const other = roster.byId[row.botId] ?? { id: row.botId, name: row.name ?? t("a Bot"), color: "gray" }; +- const prefix = { stopped: t("Stopped by"), reply: t("Reply from") }[row.role] ?? t("Message from"); +- return (0, import_react6.createElement)(EventLine, { key, prefix, bots: [other], onOpen: () => { +- if (selfId && other.id) actions.openExchange(selfId, other.id); +- }, whole: true }); +- } +- default: +- return null; +- } +- }), +- (0, import_react6.createElement)(PartLine, { time: view.startedAt }) + (0, import_react14.createElement)("span", { className: skin.title }, card?.title || emptyTitle), + labels.length ? (0, import_react14.createElement)("span", { className: skin.labels }, labels.map((name) => (0, import_react14.createElement)("span", { key: name, className: skin.label, "data-label": name }, name))) : null, + card?.owner ? (0, import_react14.createElement)("span", { className: skin.owner, "data-kind": card.ownerKind ?? "human" }, card.owner) : null, @@ -9893,13 +9835,33 @@ diff --git a/client.js b/client.js + card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, + footer, + actions -+ ); + ); } --function nextTurnStart(turns, turn) { -- let next; -- for (const key in turns) { -- const candidate = Number(key); -- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; +-function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { +- const roster = useRoster((value) => value); +- const data = node.data; +- const source = data.source !== null && typeof data.source === "object" ? data.source : {}; +- const selfId = roster.byId[sessionId]?.id ?? sessionId; +- const deferred = source.kind === "bot" && source.deferred === true; +- const relayed = isRelayContext(data); +- const turn = node.location?.turn?.turn ?? data.turn; +- (0, import_react6.useLayoutEffect)(() => { +- if (relayed) actions.noteTurnPeer(sessionId, turn, source.senderSessionId); +- }, [relayed, sessionId, turn, source.senderSessionId]); +- const order = useSessions((list) => { +- const outline = list.projectionsBySession?.[sessionId]?.values?.turnOutline ?? list.byId[sessionId]?.projectionValues?.turnOutline; +- return Array.isArray(outline) ? outline.map((entry) => entry.turn).join(",") : ""; +- }); +- const peers = useTurnClock((state) => { +- if (!relayed) return ""; +- const turns = state[sessionId] ?? {}; +- return Object.keys(turns).filter((key) => turns[key].peer !== void 0).map((key) => `${key}:${turns[key].peer}`).join(","); +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const hiddenTurns = roster.exchangeTurns[sessionId]; +- const unreadOpening = useTurnClock((state) => { +- const first = openingTurn(state[sessionId], order, hiddenTurns); +- return first?.turn === turn && unseenSince(visit, first.start) ? clockLabel(first.start) : ""; + +// src/client/delivery-chart.js +var import_react15 = require("react"); @@ -9929,28 +9891,7 @@ diff --git a/client.js b/client.js + const denominator = Number.isFinite(counted) && counted > 0 ? counted : Number.isFinite(total) && total > 0 ? total : null; + if (!Number.isFinite(failed) || denominator === null) return null; + return Math.min(1, Math.max(0, failed / denominator)); - } -- return next === void 0 ? void 0 : turns[next].first; --} --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; --} --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; ++ } + const value = day[key]; + return Number.isFinite(value) ? value : null; +} @@ -9996,11 +9937,21 @@ diff --git a/client.js b/client.js + value + }; }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; +- const body = triggerBody(); +- return unreadOpening ? (0, import_react6.createElement)(import_react6.Fragment, null, (0, import_react6.createElement)("div", { className: "bt-open-new" }, (0, import_react6.createElement)(NewDivider), (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; +- function triggerBody() { +- if (source.kind === "bot") { +- if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react6.createElement)(EarlierParts, { sessionId, roster, actions }); +- if (source.role === "kickoff" || source.role === "room") return null; +- if (source.role === "secret" || deferred) return null; +- if (source.role === "post") { +- if (source.failed !== void 0 || source.resumed === true || source.stopped === true) return null; +- const room2 = roster.roomsById[sessionId]; +- const poster = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- return (0, import_react6.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); +- } +- if (source.role === "report") { +- return (0, import_react6.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + const segments = []; + let run = null; + for (const dot of dots) { @@ -10008,12 +9959,82 @@ diff --git a/client.js b/client.js + if (run) { + segments.push(run); + run = null; -+ } + } +- if (source.role === "resume" || source.resumed === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Picked up its work after a restart")); +- if (source.role === "retry" || source.retried === true) return (0, import_react6.createElement)("div", { className: "bt-event" }, t("Asked to make the tool call it wrote as text")); +- const run = relayed ? commRunOf( +- order.split(",").filter(Boolean).map(Number), +- Object.fromEntries(peers.split(",").filter(Boolean).map((pair) => [Number(pair.slice(0, pair.indexOf(":"))), pair.slice(pair.indexOf(":") + 1)])), +- turn, +- hiddenTurns +- ) : null; +- if (run === "folded") return null; +- const sender = roster.byId[source.senderSessionId] ?? { id: source.senderSessionId, name: source.senderName ?? t("a Bot"), color: "gray" }; +- const count = run?.count ?? (isHiddenTurn(roster, sessionId, turn) ? 2 : 1); +- const bots = [sender]; +- for (const peer of (run?.peerIds ?? []).map((id) => roster.byId[id]).filter(Boolean)) { +- if (!bots.some((bot) => bot.id === peer.id)) bots.push(peer); +- } +- return (0, import_react6.createElement)(EventLine, { +- prefix: source.role === "stopped" ? t("Stopped by") : count > 1 ? t("{count} messages with", { count }) : t("Message from"), +- bots, +- onOpen: (bot) => actions.openExchange(selfId, bot.id), +- whole: true +- }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); + continue; -+ } + } +- if (source.kind === "user-question-reply") return null; +- return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); +- } +-} +-function NewDivider() { +- return (0, import_react6.createElement)("div", { className: "bt-new-sep", role: "separator", "aria-label": t("New messages") }, (0, import_react6.createElement)("span", null, t("New"))); +-} +-function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { +- const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; +- (0, import_react6.useEffect)(() => { +- if (!open) turnProcess.setOpen(true); +- }, [open, turnProcess]); +- const turn = node?.location?.turn?.turn ?? node?.data?.turn; +- const start = node?.location?.turn?.start?.time; +- const hidden = useRoster((roster) => isHiddenTurn(roster, sessionId, turn)); +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "first", start); +- }, [sessionId, turn, start]); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- const own = state[sessionId]?.[turn]; +- return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; +- }); +- return fresh ? (0, import_react6.createElement)(NewDivider) : null; +-} +-function Nothing() { +- return null; +-} +-var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +-function clockLabel(time, now = Date.now()) { +- const date = new Date(time); +- const midnight = (value) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); +- const days = Math.round((midnight(new Date(now)) - midnight(date)) / 864e5); +- const clock4 = date.toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }); +- if (days === 0) return t("Today {time}", { time: clock4 }); +- if (days === 1) return t("Yesterday {time}", { time: clock4 }); +- const sameYear = date.getFullYear() === new Date(now).getFullYear(); +- return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; +-} +-function nextTurnStart(turns, turn) { +- let next; +- for (const key in turns) { +- const candidate = Number(key); +- if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; + if (!run) run = { dots: [] }; + run.dots.push(dot); -+ } + } +- return next === void 0 ? void 0 : turns[next].first; + if (run) segments.push(run); + return { + width, @@ -10027,7 +10048,15 @@ diff --git a/client.js b/client.js + today: segment.dots.some((dot) => dot.today) + })) + }; -+} + } +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10061,7 +10090,23 @@ diff --git a/client.js b/client.js + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) + ); -+} + } +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; +- }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); @@ -10549,8 +10594,10 @@ diff --git a/client.js b/client.js + ) + ); +} -+function DeliveryColumns({ view, repo = null }) { ++function DeliveryColumns({ view, repo = null, placed = null }) { + const columns = view?.columns ?? {}; ++ const drawn = placed === null || placed === void 0 ? null : new Set(placed); ++ const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)); + return (0, import_react16.createElement)( "div", - { className: "bt-group-replies" }, @@ -10615,12 +10662,48 @@ diff --git a/client.js b/client.js + "h3", + { className: "bt-board-column-head" }, + (0, import_react16.createElement)("span", null, t(column.label)), -+ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String((columns[column.id] ?? []).length)) ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(cards(column.id).length)) + ), -+ (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) ++ cards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : cards(column.id).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) + )) + ); +} ++function DeliveryWorkflowSections({ sections, repo = null }) { ++ const list = Array.isArray(sections) ? sections : []; ++ if (list.length === 0) return null; ++ return (0, import_react16.createElement)("div", { className: "bt-del-workflows" }, list.map((section) => (0, import_react16.createElement)( ++ "section", ++ { ++ key: section.id, ++ className: "bt-del-workflow", ++ "data-workflow": section.id ++ }, ++ (0, import_react16.createElement)( ++ "h3", ++ { className: "bt-del-workflow-name" }, ++ (0, import_react16.createElement)("span", null, section.name), ++ (0, import_react16.createElement)("span", { className: "bt-wf-path" }, `workflows/${section.id}.json`) ++ ), ++ (0, import_react16.createElement)("div", { className: "bt-del-columns" }, section.columns.map((column) => (0, import_react16.createElement)( ++ "section", ++ { ++ key: column.id, ++ className: "bt-del-column", ++ "data-column": column.id, ++ "data-workflow": section.id ++ }, ++ (0, import_react16.createElement)( ++ "h4", ++ { className: "bt-board-column-head" }, ++ (0, import_react16.createElement)("span", null, column.label), ++ (0, import_react16.createElement)("span", { className: "bt-del-workflow-tag" }, section.name), ++ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(column.cards.length)) ++ ), ++ column.cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : column.cards.map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ""}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null })) ++ ))), ++ (section.unplaced ?? []).length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("{count} card(s) name a node this workflow draws no column for.", { count: section.unplaced.length })) : null ++ ))); ++} +function DeliveryRepoBlock({ entry }) { + const state = entry?.state ?? "fresh"; + const source = repoSourceText(entry?.repoSource); @@ -10638,7 +10721,7 @@ diff --git a/client.js b/client.js + { className: "bt-del-block-state", role: "status" }, + state === "loading" ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t("The delivery board is not ready yet.")), + entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : "" -+ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo }) ++ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed }) + ); +} +function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { @@ -10709,7 +10792,10 @@ diff --git a/client.js b/client.js + ) : null, + state !== "stale" && missing.length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-partial", role: "status" }, missing.map((entry) => missingText(entry)).join(" · ")) : null, + (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), -+ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) : (0, import_react16.createElement)( ++ // A workflow's own columns come first: a card that names a workflow is drawn there and not in ++ // the five columns below, which keep every card that names none (#1272 step 3). ++ entries2 === null ? (0, import_react16.createElement)(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) : null, ++ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : (0, import_react16.createElement)( + "div", + { className: "bt-del-repos" }, + entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) @@ -10738,76 +10824,6 @@ diff --git a/client.js b/client.js + onRetry: actions?.deliveryBoard ? retry : null, + onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null + }); -+} -+function DeliveryCard({ card, repo = null }) { -+ const model = githubCard(card, repo) ?? { source: "github", key: null, title: card?.title ?? "", badge: card?.badge ?? null }; -+ return (0, import_react16.createElement)(BoardCard, { -+ card: model, -+ skin: DELIVERY_SKIN, -+ attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0 }, -+ idText: `#${card?.number ?? "?"}`, -+ badge: badgeLabel(card?.badge) -+ }); -+} -+var DELIVERY_CSS = ` -+.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} -+.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} -+.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px} -+.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums} -+.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap} -+.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)} -+.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)} -+.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3);display:flex;flex-wrap:wrap;align-items:center;gap:6px} -+.bt-del-loading{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} -+.bt-del-hint{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-del-stale,.bt-del-partial{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-stale[role=status],.bt-del-partial[role=status]{padding:6px 8px;border-radius:10px;background:var(--bt-hover)} -+.bt-del-diag{display:inline-flex;position:relative} -+.bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} -+.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} -+.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} -+.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} -+.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} -+.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} -+.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} -+.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} -+.bt-del-card-top{display:flex;align-items:center;gap:6px} -+.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} -+.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} -+.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} -+.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} -+.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} -+.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} -+.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} -+.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} -+.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} -+.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} -+@media (max-width:800px){ -+ .bt-del-columns{overflow:auto} -+ .bt-del-column{min-width:220px} -+} -+@media (max-width:640px){ -+ .bt-del-columns{flex-direction:column} -+ .bt-del-column{min-width:0} -+} -+` + DELIVERY_CHART_CSS; -+ -+// src/client/kanban.js -+var import_react17 = require("react"); -+var POLL_MS4 = 15e3; -+function BoardIcon() { -+ return (0, import_react17.createElement)( -+ "svg", -+ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, -+ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), -+ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) -+ ); } -function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); @@ -10817,9 +10833,15 @@ diff --git a/client.js b/client.js - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); -+function shortId(id) { -+ const hex = String(id ?? "").replace(/^task-/, ""); -+ return `#${hex.slice(-6)}`; ++function DeliveryCard({ card, repo = null }) { ++ const model = githubCard(card, repo) ?? { source: "github", key: null, title: card?.title ?? "", badge: card?.badge ?? null }; ++ return (0, import_react16.createElement)(BoardCard, { ++ card: model, ++ skin: DELIVERY_SKIN, ++ attrs: { "data-kind": card?.kind, "data-number": card?.number, "data-badge": card?.badge ?? void 0 }, ++ idText: `#${card?.number ?? "?"}`, ++ badge: badgeLabel(card?.badge) ++ }); } -function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { - const tool = toolName(call2); @@ -10890,6 +10912,70 @@ diff --git a/client.js b/client.js - if (!done) return null; - const room2 = roster.rooms.find((entry) => entry.name === arg("group")); - return room2 ? (0, import_react6.createElement)(GroupEvent, { prefix: t("Read"), room: room2, roster, actions }) : null; ++var DELIVERY_CSS = ` ++.bt-del-body{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:10px;padding:12px 14px 16px} ++.bt-del-head{display:flex;flex-direction:column;gap:6px;border-bottom:.5px solid var(--bt-line);padding-bottom:8px} ++.bt-del-metrics{display:flex;flex-wrap:wrap;align-items:flex-start;gap:16px} ++.bt-del-table{border-collapse:collapse;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums} ++.bt-del-table th,.bt-del-table td{padding:2px 8px;text-align:right;white-space:nowrap} ++.bt-del-table th[scope=row]{text-align:left;font-weight:500;color:var(--bt-ink-2)} ++.bt-del-table thead th{font-weight:600;color:var(--bt-ink-3)} ++.bt-del-ci,.bt-del-open{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-asof{margin:0;font-size:11.5px;line-height:15px;color:var(--bt-ink-3);display:flex;flex-wrap:wrap;align-items:center;gap:6px} ++.bt-del-loading{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-2);font-variant-numeric:tabular-nums} ++.bt-del-hint{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-del-stale,.bt-del-partial{margin:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-stale[role=status],.bt-del-partial[role=status]{padding:6px 8px;border-radius:10px;background:var(--bt-hover)} ++.bt-del-diag{display:inline-flex;position:relative} ++.bt-del-diag-box{position:absolute;top:100%;left:0;z-index:3;display:flex;flex-direction:column;gap:6px;padding:8px;border:.5px solid var(--bt-line-2);border-radius:10px;background:var(--bt-card,var(--bt-main));min-width:320px} ++.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} ++.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} ++.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} ++.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} ++.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} ++.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} ++.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} ++.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} ++.bt-del-card-top{display:flex;align-items:center;gap:6px} ++.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} ++.bt-del-badge{margin-left:auto;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-badge[data-badge=inc]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=ci-red]{background:rgba(224,33,53,.12);color:#c21d2e} ++.bt-del-badge[data-badge=checks-unknown]{background:var(--bt-hover);color:var(--bt-ink-3)} ++.bt-del-badge[data-badge=pass-enqueue],.bt-del-badge[data-badge=pass-merge]{background:color-mix(in srgb,var(--bt-green) 16%,transparent);color:color-mix(in srgb,var(--bt-green) 70%,var(--bt-ink))} ++.bt-del-title{font-size:13.5px;line-height:19px;color:var(--bt-ink)} ++.bt-del-labels{display:flex;flex-wrap:wrap;gap:4px} ++.bt-del-label{font-size:10.5px;line-height:16px;padding:0 6px;border-radius:999px;background:var(--bt-hover);color:var(--bt-ink-2)} ++.bt-del-owner{font-size:12px;line-height:16px;color:var(--bt-ink-2)} ++.bt-del-owner[data-kind=human]{font-weight:600;color:var(--bt-ink)} ++.bt-del-linked,.bt-del-sha{font-size:11.5px;line-height:15px;color:var(--bt-ink-3)} ++.bt-del-open{align-self:flex-start;font-size:12px;line-height:16px;color:var(--bt-accent)} ++@media (max-width:800px){ ++ .bt-del-columns{overflow:auto} ++ .bt-del-column{min-width:220px} ++} ++@media (max-width:640px){ ++ .bt-del-columns{flex-direction:column} ++ .bt-del-column{min-width:0} ++} ++` + DELIVERY_CHART_CSS; ++ ++// src/client/kanban.js ++var import_react17 = require("react"); ++var POLL_MS4 = 15e3; ++function BoardIcon() { ++ return (0, import_react17.createElement)( ++ "svg", ++ { width: 18, height: 18, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinejoin: "round", "aria-hidden": true }, ++ (0, import_react17.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), ++ (0, import_react17.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ++ ); ++} ++function shortId(id) { ++ const hex = String(id ?? "").replace(/^task-/, ""); ++ return `#${hex.slice(-6)}`; ++} +var ARCHIVE_SHOWN = 20; +function boardTasksFor(tasks, archive, limit = ARCHIVE_SHOWN) { + const newest = [...archive ?? []].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || (b.createdAt ?? 0) - (a.createdAt ?? 0)); @@ -11244,10 +11330,6 @@ diff --git a/client.js b/client.js - if (!host || phase === null || !shown) return null; - const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; - return (0, import_react_dom2.createPortal)((0, import_react7.createElement)( -- "div", -- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, -- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -- (0, import_react7.createElement)( +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); @@ -11277,16 +11359,11 @@ diff --git a/client.js b/client.js + question ? (0, import_react17.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null + ], + footer: (0, import_react17.createElement)( - "span", -- { className: "bt-typing-text" }, -- (0, import_react7.createElement)( ++ "span", + { className: "bt-board-foot" }, + who ? (0, import_react17.createElement)("span", { className: "bt-board-who" }, who) : null, + (0, import_react17.createElement)( - "span", -- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), -- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null ++ "span", + { className: "bt-board-progress" }, + (0, import_react17.createElement)( + "span", @@ -11363,7 +11440,17 @@ diff --git a/client.js b/client.js + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; + return (0, import_react17.createElement)( -+ "div", + "div", +- { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "aria-hidden": true }, +- bot ? (0, import_react7.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react7.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), +- (0, import_react7.createElement)( +- "span", +- { className: "bt-typing-text" }, +- (0, import_react7.createElement)( +- "span", +- { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, +- (0, import_react7.createElement)(Shimmer, null, `${shown.text}…`), +- elapsed ? (0, import_react7.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + { className: "bt-board-body" }, + (0, import_react17.createElement)( + "aside", @@ -13054,37 +13141,10 @@ diff --git a/client.js b/client.js // src/client/speech.js var VOICE_BUNDLE = "@deepseek-ai/dsh-experimental-voice-input-bundle"; var USABLE = /* @__PURE__ */ new Set(["ready", "standby", "waking"]); -@@ -6067,286 +6067,62 @@ async function openCapture() { - 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)); +@@ -6094,259 +6094,35 @@ async function openCapture() { + source.connect(offline.destination); + source.start(); + return waveBytes((await offline.startRendering()).getChannelData(0)); - } finally { - close(); - } @@ -13334,33 +13394,6 @@ diff --git a/client.js b/client.js - ) - ) - ); -+ 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(); + } @@ -15000,7 +15033,7 @@ diff --git a/client.js b/client.js alive.current = true; void load({ summarize: true }); return () => { -@@ -7606,38 +7606,331 @@ function useMemoryView(botId, actions) { +@@ -7606,9 +7606,9 @@ function useMemoryView(botId, actions) { }, [botId]); return { view, error, load, show }; } @@ -15013,13 +15046,15 @@ diff --git a/client.js b/client.js const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); - return now; +@@ -7616,18 +7616,18 @@ function useNow(every) { } --function MemoryDialog({ bot, actions, leaving = false }) { -- const { view, error, load, show } = useMemoryView(bot.id, actions); + function MemoryDialog({ bot, actions, leaving = false }) { + const { view, error, load, show } = useMemoryView(bot.id, actions); - const [page, setPage] = (0, import_react17.useState)("summary"); - const [menu, setMenu] = (0, import_react17.useState)(false); -- const [menuShown, menuLeaving] = useLinger(menu, MENU_EXIT_MS2); ++ 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 [text, setText] = (0, import_react17.useState)(""); - const [answer, setAnswer] = (0, import_react17.useState)(null); - const menuRef = (0, import_react17.useRef)(null); @@ -15027,14 +15062,6 @@ diff --git a/client.js b/client.js - const inputRef = (0, import_react17.useRef)(null); - const now = useNow(3e4); - const close = (0, import_react17.useCallback)(() => actions.closeMemory(), []); -- useEscape(close); -- (0, import_react17.useEffect)(() => { -- if (!menu) return void 0; -+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 [text, setText] = (0, import_react27.useState)(""); + const [answer, setAnswer] = (0, import_react27.useState)(null); + const menuRef = (0, import_react27.useRef)(null); @@ -15042,79 +15069,37 @@ diff --git a/client.js b/client.js + const inputRef = (0, import_react27.useRef)(null); + const now = useNow3(3e4); + const close = (0, import_react27.useCallback)(() => actions.closeMemory(), []); -+ useEscape(close); + useEscape(close); +- (0, import_react17.useEffect)(() => { + (0, import_react27.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 openEntries = () => { -+ setMenu(false); -+ setPage("entries"); -+ }; -+ const backToSummary = () => { -+ setPage("summary"); -+ void load({ summarize: true }); -+ }; -+ 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 ask = async (event) => { -+ event.preventDefault(); -+ const asked = text.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 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; + if (!menu) return void 0; + const onDown = (event) => { + if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; +@@ -7689,52 +7689,52 @@ function MemoryDialog({ bot, actions, leaving = false }) { + 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_react17.createElement)( + return (0, import_react27.createElement)( -+ "div", -+ { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, + "div", + { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, +- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), +- (0, import_react17.createElement)( + (0, import_react27.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react27.createElement)( -+ "div", -+ { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, + "div", + { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, +- (0, import_react17.createElement)( + (0, import_react27.createElement)( -+ "div", -+ { className: "bt-mem-head" }, + "div", + { className: "bt-mem-head" }, +- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, +- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), +- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), +- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), +- (0, import_react17.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_react17.createElement)(DotsIcon)), +- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), +- menuShown ? (0, import_react17.createElement)( + 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), @@ -15122,55 +15107,67 @@ diff --git a/client.js b/client.js + (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)( -+ "div", -+ { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, + "div", + { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, +- (0, import_react17.createElement)( + (0, import_react27.createElement)( -+ "button", -+ { type: "button", role: "menuitem", onClick: openEntries }, + "button", + { type: "button", role: "menuitem", onClick: openEntries }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), +- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) + (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_react17.createElement)( + (0, import_react27.createElement)( -+ "button", -+ { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, + "button", + { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, +- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) + (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) -+ ) -+ ) : null -+ ), + ) + ) : null + ), +- (0, import_react17.createElement)( + (0, import_react27.createElement)( -+ "div", -+ { className: "bt-mem-body" }, + "div", + { className: "bt-mem-body" }, +- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, +- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) + 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_react17.createElement)( + (0, import_react27.createElement)( -+ "div", -+ { className: "bt-mem-foot" }, + "div", + { className: "bt-mem-foot" }, +- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, +- (0, import_react17.createElement)( + answer ? (0, import_react27.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, + (0, import_react27.createElement)( -+ "form", -+ { className: "bt-mem-ask", onSubmit: ask }, + "form", + { className: "bt-mem-ask", onSubmit: ask }, +- (0, import_react17.createElement)("input", { + (0, import_react27.createElement)("input", { -+ ref: inputRef, -+ className: "bt-mem-input", -+ value: text, -+ 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) -+ }), + ref: inputRef, + className: "bt-mem-input", + value: text, +@@ -7744,10 +7744,10 @@ function MemoryDialog({ bot, actions, leaving = false }) { + "aria-label": t("Ask about or update {name}'s memory", { name: bot.name }), + onChange: (event) => setText(event.target.value) + }), +- (0, import_react17.createElement)( + (0, import_react27.createElement)( -+ "button", -+ { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, + "button", + { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, +- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) + busy ? (0, import_react27.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react27.createElement)(ArrowUpIcon) -+ ) -+ ) -+ ) -+ ) -+ ); -+} -+ + ) + ) + ) +@@ -7755,8 +7755,40 @@ function MemoryDialog({ bot, actions, leaving = false }) { + ); + } + +// src/client/standing-approvals.js +var import_react28 = require("react"); +var standingOf = (target, roster) => { @@ -15203,422 +15200,334 @@ diff --git a/client.js b/client.js + ); +} + -+// src/client/bot-settings.js + // src/client/bot-settings.js +-var import_react18 = require("react"); +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 }) { + 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")); +@@ -7780,8 +7780,8 @@ async function avatarImage(file) { + 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_react18.useState)(null); +- const reverting = (0, import_react18.useRef)(false); + 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); -+ }; + const commit = () => { + const next = draft?.trim(); + setDraft(null); +@@ -7792,7 +7792,7 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu + if (next === void 0 || next === (value ?? "") || required && next === "") return; + void onSave(next); + }; +- return (0, import_react18.createElement)(multiline ? "textarea" : "input", { + 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 }) { + className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", + value: draft ?? value ?? "", + placeholder, +@@ -7813,29 +7813,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu + }); + } + function Row({ label, hint, stack = false, children }) { +- return (0, import_react18.createElement)( + return (0, import_react29.createElement)( -+ "label", -+ { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, + "label", + { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (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 }) { + children + ); + } + function Card({ title, children }) { +- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, 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 }) { + } + function Switch({ on, label, disabled, onChange }) { +- return (0, import_react18.createElement)( + return (0, import_react29.createElement)( -+ "button", -+ { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, + "button", + { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, +- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) + (0, import_react29.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) -+ ); -+} + ); + } +-function SwitchRow({ label, hint, on, disabled, onChange }) { +- return (0, import_react18.createElement)( +function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) { + return (0, import_react29.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), +- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) + (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 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 }) { + ); + } + function modelHint(bot, roster) { +@@ -7846,22 +7846,22 @@ function modelHint(bot, roster) { + return t("Applies from the next message."); + } + function ShapeRing({ shape, size }) { +- const maskId = (0, import_react18.useRef)(""); + 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)}`; + 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_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); + return (0, import_react29.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)); + } + 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_react18.createElement)( +- import_react18.Fragment, + return (0, import_react29.createElement)( + import_react29.Fragment, -+ null, + null, +- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.createElement)("button", { + (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) + key: id, + type: "button", + className: "bt-shape", +@@ -7869,8 +7869,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { + title: t(shapeOf(id).label), + "aria-pressed": !hasPhoto && look.shape === id, + onClick: () => onShape(id) +- }, (0, import_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), +- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.createElement)("button", { + }, (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) + key: color, + type: "button", + className: "bt-color", +@@ -7878,19 +7878,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { + title: t(capitalize(color)), + "aria-pressed": look.color === color, + onClick: () => onColor(color) +- }, (0, import_react18.createElement)("span", { style: { background: paintCss(color) } })))) + }, (0, import_react29.createElement)("span", { style: { background: paintCss(color) } })))) -+ ); -+} -+function AvatarEditor({ bot, actions, run, onClose }) { -+ const look = lookOf(bot); + ); + } + function AvatarEditor({ bot, actions, run, onClose }) { + const look = lookOf(bot); +- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); +- const [over, setOver] = (0, import_react18.useState)(false); +- const ref = (0, import_react18.useRef)(null); +- const fileRef = (0, import_react18.useRef)(null); + 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 } }))); -+ }; + const upload = (file) => { + if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); + }; +- (0, import_react18.useEffect)(() => { + (0, import_react29.useEffect)(() => { const onDown = (event) => { -- if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; -- setMenu(false); -+ 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(); -- setMenu(false); -- dotsRef.current?.focus(); -+ onClose(); - } - }; - window.addEventListener("mousedown", onDown); -@@ -7646,514 +7646,900 @@ function MemoryDialog({ bot, actions, leaving = false }) { - window.removeEventListener("mousedown", onDown); + if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; + onClose(); +@@ -7908,7 +7908,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; -- }, [menu]); -- const regenerate = () => { -- setMenu(false); -- setPage("summary"); -- void load({ regenerate: true }); -- }; -- const openEntries = () => { -- setMenu(false); -- setPage("entries"); -- }; -- const backToSummary = () => { -- setPage("summary"); -- void load({ summarize: true }); -- }; -- 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 ask = async (event) => { -- event.preventDefault(); -- const asked = text.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(); -+ }, [onClose]); + }, [onClose]); +- (0, import_react18.useEffect)(() => { + (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); + if (tab !== "upload") return void 0; + const onPaste = (event) => { + const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); +@@ -7922,32 +7922,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { + }, [tab, bot]); + const hasPhoto = Boolean(look.image); + const glide = useGlide(tab); +- return (0, import_react18.createElement)( + return (0, import_react29.createElement)( -+ "div", -+ { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, + "div", + { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( -+ "div", -+ { className: "bt-avedit-top" }, + "div", + { className: "bt-avedit-top" }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( -+ "div", -+ { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, + "div", + { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, +- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), +- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.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)("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_react18.createElement)("button", { + (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")) -+ ), + 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_react18.createElement)(CharacterGrid, { + 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, + look, + hasPhoto, + onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), + onColor: (color) => run(actions.updateBot(bot.id, { color })) + }) : null, +- tab === "upload" ? (0, import_react18.createElement)( + tab === "upload" ? (0, import_react29.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]); -+ } -+ }, + "div", + { + className: "bt-dropzone", +@@ -7963,15 +7963,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { + upload(event.dataTransfer.files?.[0]); + } + }, +- hasPhoto ? (0, import_react18.createElement)("img", { src: look.image, alt: "" }) : null, +- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), +- (0, import_react18.createElement)( + 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" } }, + "div", + { className: "bt-actions", style: { justifyContent: "center" } }, +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), +- hasPhoto ? (0, import_react18.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)("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_react18.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { + (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); -+ } }) -+ ) : 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 }); + const file = event.target.files?.[0]; + event.target.value = ""; + upload(file); +@@ -7982,26 +7982,27 @@ function AvatarEditor({ bot, actions, run, onClose }) { + 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_react18.createElement)( + return (0, import_react29.createElement)( -+ Card, -+ { title: t("Memory") }, + Card, + { title: t("Memory") }, +- (0, import_react18.createElement)( + (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) }, + "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_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), +- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) + (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 BotSettingsForm({ bot, roster, actions, run }) { + ) + ); + } + function BotSettingsForm({ bot, roster, actions, run }) { +- const [editing, setEditing] = (0, import_react18.useState)(false); +- const [confirming, setConfirming] = (0, import_react18.useState)(false); +- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); +- (0, import_react18.useEffect)(() => { + 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 (!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); + const isMainBot = isMainOf(roster, bot.id); + const onlyMain = isMainBot && (roster.mainBotIds ?? []).length === 1; + const isTile = roster.mainBotId === bot.id; +@@ -8011,149 +8011,795 @@ function BotSettingsForm({ bot, roster, actions, run }) { + if (confirming) void run(actions.deleteBot(bot.id)); + else setConfirming(true); }; -- 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_react17.createElement)( +- (0, import_react18.useEffect)(() => { + (0, import_react29.useEffect)(() => { -+ void actions.refreshModels?.(); -+ }, [bot.id]); + void actions.refreshModels?.(); + }, [bot.id]); +- return (0, import_react18.createElement)( + return (0, import_react29.createElement)( "div", -- { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, -- (0, import_react17.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -- (0, import_react17.createElement)( -+ { className: "bt-bs" }, + { className: "bt-bs" }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( "div", -- { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, -- (0, import_react17.createElement)( -+ { className: "bt-bs-hero" }, + { className: "bt-bs-hero" }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( "div", -- { className: "bt-mem-head" }, -- page === "entries" ? (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react17.createElement)(ChevronLeftIcon)) : null, -- (0, import_react17.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), -- (0, import_react17.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), -- (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), -- (0, import_react17.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_react17.createElement)(DotsIcon)), -- (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react17.createElement)(CloseIcon)), -- menuShown ? (0, import_react17.createElement)( -- "div", -- { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, -- (0, import_react17.createElement)( -- "button", -- { type: "button", role: "menuitem", onClick: openEntries }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("All entries")), -- (0, import_react17.createElement)("span", { className: "bt-menu-hint" }, String(count)) -- ), -- (0, import_react17.createElement)( -- "button", -- { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, -- (0, import_react17.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) -- ) -- ) : null -+ { className: "bt-avatar-anchor" }, + { className: "bt-avatar-anchor" }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( -+ "button", -+ { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, + "button", + { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, +- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), +- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) + (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_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null + editing ? (0, import_react29.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ), -- (0, import_react17.createElement)( +- (0, import_react18.createElement)( + (0, import_react29.createElement)( "div", -- { className: "bt-mem-body" }, -- error ? (0, import_react17.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, -- view === null ? error ? null : (0, import_react17.createElement)(Skeleton) : page === "entries" ? (0, import_react17.createElement)(Entries, { bot, view, remove }) : (0, import_react17.createElement)(Summary, { bot, view, regenerate }) -+ { className: "bt-bs-who" }, + { className: "bt-bs-who" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), +- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) + (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_react18.createElement)( + (0, import_react29.createElement)( -+ Card, -+ { title: t("Profile") }, + Card, + { title: t("Profile") }, +- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), +- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) + (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_react18.createElement)( + (0, import_react29.createElement)( -+ Card, -+ { title: t("Model") }, + Card, + { title: t("Model") }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, + "div", + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), +- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) +- ) + (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 }) - ), -- (0, import_react17.createElement)( ++ ), + bot.model ? (0, import_react29.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null -+ ), + ), +- (0, import_react18.createElement)( + (0, import_react29.createElement)( -+ Card, -+ { title: t("Working folder") }, + Card, + { title: t("Working folder") }, +- (0, import_react18.createElement)( + (0, import_react29.createElement)( "div", -- { className: "bt-mem-foot" }, -- answer ? (0, import_react17.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, -- (0, import_react17.createElement)( -- "form", -- { className: "bt-mem-ask", onSubmit: ask }, -- (0, import_react17.createElement)("input", { -- ref: inputRef, -- className: "bt-mem-input", -- value: text, -- 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_react17.createElement)( -- "button", -- { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, -- busy ? (0, import_react17.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react17.createElement)(ArrowUpIcon) -- ) -- ) -+ { className: "bt-set-row bt-bs-row" }, + { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), +- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) + (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_react18.createElement)( + (0, import_react29.createElement)( -+ Card, -+ { title: t("Instructions") }, + Card, + { title: t("Instructions") }, +- (0, import_react18.createElement)( + (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 }, + 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_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) + (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 }) }) + ) + ), @@ -15669,10 +15578,10 @@ diff --git a/client.js b/client.js + (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)) - ) - ) - ); - } ++ ) ++ ) ++ ); ++} +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} @@ -15707,26 +15616,7 @@ diff --git a/client.js b/client.js +.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}} +`; - --// src/client/bot-settings.js --var import_react18 = 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; ++ +// src/client/group-settings.js +var import_react30 = require("react"); +var ROOM_MODES = [ @@ -15761,42 +15651,7 @@ diff --git a/client.js b/client.js + const bot = faceSeatBot(seat, roster); + if (!bot) continue; + marks.push((0, import_react30.createElement)(BotMark, { key: seat.key, bot, size, live: false })); - } -- 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_react18.useState)(null); -- const reverting = (0, import_react18.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_react18.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(); -- } ++ } + if (marks.length === 0) return [(0, import_react30.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + return marks; +} @@ -15814,24 +15669,7 @@ diff --git a/client.js b/client.js + 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]; - }); --} --function Row({ label, hint, stack = false, children }) { -- return (0, import_react18.createElement)( -- "label", -- { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), -- children -- ); --} --function Card({ title, children }) { -- return (0, import_react18.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react18.createElement)("h3", null, title) : null, (0, import_react18.createElement)("div", { className: "bt-set-card" }, children)); --} --function Switch({ on, label, disabled, onChange }) { -- return (0, import_react18.createElement)( -- "button", -- { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, -- (0, import_react18.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) ++ }); + return (0, import_react30.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, @@ -15846,10 +15684,8 @@ diff --git a/client.js b/client.js + (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots) + ) + ) - ); - } --function SwitchRow({ label, hint, on, disabled, onChange }) { -- return (0, import_react18.createElement)( ++ ); ++} +function FaceLead({ room: room2, roster, pick: pick2 }) { + const lead = room2.face?.lead; + const [open, setOpen] = (0, import_react30.useState)(false); @@ -15872,10 +15708,7 @@ diff --git a/client.js b/client.js + } + }; + return (0, import_react30.createElement)( - "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-set-hint" }, hint) : null), -- (0, import_react18.createElement)(Switch, { on, label, disabled, onChange }) ++ "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)( @@ -15897,60 +15730,21 @@ diff --git a/client.js b/client.js + )) : 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 - ); - } --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."); ++ error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : 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); - } --function ShapeRing({ shape, size }) { -- const maskId = (0, import_react18.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_react18.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); ++} +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 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_react18.createElement)( -- import_react18.Fragment, -- null, -- (0, import_react18.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react18.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react18.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_react18.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react18.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), -- (0, import_react18.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react18.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_react18.createElement)("span", { style: { background: paintCss(color) } })))) ++} +function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { + const [adding, setAdding] = (0, import_react30.useState)(false); + const [confirming, setConfirming] = (0, import_react30.useState)(false); @@ -16047,10 +15841,16 @@ diff --git a/client.js b/client.js + { 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 }) }) -+ ) -+ ), + ) + ), +- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), +- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), +- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), +- (0, import_react18.createElement)( + face ? (0, import_react30.createElement)( -+ Card, + Card, +- { title: t("Main Bot") }, +- onlyMain ? (0, import_react18.createElement)( + { 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, @@ -16058,10 +15858,15 @@ diff --git a/client.js b/client.js + Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, + humans.map((person) => (0, import_react30.createElement)( -+ "div", + "div", +- { className: "bt-set-row bt-bs-row" }, +- (0, import_react18.createElement)( + { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, + (0, import_react30.createElement)( -+ "span", + "span", +- { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), +- (0, import_react18.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.")) + { 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")), @@ -16101,7 +15906,9 @@ diff --git a/client.js b/client.js + // #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")) -+ ), + ), +- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) +- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) + // 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 }] }) }, "×") + )), @@ -16146,39 +15953,48 @@ diff --git a/client.js b/client.js + // #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 }) -+ ), + ), +- // The Main Bot tile is always first and always shown, like in the context menu. +- isTile ? null : (0, import_react18.createElement)( + (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react30.createElement)( -+ Card, -+ { title: t("Sidebar") }, + Card, + { title: t("Sidebar") }, +- (0, import_react18.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_react18.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)(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_react18.createElement)( + (0, import_react30.createElement)( -+ Card, + Card, +- { title: t("Manage") }, +- (0, import_react18.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, +- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), +- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) +- ), +- isMainBot ? null : (0, import_react18.createElement)( + { 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 }, + "button", + { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, +- (0, import_react18.createElement)( + (0, import_react30.createElement)( -+ "span", -+ { className: "bt-set-copy" }, + "span", + { className: "bt-set-copy" }, +- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), +- (0, import_react18.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-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_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) + (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon)) + ) + ) - ); - } --function AvatarEditor({ bot, actions, run, onClose }) { -- const look = lookOf(bot); -- const [tab, setTab] = (0, import_react18.useState)(look.image ? "upload" : "bot"); -- const [over, setOver] = (0, import_react18.useState)(false); -- const ref = (0, import_react18.useRef)(null); -- const fileRef = (0, import_react18.useRef)(null); -- const upload = (file) => { -- if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); ++ ); ++} +function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { + const [admin, setAdmin] = (0, import_react30.useState)(null); + const [members, setMembers] = (0, import_react30.useState)([]); @@ -16188,40 +16004,7 @@ diff --git a/client.js b/client.js + const pickAdmin = (id) => { + setAdmin(id); + setMembers((list) => list.filter((item) => item !== id)); - }; -- (0, import_react18.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_react18.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_react18.createElement)( ++ }; + const create = async () => { + if (busy || admin === null || members.length === 0) return; + setBusy(true); @@ -16241,85 +16024,24 @@ diff --git a/client.js b/client.js + 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", -- { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-set-card bt-ng" }, + (0, import_react30.createElement)( - "div", -- { className: "bt-avedit-top" }, -- (0, import_react18.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", -- { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, -- (0, import_react18.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -- [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react18.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) -- ), -- (0, import_react18.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")) ++ "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")) + ) - ), -- tab === "bot" ? (0, import_react18.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_react18.createElement)( ++ ), + admin === null ? null : (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_react18.createElement)("img", { src: look.image, alt: "" }) : null, -- (0, import_react18.createElement)("div", null, t("Drag, drop, or paste an image")), -- (0, import_react18.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)( - "div", -- { className: "bt-actions", style: { justifyContent: "center" } }, -- (0, import_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), -- hasPhoto ? (0, import_react18.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_react18.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 -- ); --} --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_react18.createElement)( -- Card, -- { title: t("Memory") }, -- (0, import_react18.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_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, label)), -- (0, import_react18.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react18.createElement)(ChevronRightIcon)) ++ "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")) + ) @@ -16335,26 +16057,9 @@ diff --git a/client.js b/client.js + { 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")) - ) - ); - } --function BotSettingsForm({ bot, roster, actions, run }) { -- const [editing, setEditing] = (0, import_react18.useState)(false); -- const [confirming, setConfirming] = (0, import_react18.useState)(false); -- const closeEditor = (0, import_react18.useCallback)(() => setEditing(false), []); -- (0, import_react18.useEffect)(() => { -- if (!confirming) return void 0; -- const timer = setTimeout(() => setConfirming(false), 4e3); -- return () => clearTimeout(timer); -- }, [confirming]); -- 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); ++ ) ++ ); ++} +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} @@ -16424,7 +16129,7 @@ diff --git a/client.js b/client.js + } + return JSON.stringify(hits); + }); -+ const matchesName = (...fields) => wanted === "" || fields.some((field) => (field ?? "").toLowerCase().includes(wanted)); ++ const matchesName = (...fields) => wanted === "" || fields.some((field2) => (field2 ?? "").toLowerCase().includes(wanted)); + const sections = []; + const people2 = [ + ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ @@ -16492,153 +16197,52 @@ diff --git a/client.js b/client.js + if (!row || leaving) return; + void Promise.resolve(row.run()).catch((error) => actions.reportError(error, "menu")); + close(); - }; -- (0, import_react18.useEffect)(() => { -- void actions.refreshModels?.(); -- }, [bot.id]); -- return (0, import_react18.createElement)( ++ }; + let index = -1; + return (0, import_react31.createElement)( - "div", -- { className: "bt-bs" }, -- (0, import_react18.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)( - "div", -- { className: "bt-bs-hero" }, -- (0, import_react18.createElement)( ++ "div", + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, + (0, import_react31.createElement)( - "div", -- { className: "bt-avatar-anchor" }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, -- (0, import_react18.createElement)(BotMark, { bot, size: 64, live: true }), -- (0, import_react18.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react18.createElement)(PencilIcon)) -- ), -- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ++ "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_react18.createElement)( -- "div", -- { className: "bt-bs-who" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-name" }, bot.name), -- (0, import_react18.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Profile") }, -- (0, import_react18.createElement)(Row, { label: t("Name") }, (0, import_react18.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), -- (0, import_react18.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react18.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Model") }, -- (0, import_react18.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Model")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), -- (0, import_react18.createElement)(ModelMenu, { bot, roster, actions, run }) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Working folder") }, -- (0, import_react18.createElement)( -- "div", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -- (0, import_react18.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) -- ) -- ), -- (0, import_react18.createElement)( -- Card, -- { title: t("Instructions") }, -- (0, import_react18.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_react18.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) -- ) -- ), -- (0, import_react18.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), -- roster.memory === false ? null : (0, import_react18.createElement)(MemoryCard, { bot, actions }), -- (0, import_react18.createElement)(BotSchedules, { bot, roster, actions }), -- (0, import_react18.createElement)( -- Card, -- { title: t("Main Bot") }, -- onlyMain ? (0, import_react18.createElement)( ++ 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", -- { className: "bt-set-row bt-bs-row" }, -- (0, import_react18.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Main Bot")), -- (0, import_react18.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_react18.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -- ) : (0, import_react18.createElement)(SwitchRow, { label: t("Main Bot"), 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_react18.createElement)( -- Card, -- { title: t("Sidebar") }, -- (0, import_react18.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_react18.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_react18.createElement)( -- Card, -- { title: t("Manage") }, -- (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -- (0, import_react18.createElement)("span", { className: "bt-set-copy" }, (0, import_react18.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react18.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -- (0, import_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) -- ), -- isMainBot ? null : (0, import_react18.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -- (0, import_react18.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react18.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -- (0, import_react18.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_react18.createElement)("span", { className: "bt-chevron" }, (0, import_react18.createElement)(ChevronRightIcon)) ++ "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)( @@ -17425,106 +17029,36 @@ diff --git a/client.js b/client.js if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; -@@ -8785,381 +8785,137 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } - 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 text = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; -- if (turn.current !== own) return; -- if (text) submit(text); -- 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]); +@@ -8838,7 +8838,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } + setServiceLevel(0); + }; + }, [speech, catalog, listen, cycle]); - (0, import_react22.useEffect)(() => { -- if (phase !== "thinking" || running || waiting.current === null) return; -- const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; -- if (count === waiting.current || !text.trim()) return; -- waiting.current = null; -- const spoken = plainSpeech(text); -- 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)(() => { + if (phase !== "thinking" || running || waiting.current === null) return; + const [count, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; + if (count === waiting.current || !text.trim()) return; +@@ -8858,7 +8858,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utterance); + }, [phase, running, lastReply]); - (0, import_react22.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 openSetup = phase === "setup" ? actions.voiceSetup() : null; -- const caption = phase === "speaking" ? said : heard; -- const shownNote = note || dictation.note; ++ (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"; +@@ -8875,291 +8875,304 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } + const openSetup = phase === "setup" ? actions.voiceSetup() : null; + const caption = phase === "speaking" ? said : heard; + const shownNote = note || dictation.note; - return (0, import_react22.createElement)( -- "div", -- { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, ++ return (0, import_react35.createElement)( + "div", + { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, - (0, import_react22.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)( + "div", + { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, - (0, import_react22.createElement)( - "div", - { className: "bt-voice-orb", style: { "--bt-level": level } }, @@ -17650,7 +17184,8 @@ diff --git a/client.js b/client.js - Card, - { title: t("Working folder") }, - (0, import_react23.createElement)( -- "div", ++ (0, import_react35.createElement)( + "div", - { className: "bt-set-row bt-bs-row" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), - (0, import_react23.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) @@ -17700,7 +17235,24 @@ diff --git a/client.js b/client.js - { title: t("Sidebar") }, - (0, import_react23.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_react23.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 })) }) -- ), ++ { 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 ++ ), ++ (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name), ++ (0, import_react35.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]) : "" ++ ), ++ (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 + ), - (0, import_react23.createElement)( - Card, - { title: t("Manage") }, @@ -17715,9 +17267,21 @@ diff --git a/client.js b/client.js - ), - (0, import_react23.createElement)("span", { className: "bt-chevron" }, (0, import_react23.createElement)(ChevronRightIcon)) - ) -- ) -- ); --} ++ (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)) + ) + ); + } -function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { - const [admin, setAdmin] = (0, import_react23.useState)(null); - const [members, setMembers] = (0, import_react23.useState)([]); @@ -17727,7 +17291,42 @@ diff --git a/client.js b/client.js - const pickAdmin = (id) => { - setAdmin(id); - setMembers((list) => list.filter((item) => item !== id)); -- }; ++ ++// src/client/workflows.js ++var import_react36 = require("react"); ++function parseList(text) { ++ return String(text ?? "").split(",").map((item) => item.trim()).filter(Boolean); ++} ++var formatList = (list) => Array.isArray(list) ? list.join(", ") : ""; ++function guardLines(guards) { ++ return (Array.isArray(guards) ? guards : []).map((guard) => `${guard.spec} is ${guard.is}${guard.value === null || guard.value === void 0 ? "" : ` ${guard.value}`}`).join("\n"); ++} ++function parseGuards(text, previous = []) { ++ return String(text ?? "").split("\n").map((line2) => line2.trim()).filter(Boolean).map((line2) => { ++ const found = /^(\S+)\s+is\s+(\S+)(?:\s+(.+))?$/.exec(line2); ++ if (found === null) return null; ++ const [, spec, is, value] = found; ++ const before = previous.find((guard) => guard.spec === spec && guard.is === is); ++ return { ++ id: before?.id ?? `${spec}-${is}`, ++ spec, ++ is, ++ value: value === void 0 ? null : value.trim(), ++ refusal: before?.refusal ?? "" ++ }; ++ }).filter(Boolean); ++} ++function applyNodeField(definition, nodeId, field2, value) { ++ if (definition === null || definition === void 0) return definition; ++ return { ++ ...definition, ++ nodes: definition.nodes.map((node) => { ++ if (node.id !== nodeId) return node; ++ if (field2 === "inputs" || field2 === "outputs") return { ...node, [field2]: parseList(value) }; ++ if (field2 === "guards") return { ...node, guards: parseGuards(value, node.guards) }; ++ return { ...node, [field2]: value }; ++ }) + }; - const create = async () => { - if (busy || admin === null || members.length === 0) return; - setBusy(true); @@ -17737,92 +17336,103 @@ diff --git a/client.js b/client.js - })); - if (done && room2) onCreated(room2); - else setBusy(false); -+ const finish = async (current) => { -+ setPhase("transcribing"); -+ try { -+ const wave = await current.finish(catalog.maxDurationSeconds); -+ const text = wave ? await transcribeWave(speech, catalog, wave, new AbortController().signal) : ""; -+ if (turn.current !== own) return; -+ if (text) submit(text); -+ 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, text] = [lastReply.slice(0, lastReply.indexOf("|")), lastReply.slice(lastReply.indexOf("|") + 1)]; -+ if (count === waiting.current || !text.trim()) return; -+ waiting.current = null; -+ const spoken = plainSpeech(text); -+ 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; ++} ++var field = (label, value, onChange, options = {}) => (0, import_react36.createElement)( ++ "label", ++ { className: "bt-wf-field", key: label }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-label" }, label), ++ (0, import_react36.createElement)("input", { ++ className: "bt-wf-input", ++ value: value ?? "", ++ disabled: options.disabled === true, ++ placeholder: options.placeholder ?? "", ++ onChange: (event) => onChange(event.target.value) ++ }) ++); ++var guardState = (row) => row.state === "holds" ? t("holds") : row.state === "unreadable" ? t("cannot read") : t("refuses"); ++function Flow({ layout }) { ++ if (!layout || !Array.isArray(layout.boxes) || layout.boxes.length === 0) return null; ++ return (0, import_react36.createElement)( ++ "svg", ++ { ++ className: "bt-wf-flow", ++ viewBox: `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`, ++ role: "img", ++ "aria-label": t("The flowchart, drawn from the node table"), ++ "data-boxes": String(layout.boxes.length), ++ "data-edges": String(layout.edges.length) ++ }, ++ layout.edges.map((edge, index) => (0, import_react36.createElement)("line", { ++ key: `e${index}`, ++ className: "bt-wf-edge", ++ x1: edge.points?.[0]?.x ?? 0, ++ y1: edge.points?.[0]?.y ?? 0, ++ x2: edge.points?.[1]?.x ?? 0, ++ y2: edge.points?.[1]?.y ?? 0 ++ })), ++ layout.boxes.map((box) => (0, import_react36.createElement)( ++ "g", ++ { key: box.id, className: "bt-wf-box", "data-node": box.id, transform: `translate(${box.x} ${box.y})` }, ++ (0, import_react36.createElement)("rect", { width: box.width, height: box.height, rx: 12 }), ++ (0, import_react36.createElement)("text", { x: 14, y: 26, className: "bt-wf-box-name" }, box.name), ++ (0, import_react36.createElement)("text", { x: 14, y: 48, className: "bt-wf-box-role" }, box.role), ++ (0, import_react36.createElement)("text", { x: 14, y: 72, className: "bt-wf-box-rails" }, `${box.inputs.join(", ") || "—"} → ${box.outputs.join(", ") || "—"}`) ++ )) ++ ); ++} ++function Preview({ preview }) { ++ if (!preview) return null; ++ return (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-preview", "data-facts": preview.facts }, ++ preview.facts === "unavailable" ? (0, import_react36.createElement)("div", { className: "bt-wf-hint" }, t("This host could not read the facts, so these guards say what they could not read — none of them is a pass.")) : null, ++ (preview.rows ?? []).map((row) => (0, import_react36.createElement)( ++ "div", ++ { key: row.id, className: "bt-wf-guard", "data-state": row.state }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-badge", "data-state": row.state }, guardState(row)), ++ (0, import_react36.createElement)("span", { className: "bt-wf-guard-spec" }, `${row.spec} is ${row.is}${row.value === null || row.value === void 0 ? "" : ` ${row.value}`}`), ++ row.sentence ? (0, import_react36.createElement)("span", { className: "bt-wf-guard-line" }, row.sentence) : null ++ )) ++ ); ++} ++function WorkflowsPage({ actions }) { ++ const [list, setList] = (0, import_react36.useState)(null); ++ const [opened, setOpened] = (0, import_react36.useState)(null); ++ const [draft, setDraft] = (0, import_react36.useState)(null); ++ const [node, setNode] = (0, import_react36.useState)(null); ++ const [preview, setPreview] = (0, import_react36.useState)(null); ++ const [error, setError] = (0, import_react36.useState)(""); ++ const [saved, setSaved] = (0, import_react36.useState)(""); ++ const load = () => Promise.resolve(actions.workflows({ op: "list" })).then((value) => { ++ setList(value); ++ return value; ++ }).catch((failure) => { ++ setError(failure?.message ?? String(failure)); ++ return null; ++ }); ++ (0, import_react36.useEffect)(() => { ++ load(); ++ }, []); ++ const open = (id, target) => Promise.resolve(actions.workflows({ op: "open", id, node: target ?? null })).then((value) => { ++ if (value?.ok === false) { ++ setError(value.message ?? t("Could not open this workflow")); ++ return value; + } -+ turn.current += 1; -+ window.speechSynthesis?.cancel(); -+ setPhase("paused"); ++ setOpened(value); ++ setDraft(value.workflow.definition); ++ setNode(value.preview?.node ?? value.workflow.definition.nodes[0]?.id ?? null); ++ setPreview(value.preview); ++ setError(""); ++ setSaved(""); ++ return value; ++ }).catch((failure) => { ++ setError(failure?.message ?? String(failure)); ++ return null; ++ }); ++ const showNode = (id) => { ++ setNode(id); ++ if (!opened) return; ++ Promise.resolve(actions.workflows({ op: "preview", id: opened.workflow.id, node: id })).then((value) => setPreview(value?.preview ?? null)).catch((failure) => setError(failure?.message ?? String(failure))); }; - const chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { - key: bot.id, @@ -17833,38 +17443,73 @@ diff --git a/client.js b/client.js - onClick - }, (0, import_react23.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react23.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react23.createElement)(AdminStar) : null); - return (0, import_react23.createElement)( -+ const openSetup = phase === "setup" ? actions.voiceSetup() : null; -+ const caption = phase === "speaking" ? said : heard; -+ const shownNote = note || dictation.note; -+ return (0, import_react35.createElement)( ++ const save = () => Promise.resolve(actions.workflowSave({ op: "save", definition: draft })).then((value) => { ++ if (value?.ok === false) { ++ setError(value.message ?? t("This definition cannot be saved")); ++ return value; ++ } ++ setSaved(t("Saved workflows/{id}.json", { id: value.id })); ++ setError(""); ++ return load().then(() => open(value.id, node)); ++ }).catch((failure) => { ++ setError(failure?.message ?? String(failure)); ++ return null; ++ }); ++ const customize = () => { ++ if (!opened) return null; ++ const base = opened.workflow.id; ++ const id = `${base}-copy`; ++ return Promise.resolve(actions.workflowSave({ op: "customize", from: base, id, name: `${opened.workflow.name} (mine)` })).then((value) => { ++ if (value?.ok === false) { ++ setError(value.message ?? t("This definition cannot be copied")); ++ return value; ++ } ++ return load().then(() => open(id)); ++ }).catch((failure) => { ++ setError(failure?.message ?? String(failure)); ++ return null; ++ }); ++ }; ++ const useDefault = () => { ++ if (!opened) return null; ++ return Promise.resolve(actions.workflowSave({ op: "default", id: opened.workflow.id })).then((value) => { ++ if (value?.ok !== false) setList(value); ++ return value; ++ }).catch((failure) => { ++ setError(failure?.message ?? String(failure)); ++ return null; ++ }); ++ }; ++ const editable = opened?.workflow.editable === true; ++ const current = draft?.nodes?.find((entry) => entry.id === node) ?? null; ++ return (0, import_react36.createElement)( "div", - { className: "bt-set-card bt-ng" }, - (0, import_react23.createElement)( -+ { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, -+ (0, import_react35.createElement)( ++ { className: "bt-wf-page" }, ++ (0, import_react36.createElement)( "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react23.createElement)(AdminStar), t("Choose the group admin"))), - (0, import_react23.createElement)( -+ { className: "bt-voice-stage", role: "dialog", "aria-label": t("Voice chat with {name}", { name }), "data-phase": phase === "transcribing" ? "thinking" : phase }, -+ (0, import_react35.createElement)( - "div", +- "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")) - ) -- ), ++ { className: "bt-wf-head" }, ++ (0, import_react36.createElement)("h3", null, (0, import_react36.createElement)(WorkflowIcon), t("Workflows")), ++ (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.")) + ), - admin === null ? null : (0, import_react23.createElement)( -- "div", ++ error ? (0, import_react36.createElement)("div", { className: "bt-error", role: "alert" }, error) : null, ++ saved ? (0, import_react36.createElement)("div", { className: "bt-wf-saved", role: "status" }, saved) : null, ++ (0, import_react36.createElement)( + "div", - { className: "bt-set-row bt-bs-row bt-bs-stack bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), - (0, import_react23.createElement)( -+ { 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 -+ ), -+ (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name), -+ (0, import_react35.createElement)( ++ { className: "bt-wf-list" }, ++ ["presets", "own"].map((group) => (0, import_react36.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")) @@ -17875,32 +17520,75 @@ diff --git a/client.js b/client.js - { className: "bt-set-row bt-bs-row bt-ng-step" }, - (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react23.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) -+ { 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]) : "" -+ ), -+ (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 ++ { key: group, className: "bt-wf-group" }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, group === "presets" ? t("Factory presets") : t("Yours")), ++ (list?.[group] ?? []).map((entry) => (0, import_react36.createElement)("button", { ++ key: `${group}:${entry.id}`, ++ type: "button", ++ className: "bt-wf-pick", ++ "aria-current": opened?.workflow.id === entry.id ? "true" : void 0, ++ "data-id": entry.id, ++ "data-source": entry.source, ++ onClick: () => open(entry.id) ++ }, entry.name, list?.default === entry.id ? (0, import_react36.createElement)("span", { className: "bt-wf-default" }, t("project default")) : null)) ++ )), ++ list === null ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("Reading workflows…")) : null ), - (0, import_react23.createElement)( -+ (0, import_react35.createElement)( ++ opened === null ? null : (0, import_react36.createElement)( "div", - { className: "bt-set-row bt-task-foot" }, - (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react23.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) -+ { 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)) ++ { className: "bt-wf-doc" }, ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-doc-head" }, ++ (0, import_react36.createElement)("strong", null, opened.workflow.name), ++ (0, import_react36.createElement)("span", { className: "bt-wf-path" }, `workflows/${opened.workflow.id}.json`), ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: customize }, t("Customize")), ++ editable ? (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: save }, t("Save")) : null, ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-wf-action", onClick: useDefault }, t("Use for this project")) ++ ), ++ (0, import_react36.createElement)(Flow, { layout: opened.layout }), ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-table" }, ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-row bt-wf-row-head" }, ++ ["id", "name", "role", "inputs", "outputs", "guards", "refusal"].map((key) => (0, import_react36.createElement)("span", { key }, key)) ++ ), ++ (draft?.nodes ?? []).map((entry) => (0, import_react36.createElement)( ++ "div", ++ { ++ key: entry.id, ++ className: "bt-wf-row", ++ "data-node": entry.id, ++ "aria-current": entry.id === node ? "true" : void 0, ++ onClick: () => showNode(entry.id) ++ }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-cell bt-wf-id" }, entry.id), ++ editable ? field("name", entry.name, (value) => setDraft(applyNodeField(draft, entry.id, "name", value))) : (0, import_react36.createElement)("span", null, entry.name), ++ editable ? field("role", entry.role, (value) => setDraft(applyNodeField(draft, entry.id, "role", value))) : (0, import_react36.createElement)("span", null, entry.role), ++ editable ? field("inputs", formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, "inputs", value))) : (0, import_react36.createElement)("span", null, formatList(entry.inputs)), ++ editable ? field("outputs", formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, "outputs", value))) : (0, import_react36.createElement)("span", null, formatList(entry.outputs)), ++ editable ? (0, import_react36.createElement)("textarea", { className: "bt-wf-guards-input", value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, "guards", event.target.value)) }) : (0, import_react36.createElement)("span", null, guardLines(entry.guards)), ++ editable ? field("refusal", entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, "refusal", value))) : (0, import_react36.createElement)("span", null, entry.refusal) ++ )) ++ ), ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-edges" }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, t("Edges")), ++ (draft?.edges ?? []).map((edge) => (0, import_react36.createElement)("span", { key: `${edge.from}->${edge.to}`, className: "bt-wf-edge-row" }, `${edge.from} → ${edge.to}`)) ++ ), ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-wf-node" }, ++ (0, import_react36.createElement)("span", { className: "bt-wf-group-name" }, node === null ? t("Guards") : t("Guards of {node}", { node: current?.name ?? node })), ++ current === null || current.guards.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-wf-hint" }, t("This node has no guards, so nothing refuses it but its inputs.")) : (0, import_react36.createElement)(Preview, { preview }) ++ ) ) ); } @@ -17949,7 +17637,7 @@ diff --git a/client.js b/client.js // src/client/usage-page.js -var import_react24 = require("react"); -+var import_react36 = require("react"); ++var import_react37 = require("react"); var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; @@ -17965,9 +17653,9 @@ diff --git a/client.js b/client.js - const [shown, setShown] = (0, import_react24.useState)(0); - const from = (0, import_react24.useRef)(0); - (0, import_react24.useEffect)(() => { -+ const [shown, setShown] = (0, import_react36.useState)(0); -+ const from = (0, import_react36.useRef)(0); -+ (0, import_react36.useEffect)(() => { ++ 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); @@ -17976,10 +17664,10 @@ diff --git a/client.js b/client.js return shown; } -var glyphBox = (size, icon2) => (0, import_react24.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react24.createElement)(icon2)); -+var glyphBox = (size, icon3) => (0, import_react36.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react36.createElement)(icon3)); ++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_react24.createElement)("span", { -+ return (0, import_react36.createElement)("span", { ++ 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))` } @@ -17988,7 +17676,7 @@ diff --git a/client.js b/client.js paint: paintCss(colorOf(bot)), stops: GRADIENTS[colorOf(bot)], - glyph: (size) => (0, import_react24.createElement)(BotAvatar, { bot, size, badge: false, live: false }) -+ glyph: (size) => (0, import_react36.createElement)(BotAvatar, { bot, size, badge: false, live: false }) ++ glyph: (size) => (0, import_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false }) }; } if (owner.kind === "room") { @@ -17997,7 +17685,7 @@ diff --git a/client.js b/client.js sub: owner.deleted ? t("Deleted") : t("Group chat"), paint: "var(--bt-us-rest)", - glyph: (size) => room2 ? (0, import_react24.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) -+ glyph: (size) => room2 ? (0, import_react36.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) ++ glyph: (size) => room2 ? (0, import_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) }; } if (owner.kind === "agent") { @@ -18006,13 +17694,13 @@ diff --git a/client.js b/client.js const glide = useGlide(`${value} ${names}`, { box: boxRef }); - return (0, import_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "div", { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label }, - (0, import_react24.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.map(([id, text, icon2]) => (0, import_react24.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon2 ? (0, import_react24.createElement)(icon2) : null, text)) -+ (0, import_react36.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ options.map(([id, text, icon3]) => (0, import_react36.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react36.createElement)(icon3) : null, text)) ++ (0, import_react37.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ options.map(([id, text, icon3]) => (0, import_react37.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react37.createElement)(icon3) : null, text)) ); } function Stats({ range, total, previous }) { @@ -18020,25 +17708,25 @@ diff --git a/client.js b/client.js const change = previous > 0 ? Math.round((total.total - previous) / previous * 100) : null; - const cell = (label, value) => (0, import_react24.createElement)("div", { className: "bt-us-stat" }, (0, import_react24.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react24.createElement)("span", { className: "bt-us-stat-value" }, value)); - return (0, import_react24.createElement)( -+ const cell = (label, value) => (0, import_react36.createElement)("div", { className: "bt-us-stat" }, (0, import_react36.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react36.createElement)("span", { className: "bt-us-stat-value" }, value)); -+ return (0, import_react36.createElement)( ++ 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_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-stat bt-us-stat-main" }, - (0, import_react24.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), ++ (0, import_react37.createElement)( "span", { className: "bt-us-stat-value" }, compact(shown), - (0, import_react24.createElement)("small", null, t("tokens")), - change === null ? null : (0, import_react24.createElement)( -+ (0, import_react36.createElement)("small", null, t("tokens")), -+ change === null ? null : (0, import_react36.createElement)( ++ (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)}%` @@ -18047,7 +17735,7 @@ diff --git a/client.js b/client.js } function Chart({ range, columns, series, tipSeries, by, onBy, onPick }) { - const [hover, setHover] = (0, import_react24.useState)(null); -+ const [hover, setHover] = (0, import_react36.useState)(null); ++ 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])); @@ -18055,77 +17743,77 @@ diff --git a/client.js b/client.js 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_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "section", { className: "bt-us-card bt-us-chart", style: { "--i": 1 } }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-card-head" }, - (0, import_react24.createElement)("h3", null, t("Tokens over time"), (0, import_react24.createElement)("span", null, unitLabel(range.unit))), - onBy ? (0, import_react24.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null -+ (0, import_react36.createElement)("h3", null, t("Tokens over time"), (0, import_react36.createElement)("span", null, unitLabel(range.unit))), -+ onBy ? (0, import_react36.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null ++ (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 ), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-chart-body" }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react24.createElement)("span", null, compact(top * level)))), - (0, import_react24.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react24.createElement)("span", null, "0")), - (0, import_react24.createElement)( -+ [1, 0.5].map((level) => (0, import_react36.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react36.createElement)("span", null, compact(top * level)))), -+ (0, import_react36.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react36.createElement)("span", null, "0")), -+ (0, import_react36.createElement)( ++ [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_react24.createElement)("div", { -+ columns.map((column, at) => (0, import_react36.createElement)("div", { ++ 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_react24.createElement)( -+ }, column.total > 0 ? (0, import_react36.createElement)( ++ }, 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_react24.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) - ) : (0, import_react24.createElement)("div", { className: "bt-us-stub" }))) -+ column.parts.map(([key, value]) => value > 0 ? (0, import_react36.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) -+ ) : (0, import_react36.createElement)("div", { className: "bt-us-stub" }))) ++ 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_react24.createElement)( -+ tip ? (0, import_react36.createElement)( ++ tip ? (0, import_react37.createElement)( "div", { className: "bt-us-tip", style: tipSide }, - (0, import_react24.createElement)("div", { className: "bt-us-tip-title" }, tip.title), - (0, import_react24.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_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-tip-title" }, tip.title), -+ (0, import_react36.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_react36.createElement)( ++ (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_react24.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), - (0, import_react24.createElement)("span", null, tipOf.get(key)?.name), - (0, import_react24.createElement)("b", null, compact(value)) -+ (0, import_react36.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), -+ (0, import_react36.createElement)("span", null, tipOf.get(key)?.name), -+ (0, import_react36.createElement)("b", null, compact(value)) ++ (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_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.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_react24.createElement)( -+ columns.map((column, at) => (0, import_react36.createElement)( ++ 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 : "" @@ -18133,23 +17821,23 @@ diff --git a/client.js b/client.js ) ), - series.length > 1 || series[0]?.pick ? (0, import_react24.createElement)( -+ series.length > 1 || series[0]?.pick ? (0, import_react36.createElement)( ++ series.length > 1 || series[0]?.pick ? (0, import_react37.createElement)( "div", { className: "bt-us-legend" }, - series.map((entry) => (0, import_react24.createElement)(entry.pick ? "button" : "span", { -+ series.map((entry) => (0, import_react36.createElement)(entry.pick ? "button" : "span", { ++ 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_react24.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) -+ }, (0, import_react36.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) ++ }, (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) ) : null ); } function Row2({ kind, glyph, name, sub, sum, whole, fill: fill2, index, onClick }) { const width = whole > 0 ? Math.max(sum.total > 0 ? 2 : 0, sum.total / whole * 100) : 0; - return (0, import_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( onClick ? "button" : "div", { className: "bt-us-row", @@ -18159,77 +17847,77 @@ diff --git a/client.js b/client.js }, - glyph ? (0, import_react24.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, - (0, import_react24.createElement)( -+ glyph ? (0, import_react36.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, -+ (0, import_react36.createElement)( ++ 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_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "span", { className: "bt-us-row-line" }, - (0, import_react24.createElement)("span", { className: "bt-us-row-name" }, name), - sub ? (0, import_react24.createElement)("span", { className: "bt-us-row-sub" }, sub) : null -+ (0, import_react36.createElement)("span", { className: "bt-us-row-name" }, name), -+ sub ? (0, import_react36.createElement)("span", { className: "bt-us-row-sub" }, sub) : null ++ (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_react24.createElement)("span", { className: "bt-us-track" }, (0, import_react24.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) -+ (0, import_react36.createElement)("span", { className: "bt-us-track" }, (0, import_react36.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ++ (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ), - (0, import_react24.createElement)("span", { className: "bt-us-row-num" }, (0, import_react24.createElement)("b", null, compact(sum.total)), (0, import_react24.createElement)("small", null, `${percent(sum.total, whole)}%`)) -+ (0, import_react36.createElement)("span", { className: "bt-us-row-num" }, (0, import_react36.createElement)("b", null, compact(sum.total)), (0, import_react36.createElement)("small", null, `${percent(sum.total, whole)}%`)) ++ (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_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "section", { className: "bt-us-card", style: { "--i": index } }, - (0, import_react24.createElement)("div", { className: "bt-us-card-head" }, (0, import_react24.createElement)("h3", null, title, count === void 0 ? null : (0, import_react24.createElement)("span", null, count))), - (0, import_react24.createElement)("div", { className: "bt-us-rows" }, children) -+ (0, import_react36.createElement)("div", { className: "bt-us-card-head" }, (0, import_react36.createElement)("h3", null, title, count === void 0 ? null : (0, import_react36.createElement)("span", null, count))), -+ (0, import_react36.createElement)("div", { className: "bt-us-rows" }, children) ++ (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)) * RING; - return (0, import_react24.createElement)( + const arc = Math.max(0, Math.min(1, share)) * RING2; -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "section", { className: "bt-us-profile" }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "span", { className: "bt-us-ring" }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "svg", { viewBox: "0 0 96 96", "aria-hidden": true }, - face.stops ? (0, import_react24.createElement)("defs", null, (0, import_react24.createElement)( -+ face.stops ? (0, import_react36.createElement)("defs", null, (0, import_react36.createElement)( ++ 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_react24.createElement)("stop", { key: offset, offset, stopColor: color })) -+ face.stops.map(([offset, color]) => (0, import_react36.createElement)("stop", { key: offset, offset, stopColor: color })) ++ face.stops.map(([offset, color]) => (0, import_react37.createElement)("stop", { key: offset, offset, stopColor: color })) )) : null, - (0, import_react24.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), - arc > 0 ? (0, import_react24.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} ${RING}` } }) : null -+ (0, import_react36.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), -+ arc > 0 ? (0, import_react36.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)("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_react24.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) -+ (0, import_react36.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ++ (0, import_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-profile-copy" }, - (0, import_react24.createElement)("h3", null, face.name), - face.sub ? (0, import_react24.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, - (0, import_react24.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) })) -+ (0, import_react36.createElement)("h3", null, face.name), -+ face.sub ? (0, import_react36.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, -+ (0, import_react36.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) })) ++ (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) })) ) ); } @@ -18237,17 +17925,17 @@ diff --git a/client.js b/client.js 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_react24.useRef)(null); -+ const box = (0, import_react36.useRef)(null); ++ const box = (0, import_react37.useRef)(null); const todayMs = hourMs(today); const thisMonth = monthOf(todayMs); - const [right, setRight] = (0, import_react24.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); - const [anchor, setAnchor] = (0, import_react24.useState)(null); - const [hover, setHover] = (0, import_react24.useState)(null); - (0, import_react24.useEffect)(() => { -+ const [right, setRight] = (0, import_react36.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); -+ const [anchor, setAnchor] = (0, import_react36.useState)(null); -+ const [hover, setHover] = (0, import_react36.useState)(null); -+ (0, import_react36.useEffect)(() => { ++ 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(); }; @@ -18256,19 +17944,19 @@ diff --git a/client.js b/client.js onPick(from, to); }; - const weekdays = (0, import_react24.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); -+ const weekdays = (0, import_react36.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); ++ 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_react24.createElement)("span", { key: `b${at}` })); -+ const cells = Array.from({ length: lead }, (_, at) => (0, import_react36.createElement)("span", { key: `b${at}` })); ++ 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_react24.createElement)("button", { -+ cells.push((0, import_react36.createElement)("button", { ++ cells.push((0, import_react37.createElement)("button", { key, type: "button", className: "bt-us-day", @@ -18277,20 +17965,20 @@ diff --git a/client.js b/client.js onMouseEnter: () => setHover(key), onClick: () => press(key) - }, (0, import_react24.createElement)("b", null, day))); -+ }, (0, import_react36.createElement)("b", null, day))); ++ }, (0, import_react37.createElement)("b", null, day))); } - const nav = side === "left" ? (0, import_react24.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_react24.createElement)(ChevronLeftIcon)) : (0, import_react24.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_react24.createElement)(ChevronRightIcon)); - return (0, import_react24.createElement)( -+ const nav = side === "left" ? (0, import_react36.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_react36.createElement)(ChevronLeftIcon)) : (0, import_react36.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_react36.createElement)(ChevronRightIcon)); -+ return (0, import_react36.createElement)( ++ 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_react24.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react24.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), - (0, import_react24.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react24.createElement)("span", { key: at }, name))), - (0, import_react24.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) -+ (0, import_react36.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react36.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), -+ (0, import_react36.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react36.createElement)("span", { key: at }, name))), -+ (0, import_react36.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) ++ (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 = [ @@ -18299,15 +17987,15 @@ diff --git a/client.js b/client.js ]; const count = low === null ? 0 : Math.round((hourMs(high) - hourMs(low)) / DAY_MS) + 1; - return (0, import_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "div", { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-pick-quick" }, - shortcuts.map(([id, label, from, to]) => (0, import_react24.createElement)("button", { -+ shortcuts.map(([id, label, from, to]) => (0, import_react36.createElement)("button", { ++ shortcuts.map(([id, label, from, to]) => (0, import_react37.createElement)("button", { key: id, type: "button", "aria-pressed": span?.from === from && span?.to === to, @@ -18316,20 +18004,20 @@ diff --git a/client.js b/client.js ), - (0, import_react24.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), - low === null ? null : (0, import_react24.createElement)("span", null, (0, import_react24.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) -+ (0, import_react36.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), -+ low === null ? null : (0, import_react36.createElement)("span", null, (0, import_react36.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) ++ (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)}`) ) ); } function Heat({ view, keep, paint, index }) { - const [hover, setHover] = (0, import_react24.useState)(null); -+ const [hover, setHover] = (0, import_react36.useState)(null); ++ 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); @@ -18338,14 +18026,14 @@ diff --git a/client.js b/client.js 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_react24.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); -+ months.push((0, import_react36.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); ++ 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_react24.createElement)("span", { -+ cells.push((0, import_react36.createElement)("span", { ++ cells.push((0, import_react37.createElement)("span", { key: ms, className: "bt-us-cell", style: { "--w": week }, @@ -18354,7 +18042,7 @@ diff --git a/client.js b/client.js "data-today": ms === today ? "" : void 0, onMouseEnter: ms > today ? void 0 : () => setHover({ ms, week, day }) - }, level > 0 ? (0, import_react24.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); -+ }, level > 0 ? (0, import_react36.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); ++ }, level > 0 ? (0, import_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); } } const tipSide = hover ? { @@ -18363,63 +18051,63 @@ diff --git a/client.js b/client.js 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_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "section", { className: "bt-us-card bt-us-heat", style: { "--i": index } }, - (0, import_react24.createElement)("div", { className: "bt-us-card-head" }, (0, import_react24.createElement)("h3", null, t("Activity")), (0, import_react24.createElement)("span", { className: "bt-us-head-note" }, summary)), - (0, import_react24.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-card-head" }, (0, import_react36.createElement)("h3", null, t("Activity")), (0, import_react36.createElement)("span", { className: "bt-us-head-note" }, summary)), -+ (0, import_react36.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), - hover ? (0, import_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), -+ hover ? (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), - (0, import_react24.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) })) -+ (0, import_react36.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), -+ (0, import_react36.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) })) ++ (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_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-heat-scale", "aria-hidden": true }, t("Light"), - levels.map((level) => (0, import_react24.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react24.createElement)("i", { style: { background: soften(paint, level) } }))), -+ levels.map((level) => (0, import_react36.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react36.createElement)("i", { style: { background: soften(paint, level) } }))), ++ 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_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "div", { className: "bt-us", "aria-busy": true }, - (0, import_react24.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), - (0, import_react24.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), -+ (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), - (0, import_react24.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) -+ (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), -+ (0, import_react36.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) ++ (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), ++ (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) ) ) ); @@ -18433,16 +18121,16 @@ diff --git a/client.js b/client.js - const [picking, setPicking] = (0, import_react24.useState)(false); - const [by, setBy] = (0, import_react24.useState)("bot"); - const tabs = (0, import_react24.useRef)(null); -+ const [view, setView] = (0, import_react36.useState)(null); -+ const [error, setError] = (0, import_react36.useState)(""); -+ const [rangeId, setRangeId] = (0, import_react36.useState)("7d"); -+ const [span, setSpan] = (0, import_react36.useState)(null); -+ const [picking, setPicking] = (0, import_react36.useState)(false); -+ const [by, setBy] = (0, import_react36.useState)("bot"); -+ const tabs = (0, import_react36.useRef)(null); ++ 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_react24.useEffect)(() => { -+ (0, import_react36.useEffect)(() => { ++ (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(() => { @@ -18451,7 +18139,7 @@ diff --git a/client.js b/client.js await resized; if (stopped) return; - (0, import_react24.startTransition)(() => setView(value)); -+ (0, import_react36.startTransition)(() => setView(value)); ++ (0, import_react37.startTransition)(() => setView(value)); setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { @@ -18460,7 +18148,7 @@ diff --git a/client.js b/client.js 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_react24.useMemo)(() => { -+ const shaped = (0, import_react36.useMemo)(() => { ++ const shaped = (0, import_react37.useMemo)(() => { if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); @@ -18469,7 +18157,7 @@ diff --git a/client.js b/client.js 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_react24.createElement)(ModelGlyph, { name, paint, size }) }; -+ return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react36.createElement)(ModelGlyph, { name, paint, size }) }; ++ 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; @@ -18479,29 +18167,29 @@ diff --git a/client.js b/client.js }, [view, rangeId, span, by, focusId, roster]); - const root = (0, import_react24.useRef)(null); - (0, import_react24.useEffect)(() => { -+ const root = (0, import_react36.useRef)(null); -+ (0, import_react36.useEffect)(() => { ++ 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_react24.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react24.createElement)(Skeleton2); -+ return error ? (0, import_react36.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react36.createElement)(Skeleton2); ++ 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_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "div", { className: "bt-us" }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-empty" }, - (0, import_react24.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), - (0, import_react24.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), - (0, import_react24.createElement)("span", null, t("Token usage shows up here after a model is called.")) -+ (0, import_react36.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), -+ (0, import_react36.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), -+ (0, import_react36.createElement)("span", null, t("Token usage shows up here after a model is called.")) ++ (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.")) ) ); } @@ -18510,7 +18198,7 @@ diff --git a/client.js b/client.js ]; const whole = part.total.total; - const ownerRow = ([at, sum], index, total = whole) => (0, import_react24.createElement)(Row2, { -+ const ownerRow = ([at, sum], index, total = whole) => (0, import_react36.createElement)(Row2, { ++ const ownerRow = ([at, sum], index, total = whole) => (0, import_react37.createElement)(Row2, { key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), @@ -18519,7 +18207,7 @@ diff --git a/client.js b/client.js onClick: () => pick2({ kind: "owner", key: view.owners[at].key }) }); - const modelRow = ([at, sum], index) => (0, import_react24.createElement)(Row2, { -+ const modelRow = ([at, sum], index) => (0, import_react36.createElement)(Row2, { ++ const modelRow = ([at, sum], index) => (0, import_react37.createElement)(Row2, { key: `m${at}`, kind: "model", glyph: models[at].glyph(32), @@ -18528,7 +18216,7 @@ diff --git a/client.js b/client.js onClick: () => pick2({ kind: "model", key: view.models[at].key }) }); - const nothing = (0, import_react24.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); -+ const nothing = (0, import_react36.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); ++ 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); @@ -18537,30 +18225,30 @@ diff --git a/client.js b/client.js body = [ - (0, import_react24.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), - (0, import_react24.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), -+ (0, import_react36.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react24.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_react24.createElement)( -+ ownerAt >= 0 ? (0, import_react36.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react36.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_react36.createElement)( ++ 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_react24.createElement)(Row2, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing -+ kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react36.createElement)(Row2, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing ++ kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react37.createElement)(Row2, { key: at, name: kindLabel(view.kinds[at], ownerAt >= 0 && isBot(ownerAt)), sum, whole, index, fill: soften(face.paint, 0.8) })) : nothing ) ), - (0, import_react24.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) -+ (0, import_react36.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ++ (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ) ]; } else { @@ -18569,52 +18257,52 @@ diff --git a/client.js b/client.js const usedModels = ranked(all.models); body = [ - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { key: "sheet", className: "bt-us-sheet" }, - (0, import_react24.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), - (0, import_react24.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), - (0, import_react24.createElement)( -+ (0, import_react36.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), -+ (0, import_react36.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), -+ (0, import_react36.createElement)( ++ (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_react24.createElement)( -+ (0, import_react36.createElement)( ++ (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_react24.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, -+ bots.length > 0 && rest.length > 0 ? (0, import_react36.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, ++ 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_react24.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) -+ (0, import_react36.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) ++ (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) ), - (0, import_react24.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) -+ (0, import_react36.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ++ (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ) ]; } - return (0, import_react24.createElement)( -+ return (0, import_react36.createElement)( ++ return (0, import_react37.createElement)( "div", { ref: root, className: "bt-us", key: focusId ?? "all" }, - (0, import_react24.createElement)( -+ (0, import_react36.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-us-top" }, - (0, import_react24.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), - view.scanning ? (0, import_react24.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, - picking ? (0, import_react24.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null -+ (0, import_react36.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), -+ view.scanning ? (0, import_react36.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, -+ picking ? (0, import_react36.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null ++ (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_react24.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, -+ error ? (0, import_react36.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, ++ error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, body ); } @@ -18631,7 +18319,7 @@ diff --git a/client.js b/client.js // src/client/connectors.js -var import_react25 = require("react"); -+var import_react37 = require("react"); ++var import_react38 = require("react"); var TOKEN_URL = "https://github.com/settings/personal-access-tokens/new"; -var GitHubMark = () => (0, import_react25.createElement)( +var COMPOSIO_KEY_URL = "https://platform.composio.dev/"; @@ -18666,18 +18354,18 @@ diff --git a/client.js b/client.js + ["youtube", "YouTube"] +]; +var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug; -+var GitHubMark = () => (0, import_react37.createElement)( ++var GitHubMark = () => (0, import_react38.createElement)( "svg", { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react25.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" }) -+ (0, import_react37.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" }) ++ (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_react37.createElement)( ++var FeishuMark = () => (0, import_react38.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -+ (0, import_react37.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_react37.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_react37.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" }) ++ (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 }; -var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; @@ -18704,11 +18392,11 @@ diff --git a/client.js b/client.js - const [error, setError] = (0, import_react25.useState)(null); +function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { + const label = t(credential.label); -+ if (credential.masked) return (0, import_react37.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); -+ return (0, import_react37.createElement)( ++ 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_react37.createElement)("input", { ++ (0, import_react38.createElement)("input", { + className: "bt-input", + type: "text", + value, @@ -18724,31 +18412,31 @@ diff --git a/client.js b/client.js + ); +} +function AllowedSenders({ connector, actions, run, busy }) { -+ const [openId, setOpenId] = (0, import_react37.useState)(""); ++ 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_react37.createElement)( ++ return (0, import_react38.createElement)( + "div", + { className: "bt-connector-allowed" }, -+ (0, import_react37.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), -+ allowed.length === 0 && refused.length === 0 ? (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, -+ allowed.map((entry) => (0, import_react37.createElement)( ++ (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_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), -+ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) ++ (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_react37.createElement)( ++ refused.map((entry) => (0, import_react38.createElement)( + "div", + { key: entry.openId, className: "bt-connector-person" }, -+ (0, import_react37.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), -+ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) ++ (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_react37.createElement)( ++ (0, import_react38.createElement)( + "div", + { className: "bt-connector-form-row" }, -+ (0, import_react37.createElement)("input", { ++ (0, import_react38.createElement)("input", { + className: "bt-input", + type: "text", + value: openId, @@ -18763,7 +18451,7 @@ diff --git a/client.js b/client.js + } + } + }), -+ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { ++ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { + allow(openId.trim()); + setOpenId(""); + } }, t("Allow")) @@ -18773,20 +18461,20 @@ diff --git a/client.js b/client.js +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_react37.createElement)( ++ 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_react37.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null ++ label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null + ); +} +function AppPicker({ apps, onChange, search, statuses = {} }) { -+ const [query, setQuery] = (0, import_react37.useState)(""); -+ const [found, setFound] = (0, import_react37.useState)(null); -+ const [failed, setFailed] = (0, import_react37.useState)(null); -+ const names = (0, import_react37.useRef)(new Map(APPS)); -+ const asked = (0, import_react37.useRef)(0); -+ (0, import_react37.useEffect)(() => { ++ 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 text = query.trim(); + if (text === "" || search === void 0) { + setFound(null); @@ -18813,13 +18501,13 @@ diff --git a/client.js b/client.js + 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_react37.createElement)( ++ return (0, import_react38.createElement)( + "div", + { className: "bt-connector-apps" }, -+ apps.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react37.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, -+ (0, import_react37.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_react37.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, -+ (0, import_react37.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react37.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => { ++ 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))) @@ -18827,22 +18515,22 @@ diff --git a/client.js b/client.js +} +function AppStatusList({ apps, statuses }) { + if (apps.length === 0 || Object.keys(statuses).length === 0) return null; -+ return (0, import_react37.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react37.createElement)( ++ 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_react37.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), -+ (0, import_react37.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") ++ (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]]?.() ?? "") + ))); +} +function ConnectorRow({ connector, roster, actions }) { -+ const [form, setForm] = (0, import_react37.useState)(false); -+ const [bots, setBots] = (0, import_react37.useState)(false); -+ const [token, setToken] = (0, import_react37.useState)(""); -+ const [apps, setApps] = (0, import_react37.useState)(() => connector.apps ?? []); -+ const [picking, setPicking] = (0, import_react37.useState)(false); -+ const [busy, setBusy] = (0, import_react37.useState)(false); -+ const [error, setError] = (0, import_react37.useState)(null); -+ const [message, setMessage] = (0, import_react37.useState)(""); ++ 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); @@ -18854,15 +18542,15 @@ diff --git a/client.js b/client.js + const sender = connector.kind === "sender"; + const channel = connector.kind === "channel"; + const modes = connector.modes ?? []; -+ const [mode, setMode] = (0, import_react37.useState)(() => connector.mode ?? modes[0]?.id); -+ const [values, setValues] = (0, import_react37.useState)({}); ++ 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 field2 = (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_react37.useState)({}); ++ const filled = chosen !== void 0 && chosen.credentials.every((credential) => field2(credential.name).trim() !== ""); ++ const [statuses, setStatuses] = (0, import_react38.useState)({}); + const appsKey = (connector.apps ?? []).join(","); -+ (0, import_react37.useEffect)(() => { ++ (0, import_react38.useEffect)(() => { + if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) { + setStatuses({}); + return void 0; @@ -18877,14 +18565,14 @@ diff --git a/client.js b/client.js + live = false; + }; + }, [gateway, connector.state, appsKey, picking]); -+ const search = (0, import_react37.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); ++ 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 || token.trim() === "") return; - void run(actions.connectorConnect(connector.id, token), () => { + if (busy) return; + if (channel) { + if (chosen === void 0 || !filled) return; -+ const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field(credential.name)])); ++ const credentials = Object.fromEntries(chosen.credentials.map((credential) => [credential.name, field2(credential.name)])); + void run(actions.connectorConnect(connector.id, { mode: chosen.id, credentials }), () => { + setValues({}); + setForm(false); @@ -18916,63 +18604,63 @@ diff --git a/client.js b/client.js - const row = (0, import_react25.useRef)(null); - useMorph(row, `${form}:${error !== null}:${connector.state}`); - return (0, import_react25.createElement)( -+ const row = (0, import_react37.useRef)(null); ++ const row = (0, import_react38.useRef)(null); + useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); -+ return (0, import_react37.createElement)( ++ return (0, import_react38.createElement)( "div", { ref: row, className: "bt-connector", "data-state": connector.state }, - (0, import_react25.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react38.createElement)( "div", { className: "bt-connector-head" }, - (0, import_react25.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react25.createElement)(Mark) : null), - (0, import_react25.createElement)( -+ (0, import_react37.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react37.createElement)(Mark) : null), -+ (0, import_react37.createElement)( ++ (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_react25.createElement)("span", { className: "bt-connector-name" }, connector.name), - (0, import_react25.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) -+ (0, import_react37.createElement)("span", { className: "bt-connector-name" }, connector.name), -+ (0, import_react37.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ++ (0, import_react38.createElement)("span", { className: "bt-connector-name" }, connector.name), ++ (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ), - (0, import_react25.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react38.createElement)( "span", { className: "bt-connector-actions" }, - connector.state === "error" ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null, - on ? (0, import_react25.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_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) -+ connector.state === "error" ? (0, import_react37.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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ 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_react37.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_react37.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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) ++ 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 && form ? (0, import_react25.createElement)( -+ on && gateway && !picking ? (0, import_react37.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, -+ on && perBot ? (0, import_react37.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, -+ on && bots ? (0, import_react37.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, -+ on && channel && connector.mode === "app" ? (0, import_react37.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, -+ on && canPost ? (0, import_react37.createElement)( ++ 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_react25.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react25.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: t("GitHub token"), autoFocus: true })), - (0, import_react25.createElement)( -+ (0, import_react37.createElement)("label", { className: "bt-q-field" }, (0, import_react37.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_react37.createElement)( ++ (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)( + "div", + { className: "bt-connector-form-row" }, -+ (0, import_react37.createElement)("span", { className: "bt-connector-link" }), -+ (0, import_react37.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")) ++ (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_react37.createElement)( ++ !on && form ? (0, import_react38.createElement)( + "div", + { className: "bt-connector-form" }, -+ channel ? (0, import_react37.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react37.createElement)("button", { ++ 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, + type: "button", + className: "bt-state", @@ -18983,77 +18671,77 @@ diff --git a/client.js b/client.js + setError(null); + } + }, t(entry.label)))) : null, -+ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react37.createElement)(CredentialField, { ++ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, { + key: credential.name, + credential, -+ value: field(credential.name), ++ value: field2(credential.name), + onChange: (value) => setField(credential.name, value), + onEnter: connect, + autoFocus: index === 0 -+ })) : (0, import_react37.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react37.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), -+ gateway ? (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, -+ (0, import_react37.createElement)( ++ })) : (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_react25.createElement)("a", { className: "bt-connector-link", href: TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, t("Create a token on GitHub")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { -+ (0, import_react37.createElement)( ++ (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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ (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_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) ++ (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_react37.createElement)( ++ on && gateway && picking ? (0, import_react38.createElement)( + "div", + { className: "bt-connector-form" }, -+ (0, import_react37.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), -+ (0, import_react37.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_react37.createElement)( ++ (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_react37.createElement)("span", { className: "bt-connector-link" }), -+ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ (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_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || token.trim() === "", onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) ++ (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_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null -+ error ? (0, import_react37.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : 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_react25.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react38.createElement)( "section", { className: "bt-set-section" }, - (0, import_react25.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react25.createElement)(ConnectorRow, { key: connector.id, connector, actions }))) -+ (0, import_react37.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react37.createElement)("div", { className: "bt-set-row" }, (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react37.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions }))) ++ (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 }))) ); } // src/client/feedback.js -var import_react26 = require("react"); -var FeedbackIcon = () => (0, import_react26.createElement)( -+var import_react38 = require("react"); -+var FeedbackIcon = () => (0, import_react38.createElement)( ++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_react26.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_react26.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) -+ (0, import_react38.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_react38.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) ++ (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, text) { const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" })); @@ -19077,17 +18765,17 @@ diff --git a/client.js b/client.js - const [report, setReport] = (0, import_react26.useState)(null); - const [preview, setPreview] = (0, import_react26.useState)(false); - const [note, setNote] = (0, import_react26.useState)(""); -+ const [what, setWhat] = (0, import_react38.useState)(""); -+ const [report, setReport] = (0, import_react38.useState)(null); -+ const [preview, setPreview] = (0, import_react38.useState)(false); -+ const [note, setNote] = (0, import_react38.useState)(""); ++ 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_react26.useEffect)(() => { -+ (0, import_react38.useEffect)(() => { ++ (0, import_react39.useEffect)(() => { void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { @@ -19096,7 +18784,7 @@ diff --git a/client.js b/client.js if (!copied) setPreview(true); })); - const [issueLink, setIssueLink] = (0, import_react26.useState)(null); -+ const [issueLink, setIssueLink] = (0, import_react38.useState)(null); ++ const [issueLink, setIssueLink] = (0, import_react39.useState)(null); const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; @@ -19105,83 +18793,83 @@ diff --git a/client.js b/client.js const shown = entries2.slice(0, 8); return [ - (0, import_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( Section, { key: "report", title: t("Report a problem") }, - (0, import_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( "div", { className: "bt-set-row bt-set-paste bt-fb-what" }, - (0, import_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( "label", { className: "bt-set-copy", htmlFor: "bt-fb-what" }, - (0, import_react26.createElement)("span", null, t("What happened?")), - (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue.")) -+ (0, import_react38.createElement)("span", null, t("What happened?")), -+ (0, import_react38.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)("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_react26.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_react38.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)("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_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( Row3, { label: t("Diagnostics"), hint: t("Versions, system, models, and the latest errors. Saved keys, tokens, and your home folder are hidden.") }, - (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")), - (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy")) -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy")) ++ (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_react26.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react26.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, - (0, import_react26.createElement)( -+ preview ? (0, import_react38.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react38.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, -+ (0, import_react38.createElement)( ++ 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)( Row3, - { label: t("GitHub issue"), hint: t("Opens a new issue in the DS Bot repository with all of this filled in. Nothing is sent until you submit it there.") }, - (0, import_react26.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) + { 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_react38.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ++ (0, import_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ) ), - note ? (0, import_react26.createElement)( -+ note ? (0, import_react38.createElement)( ++ note ? (0, import_react39.createElement)( "div", { key: "note", className: "bt-note bt-fb-note", role: "status" }, note, - issueLink ? [" ", (0, import_react26.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null -+ issueLink ? [" ", (0, import_react38.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ++ issueLink ? [" ", (0, import_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ) : null, - (0, import_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( Section, { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") }, - shown.length === 0 ? (0, import_react26.createElement)(Row3, { label: t("No errors recorded."), hint: t("Failed model calls and DS Bot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react26.createElement)( -+ shown.length === 0 ? (0, import_react38.createElement)(Row3, { label: t("No errors recorded."), hint: t("Failed model calls and FlyBot problems show here, newest first.") }) : shown.map((entry, index) => (0, import_react38.createElement)( ++ shown.length === 0 ? (0, import_react39.createElement)(Row3, { 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_react26.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), - (0, import_react26.createElement)( -+ (0, import_react38.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), -+ (0, import_react38.createElement)( ++ (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_react26.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), - (0, import_react26.createElement)("span", { className: "bt-set-hint" }, [whenText(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "DS Bot")].filter(Boolean).join(" · ")) -+ (0, import_react38.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), -+ (0, import_react38.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · ")) ++ (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_react26.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null -+ entry.code ? (0, import_react38.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null ++ entry.code ? (0, import_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null )), - (0, import_react26.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react39.createElement)( Row3, { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") }, - (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), - (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) ++ (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")) ) ) ]; @@ -19191,8 +18879,8 @@ diff --git a/client.js b/client.js const toast = useUi((state) => state.toast); - const [leaving, setLeaving] = (0, import_react26.useState)(false); - (0, import_react26.useEffect)(() => { -+ const [leaving, setLeaving] = (0, import_react38.useState)(false); -+ (0, import_react38.useEffect)(() => { ++ 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); @@ -19202,19 +18890,19 @@ diff --git a/client.js b/client.js if (!toast) return null; - return (0, import_react26.createElement)( + const info = toast.tone === "info"; -+ return (0, import_react38.createElement)( ++ return (0, import_react39.createElement)( "div", - { className: "bt-toast", role: "alert", "data-leaving": leaving || void 0 }, - (0, import_react26.createElement)("span", { className: "bt-toast-text" }, toast.text), - (0, import_react26.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { + { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 }, -+ (0, import_react38.createElement)("span", { className: "bt-toast-text" }, toast.text), -+ info ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { ++ (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_react26.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react26.createElement)(CloseIcon)) -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react38.createElement)(CloseIcon)) ++ (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)) ); } var FEEDBACK_CSS = ` @@ -19239,7 +18927,7 @@ diff --git a/client.js b/client.js // src/client/about.js -var import_react27 = require("react"); -var CHEER_MS = 1800; -+var import_react39 = require("react"); ++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 DS Bot…"); @@ -19282,9 +18970,9 @@ diff --git a/client.js b/client.js - const [checking, setChecking] = (0, import_react27.useState)(false); - const [stars, setStars] = (0, import_react27.useState)(null); - (0, import_react27.useEffect)(() => { -+ const [checking, setChecking] = (0, import_react39.useState)(false); -+ const [stars, setStars] = (0, import_react39.useState)(null); -+ (0, import_react39.useEffect)(() => { ++ 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); @@ -19316,14 +19004,14 @@ diff --git a/client.js b/client.js const dshNew = showsDshRow(dsh, update.app); const dshShown = dshNew && dsh.available; - const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react27.createElement)( -+ const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react39.createElement)( ++ 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_react27.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_react27.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_react27.createElement)( -+ ) : (0, import_react39.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_react39.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_react39.createElement)( ++ ) : (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") @@ -19331,22 +19019,22 @@ diff --git a/client.js b/client.js const repo = update.repoUrl; return [ - (0, import_react27.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react40.createElement)( "div", { key: "hero", className: "bt-about-hero" }, - (0, import_react27.createElement)("span", { className: "bt-about-mark" }, (0, import_react27.createElement)(BotMark, { look: MAIN_LOOK, bot: { id: "about" }, size: 52, state: markState, live: true, gaze: true, pokeable: true })), - (0, import_react27.createElement)( -+ (0, import_react39.createElement)("span", { className: "bt-about-mark" }, (0, import_react39.createElement)(FlyBotMark, { size: 52 })), -+ (0, import_react39.createElement)( ++ (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_react27.createElement)("span", { className: "bt-about-name" }, "DS Bot"), - update.current ? (0, import_react27.createElement)("span", { className: "bt-about-version" }, t("Version {version}", { version: update.current })) : null -+ (0, import_react39.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), -+ update.current ? (0, import_react39.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null ++ (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_react27.createElement)( -+ repo ? (0, import_react39.createElement)( ++ repo ? (0, import_react40.createElement)( "a", { className: "bt-star", @@ -19360,31 +19048,31 @@ diff --git a/client.js b/client.js }, - (0, import_react27.createElement)("span", { className: "bt-star-main" }, (0, import_react27.createElement)("span", { className: "bt-star-icon" }, (0, import_react27.createElement)(StarLineIcon)), "Star"), - stars === null ? null : (0, import_react27.createElement)("span", { className: "bt-star-count" }, starCount(stars)) -+ (0, import_react39.createElement)("span", { className: "bt-star-main" }, (0, import_react39.createElement)("span", { className: "bt-star-icon" }, (0, import_react39.createElement)(StarLineIcon)), "Star"), -+ stars === null ? null : (0, import_react39.createElement)("span", { className: "bt-star-count" }, starCount(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_react27.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If DS Bot helps you, a star on GitHub helps more people find it.")) : null, - (0, import_react27.createElement)( -+ repo ? (0, import_react39.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_react39.createElement)( ++ 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_react27.createElement)(Row3, { label: "DS Bot", hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react27.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null), - dsh?.current ? (0, import_react27.createElement)(Row3, { label: "DeepSeek Harness", hint: dshHint(dsh, dshShown) }, dshControl) : null -+ (0, import_react39.createElement)(Row3, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react39.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null), -+ dsh?.current ? (0, import_react39.createElement)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null ++ (0, import_react40.createElement)(Row3, { 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)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null ), - (0, import_react27.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react40.createElement)( Section, { key: "links", title: t("Links") }, - repo ? (0, import_react27.createElement)(Row3, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react27.createElement)("a", linkProps(repo), (0, import_react27.createElement)(GitHubMark), t("Open"))) : null, - update.notesUrl ? (0, import_react27.createElement)(Row3, { label: t("Release notes") }, (0, import_react27.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, - (0, import_react27.createElement)(Row3, { label: t("Report a problem") }, (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) -+ repo ? (0, import_react39.createElement)(Row3, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react39.createElement)("a", linkProps(repo), (0, import_react39.createElement)(GitHubMark), t("Open"))) : null, -+ update.notesUrl ? (0, import_react39.createElement)(Row3, { label: t("Release notes") }, (0, import_react39.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, -+ (0, import_react39.createElement)(Row3, { label: t("Report a problem") }, (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) ++ repo ? (0, import_react40.createElement)(Row3, { 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)(Row3, { label: t("Release notes") }, (0, import_react40.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, ++ (0, import_react40.createElement)(Row3, { label: t("Report a problem") }, (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) ) ]; } @@ -19404,24 +19092,25 @@ diff --git a/client.js b/client.js // src/client/settings.js -var import_react28 = require("react"); -+var import_react40 = require("react"); ++var import_react41 = require("react"); var capitalize2 = (word) => word[0].toUpperCase() + word.slice(1); -var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About DS Bot"]]; -+var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; - var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; +-var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; ++var SETTINGS_PAGES = [["general", "General"], ["bots", "Bots"], ["groups", "Group chats"], ["connectors", "Connectors"], ["capabilities", "Capabilities"], ["schedules", "Scheduled tasks"], ["workflows", "Workflows"], ["usage", "Usage"], ["secrets", "Secrets"], ["feedback", "Feedback"], ["about", "About FlyBot"]]; ++var PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; function SetSection({ title, children }) { - return (0, import_react28.createElement)("section", { className: "bt-set-section" }, (0, import_react28.createElement)("h3", null, title), (0, import_react28.createElement)("div", { className: "bt-set-card" }, children)); -+ return (0, import_react40.createElement)("section", { className: "bt-set-section" }, (0, import_react40.createElement)("h3", null, title), (0, import_react40.createElement)("div", { className: "bt-set-card" }, 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 SetRow({ label, hint, children }) { - return (0, import_react28.createElement)( -+ return (0, import_react40.createElement)( ++ return (0, import_react41.createElement)( "div", { className: "bt-set-row" }, - (0, import_react28.createElement)("div", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", null, label), hint ? (0, import_react28.createElement)("span", { className: "bt-set-hint" }, hint) : null), - children ? (0, import_react28.createElement)("div", { className: "bt-set-control" }, children) : null -+ (0, import_react40.createElement)("div", { className: "bt-set-copy" }, (0, import_react40.createElement)("span", null, label), hint ? (0, import_react40.createElement)("span", { className: "bt-set-hint" }, hint) : null), -+ children ? (0, import_react40.createElement)("div", { className: "bt-set-control" }, children) : null ++ (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 ); } +var NOTIFY_KINDS = [ @@ -19436,9 +19125,9 @@ diff --git a/client.js b/client.js - const [custom, setCustom] = (0, import_react28.useState)(prefs.accent ?? "#4d6bfe"); - const timer = (0, import_react28.useRef)(0); - (0, import_react28.useEffect)(() => () => clearTimeout(timer.current), []); -+ const [custom, setCustom] = (0, import_react40.useState)(prefs.accent ?? "#4d6bfe"); -+ const timer = (0, import_react40.useRef)(0); -+ (0, import_react40.useEffect)(() => () => clearTimeout(timer.current), []); ++ 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); @@ -19446,33 +19135,33 @@ diff --git a/client.js b/client.js }; return [ - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetSection, { key: "look", title: t("Appearance") }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetRow, { label: t("Theme"), hint: t("Colors for the whole team, on every device.") }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (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_react28.createElement)("option", { key: id, value: id }, t(theme.label))) -+ Object.entries(themes).map(([id, theme]) => (0, import_react40.createElement)("option", { key: id, value: id }, t(theme.label))) ++ Object.entries(themes).map(([id, theme]) => (0, import_react41.createElement)("option", { key: id, value: id }, t(theme.label))) ) ), - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetRow, { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "div", { className: "bt-swatches" }, - (0, import_react28.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_react28.createElement)("button", { -+ (0, import_react40.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_react40.createElement)("button", { ++ (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", @@ -19481,18 +19170,18 @@ diff --git a/client.js b/client.js onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (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_react28.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) -+ (0, import_react40.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) ++ (0, import_react41.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) ) ) ) ), - (0, import_react28.createElement)( + // One switch for every system notification (#353), then one per kind (#469). -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetSection, - { key: "mode", title: t("Mode") }, - (0, import_react28.createElement)( @@ -19501,14 +19190,14 @@ diff --git a/client.js b/client.js - (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.setSurface("agent") }, t("Switch")) - ) + { key: "notify", title: t("Notifications") }, -+ (0, import_react40.createElement)(SwitchRow, { ++ (0, import_react41.createElement)(SwitchRow, { + label: t("Notifications"), + showLabel: false, + 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_react40.createElement)(SwitchRow, { ++ NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react41.createElement)(SwitchRow, { + key: kind, + label: t(label), + hint: hint ? t(hint) : void 0, @@ -19526,40 +19215,40 @@ diff --git a/client.js b/client.js - return (0, import_react28.createElement)( + const own = roster.bots.filter((bot) => !bot.remote); + const candidates = own.filter((bot) => !isMainOf(roster, bot.id)); -+ return (0, import_react40.createElement)( ++ return (0, import_react41.createElement)( SetSection, { title: t("Main Bot") }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (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_react28.createElement)("select", { -+ (0, import_react40.createElement)("select", { ++ (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_react28.createElement)("option", { value: "" }, t("Choose a Bot…")), roster.bots.map((bot) => (0, import_react28.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) -+ }, first ? null : (0, import_react40.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react40.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ++ }, 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_react28.createElement)( -+ others.map((bot) => (0, import_react40.createElement)( ++ others.map((bot) => (0, import_react41.createElement)( SetRow, { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") }, - (0, import_react28.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_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role")) -+ (0, import_react40.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_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role")) ++ (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_react28.createElement)( -+ candidates.length > 0 ? (0, import_react40.createElement)( ++ 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_react28.createElement)("select", { -+ (0, import_react40.createElement)("select", { ++ (0, import_react41.createElement)("select", { className: "bt-select", "aria-label": t("Add a Main Bot"), value: "", @@ -19567,7 +19256,7 @@ diff --git a/client.js b/client.js if (event.target.value) void run(actions.setMain(event.target.value, true)); } - }, (0, import_react28.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react28.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) -+ }, (0, import_react40.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react40.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ++ }, (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 ); } @@ -19575,15 +19264,15 @@ diff --git a/client.js b/client.js function MotionSection({ roster, actions, run }) { const motion = roster.prefs?.motion === "quiet" || roster.prefs?.motion === "lively" ? roster.prefs.motion : "normal"; - return (0, import_react28.createElement)( -+ return (0, import_react40.createElement)( ++ return (0, import_react41.createElement)( SetSection, { title: t("Animation") }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetRow, { label: t("Bot animation") }, - (0, import_react28.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react28.createElement)("button", { -+ (0, import_react40.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react40.createElement)("button", { ++ (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, @@ -19592,30 +19281,30 @@ diff --git a/client.js b/client.js } function BotsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react28.useState)(false); -+ const [creating, setCreating] = (0, import_react40.useState)(false); ++ const [creating, setCreating] = (0, import_react41.useState)(false); return [ - (0, import_react28.createElement)(MainBotsSection, { key: "main", roster, actions, run }), - (0, import_react28.createElement)(MotionSection, { key: "motion", roster, actions, run }), - creating ? (0, import_react28.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react28.createElement)( -+ (0, import_react40.createElement)(MainBotsSection, { key: "main", roster, actions, run }), -+ (0, import_react40.createElement)(MotionSection, { key: "motion", roster, actions, run }), -+ creating ? (0, import_react40.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react40.createElement)( ++ (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_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react28.createElement)(PlusIcon), t("New Bot")) -+ (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react40.createElement)(PlusIcon), t("New Bot")) ++ (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_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetSection, - { key: "bots", title: t("Bots · {count}", { count: roster.bots.length }) }, - roster.bots.map((bot) => (0, import_react28.createElement)( + { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) }, -+ roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react40.createElement)( ++ 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_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react28.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), @@ -19623,42 +19312,42 @@ diff --git a/client.js b/client.js - bot.role ? (0, import_react28.createElement)("span", { className: "bt-tag" }, bot.role) : null, - isMainOf(roster, bot.id) ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, - bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null -+ (0, import_react40.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), -+ (0, import_react40.createElement)("span", { className: "bt-row-title" }, bot.name), -+ bot.role ? (0, import_react40.createElement)("span", { className: "bt-tag" }, bot.role) : null, -+ isMainOf(roster, bot.id) ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, -+ bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ++ (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_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ (0, import_react40.createElement)("span", { className: "bt-chevron" }, (0, import_react40.createElement)(ChevronRightIcon)) ++ (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) )) ) ]; } function GroupsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react28.useState)(false); -+ const [creating, setCreating] = (0, import_react40.useState)(false); ++ const [creating, setCreating] = (0, import_react41.useState)(false); return [ - creating ? (0, import_react28.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react28.createElement)( -+ creating ? (0, import_react40.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react40.createElement)( ++ 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_react28.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_react28.createElement)(PlusIcon), t("Create group chat")) -+ (0, import_react40.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_react40.createElement)(PlusIcon), t("Create group chat")) ++ (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_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( SetSection, { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) }, - roster.rooms.length === 0 ? (0, import_react28.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) => { -+ roster.rooms.length === 0 ? (0, import_react40.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) => { ++ 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_react28.createElement)( -+ return (0, import_react40.createElement)( ++ return (0, import_react41.createElement)( "button", { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react28.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), @@ -19666,14 +19355,14 @@ diff --git a/client.js b/client.js - (0, import_react28.createElement)("span", { className: "bt-tag" }, t("{count} members", { count: room2.members.length })), - admin ? (0, import_react28.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react28.createElement)(AdminStar), admin.name) : null, - room2.hidden ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null -+ (0, import_react40.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -+ (0, import_react40.createElement)("span", { className: "bt-row-title" }, room2.name), -+ (0, import_react40.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })), -+ admin ? (0, import_react40.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react40.createElement)(AdminStar), admin.name) : null, -+ room2.hidden ? (0, import_react40.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ++ (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_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ (0, import_react40.createElement)("span", { className: "bt-chevron" }, (0, import_react40.createElement)(ChevronRightIcon)) ++ (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) ); }) ) @@ -19682,12 +19371,12 @@ diff --git a/client.js b/client.js function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) { - const [error, setError] = (0, import_react28.useState)(""); - const close = (0, import_react28.useCallback)(() => actions.closeSettings(), []); -+ const [error, setError] = (0, import_react40.useState)(""); -+ const close = (0, import_react40.useCallback)(() => actions.closeSettings(), []); ++ 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(""); -@@ -10779,104 +10779,112 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +10779,113 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -19704,34 +19393,34 @@ diff --git a/client.js b/client.js const pageKey = `${shown}:${bot?.id ?? room2?.id ?? ""}`; const depth = bot || room2 ? 1 : 0; - const last = (0, import_react28.useRef)({ key: pageKey, depth, move: "up" }); -+ const last = (0, import_react40.useRef)({ key: pageKey, depth, move: "up" }); ++ 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_react28.useState)(shown === "usage"); -+ const [usageOpened, setUsageOpened] = (0, import_react40.useState)(shown === "usage"); ++ const [usageOpened, setUsageOpened] = (0, import_react41.useState)(shown === "usage"); if (shown === "usage" && !usageOpened) setUsageOpened(true); - const errorLine = error ? (0, import_react28.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; - return (0, import_react28.createElement)( -+ const errorLine = error ? (0, import_react40.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; -+ return (0, import_react40.createElement)( ++ 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_react28.createElement)("div", { className: "bt-scrim", onMouseDown: close }), - (0, import_react28.createElement)( -+ (0, import_react40.createElement)("div", { className: "bt-scrim", onMouseDown: close }), -+ (0, import_react40.createElement)( ++ (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("DS Bot settings"), "data-wide": shown === "usage" ? "" : void 0 }, - (0, import_react28.createElement)( + { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 }, -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, - (0, import_react28.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - SETTINGS_PAGES.map(([key, text]) => (0, import_react28.createElement)("button", { -+ (0, import_react40.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), -+ pages.map(([key, text]) => (0, import_react40.createElement)("button", { ++ (0, import_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), ++ pages.map(([key, text]) => (0, import_react41.createElement)("button", { key, type: "button", className: "bt-settings-item", @@ -19739,10 +19428,10 @@ diff --git a/client.js b/client.js onClick: () => actions.openSettings(key) - }, (0, import_react28.createElement)(PAGE_ICONS[key]), t(text))), - (0, import_react28.createElement)("button", { -+ }, (0, import_react40.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), ++ }, (0, import_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + // 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_react40.createElement)("button", { ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-item bt-settings-shell", - title: t("Models, providers, and the rest of DeepSeek Harness"), @@ -19753,23 +19442,23 @@ diff --git a/client.js b/client.js openHarnessSettings(); } - }, (0, import_react28.createElement)(SlidersIcon), t("Harness settings")) -+ }, (0, import_react40.createElement)(SlidersIcon), t("Harness settings"), (0, import_react40.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ++ }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ), - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "div", { className: "bt-settings-main" }, - (0, import_react28.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react41.createElement)( "div", { className: "bt-settings-head" }, - back ? (0, import_react28.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react28.createElement)(ChevronLeftIcon)) : null, - (0, import_react28.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) -+ back ? (0, import_react40.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react40.createElement)(ChevronLeftIcon)) : null, -+ (0, import_react40.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) ++ 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_react28.createElement)( -+ shown === "usage" ? null : (0, import_react40.createElement)( ++ shown === "usage" ? null : (0, import_react41.createElement)( "div", { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, errorLine, @@ -19784,41 +19473,42 @@ diff --git a/client.js b/client.js - shown === "secrets" ? (0, import_react28.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, - shown === "feedback" ? (0, import_react28.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, - shown === "about" ? (0, import_react28.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null -+ shown === "general" ? (0, import_react40.createElement)(GeneralPage, { roster, actions, run }) : null, -+ bot ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, -+ shown === "bots" && !bot ? (0, import_react40.createElement)(BotsPage, { roster, actions, run }) : null, -+ room2 ? (0, import_react40.createElement)("div", { className: "bt-settings-form" }, (0, import_react40.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, -+ shown === "groups" && !room2 ? (0, import_react40.createElement)(GroupsPage, { roster, actions, run }) : null, -+ shown === "connectors" ? (0, import_react40.createElement)(ConnectorsPage, { roster, actions }) : null, -+ pluginPage ? (0, import_react40.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, -+ shown === "capabilities" ? (0, import_react40.createElement)(CapabilitiesPage, { roster, actions, run }) : null, -+ shown === "schedules" ? (0, import_react40.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, -+ shown === "secrets" ? (0, import_react40.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, -+ shown === "feedback" ? (0, import_react40.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, -+ shown === "about" ? (0, import_react40.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null ++ 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 === "connectors" ? (0, import_react41.createElement)(ConnectorsPage, { roster, actions }) : null, ++ shown === "workflows" ? (0, import_react41.createElement)(WorkflowsPage, { actions }) : 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_react28.createElement)( -+ usageOpened ? (0, import_react40.createElement)( ++ 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_react28.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) -+ (0, import_react40.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ++ (0, import_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ) : null ), - (0, import_react28.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react28.createElement)(CloseIcon)) -+ (0, import_react40.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react40.createElement)(CloseIcon)) ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ) ); } // src/client/context-menu.js -var import_react29 = require("react"); -+var import_react41 = require("react"); ++var import_react42 = require("react"); function ContextMenu({ menu, roster, actions, leaving = false }) { useEscape(() => actions.closeMenu()); - (0, import_react29.useEffect)(() => { -+ (0, import_react41.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu(); }; @@ -19829,15 +19519,15 @@ diff --git a/client.js b/client.js - const [place, setPlace] = (0, import_react29.useState)({ left: menu.x, top: menu.y }); - (0, import_react29.useLayoutEffect)(() => { + const structure = useSidebarStructure(); -+ const ref = (0, import_react41.useRef)(null); -+ const [place, setPlace] = (0, import_react41.useState)({ left: menu.x, top: menu.y }); -+ (0, import_react41.useLayoutEffect)(() => { ++ 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_react29.createElement)(AgentMenu, { menu, roster, actions, leaving }); -+ if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react41.createElement)(AgentMenu, { menu, roster, actions, leaving }); ++ 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; @@ -19880,18 +19570,18 @@ diff --git a/client.js b/client.js - return (0, import_react29.createElement)( + ]; + const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); -+ return (0, import_react41.createElement)( ++ 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 }, - groups.map((group, index) => (0, import_react29.createElement)( - import_react29.Fragment, -+ shown.map((group, index) => (0, import_react41.createElement)( -+ import_react41.Fragment, ++ shown.map((group, index) => (0, import_react42.createElement)( ++ import_react42.Fragment, { key: index }, - index > 0 ? (0, import_react29.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react29.createElement)("button", { -+ index > 0 ? (0, import_react41.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, danger]) => (0, import_react41.createElement)("button", { ++ 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, type: "button", role: "menuitem", @@ -19900,7 +19590,7 @@ diff --git a/client.js b/client.js void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } - }, (0, import_react29.createElement)(Icon), label)) -+ }, (0, import_react41.createElement)(Icon), label)) ++ }, (0, import_react42.createElement)(Icon), label)) )) ); } @@ -19908,9 +19598,9 @@ diff --git a/client.js b/client.js - const ref = (0, import_react29.useRef)(null); - const [place, setPlace] = (0, import_react29.useState)({ left: menu.x, top: menu.y }); - (0, import_react29.useLayoutEffect)(() => { -+ const ref = (0, import_react41.useRef)(null); -+ const [place, setPlace] = (0, import_react41.useState)({ left: menu.x, top: menu.y }); -+ (0, import_react41.useLayoutEffect)(() => { ++ 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)) }); @@ -19919,18 +19609,18 @@ diff --git a/client.js b/client.js ] ]; - return (0, import_react29.createElement)( -+ return (0, import_react41.createElement)( ++ 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_react29.createElement)( - import_react29.Fragment, -+ groups.map((group, index) => (0, import_react41.createElement)( -+ import_react41.Fragment, ++ groups.map((group, index) => (0, import_react42.createElement)( ++ import_react42.Fragment, { key: index }, - index > 0 ? (0, import_react29.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react29.createElement)("button", { -+ index > 0 ? (0, import_react41.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, danger]) => (0, import_react41.createElement)("button", { ++ 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, type: "button", role: "menuitem", @@ -19939,7 +19629,7 @@ diff --git a/client.js b/client.js void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } - }, (0, import_react29.createElement)(Icon), label)) -+ }, (0, import_react41.createElement)(Icon), label)) ++ }, (0, import_react42.createElement)(Icon), label)) )) ); } @@ -20301,7 +19991,7 @@ diff --git a/client.js b/client.js // src/client/details-drawer.js -var import_react31 = require("react"); -+var import_react42 = require("react"); ++var import_react43 = require("react"); function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; if (!Array.isArray(outline)) return ""; @@ -20310,11 +20000,11 @@ diff --git a/client.js b/client.js var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); function PermissionRow({ bot, roster, actions, run }) { - const [pending, setPending] = (0, import_react31.useState)(null); -+ const [pending, setPending] = (0, import_react42.useState)(null); ++ const [pending, setPending] = (0, import_react43.useState)(null); const catalog = roster.permissions; const saved = bot.permission ?? catalog?.defaultPreset; - (0, import_react31.useEffect)(() => { -+ (0, import_react42.useEffect)(() => { ++ (0, import_react43.useEffect)(() => { if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; @@ -20323,17 +20013,17 @@ diff --git a/client.js b/client.js void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; - return (0, import_react31.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react43.createElement)( "div", { className: "bt-drawer-permission" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Permissions")), - (0, import_react31.createElement)( -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Permissions")), -+ (0, import_react42.createElement)( ++ (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_react31.createElement)("button", { -+ catalog.options.map((option) => (0, import_react42.createElement)("button", { ++ catalog.options.map((option) => (0, import_react43.createElement)("button", { key: option.value, type: "button", "aria-pressed": option.value === current, @@ -20342,56 +20032,56 @@ diff --git a/client.js b/client.js const state = useBrowserTabs((map) => map[bot.id] ?? EMPTY_TABS); const pages = useBrowserPages((map) => map); - (0, import_react31.useEffect)(() => { -+ (0, import_react42.useEffect)(() => { ++ (0, import_react43.useEffect)(() => { actions.browserEnsure?.(bot.id); }, [bot.id]); if (roster.browser !== true || !actions.browserShow) { - return (0, import_react31.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); -+ return (0, import_react42.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); ++ 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_react31.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react43.createElement)( "div", { className: "bt-browser-empty-mark" }, - (0, import_react31.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), - (0, import_react31.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), - (0, import_react31.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_react31.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) -+ (0, import_react42.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), -+ (0, import_react42.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), -+ (0, import_react42.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_react42.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) ++ (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_react31.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react43.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_react31.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react43.createElement)( "div", { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } }, - (0, import_react31.createElement)( -+ (0, import_react42.createElement)( ++ (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_react31.createElement)( -+ page.excerpt ? (0, import_react42.createElement)( ++ page.excerpt ? (0, import_react43.createElement)( "span", { className: "bt-bcard-preview" }, - (0, import_react31.createElement)("span", { className: "bt-bcard-ptitle" }, label), - (0, import_react31.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) - ) : (0, import_react31.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react31.createElement)(GlobeIcon), (0, import_react31.createElement)("span", null, label)) -+ (0, import_react42.createElement)("span", { className: "bt-bcard-ptitle" }, label), -+ (0, import_react42.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) -+ ) : (0, import_react42.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react42.createElement)(GlobeIcon), (0, import_react42.createElement)("span", null, label)) ++ (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_react31.createElement)("button", { -+ (0, import_react42.createElement)("button", { ++ (0, import_react43.createElement)("button", { type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), @@ -20400,43 +20090,43 @@ diff --git a/client.js b/client.js } }, "×"), - (0, import_react31.createElement)( -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( "div", { className: "bt-bcard-row" }, - (0, import_react31.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), - (0, import_react31.createElement)("span", { className: "bt-bcard-title" }, label), - page.url ? (0, import_react31.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null -+ (0, import_react42.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), -+ (0, import_react42.createElement)("span", { className: "bt-bcard-title" }, label), -+ page.url ? (0, import_react42.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null ++ (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_react31.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react31.createElement)(PlusIcon), t("New tab")) -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react42.createElement)(PlusIcon), t("New tab")) ++ (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")) ); } function About({ text, title = "About" }) { - const [open, setOpen] = (0, import_react31.useState)(false); -+ const [open, setOpen] = (0, import_react42.useState)(false); ++ const [open, setOpen] = (0, import_react43.useState)(false); const long = text.split("\n").length > 5 || text.length > 240; - const body = (0, import_react31.useRef)(null); -+ const body = (0, import_react42.useRef)(null); ++ const body = (0, import_react43.useRef)(null); useMorph(body, open); - return (0, import_react31.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react43.createElement)( + "div", + null, -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t(title)), -+ (0, import_react42.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -+ long ? (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : 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 }, text), ++ long ? (0, import_react43.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_react42.useState)(room2.pendingApprovals ?? []); -+ const [busy, setBusy] = (0, import_react42.useState)(""); ++ 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_react42.useEffect)(() => { ++ (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; @@ -20462,31 +20152,31 @@ diff --git a/client.js b/client.js + setBusy(""); + } + }; -+ return (0, import_react42.createElement)( ++ return (0, import_react43.createElement)( + "div", + { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react42.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), -+ items.map((item) => (0, import_react42.createElement)( ++ (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_react42.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) ++ (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_react42.createElement)( ++ return (0, import_react43.createElement)( "div", null, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t(title)), - (0, import_react31.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), - long ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null -+ (0, import_react42.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), -+ (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react42.createElement)(FacePersonRow, { ++ (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, @@ -20512,31 +20202,31 @@ diff --git a/client.js b/client.js return [ - room2.notice ? (0, import_react31.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react31.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - (0, import_react31.createElement)( -+ (0, import_react42.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), -+ invited && room2.sharedFrom && !face ? (0, import_react42.createElement)( ++ (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_react42.createElement)("div", { className: "bt-section-title" }, title), -+ (0, import_react42.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_react42.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) -+ ) : face ? null : room2.notice ? (0, import_react42.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react42.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), -+ face ? null : (0, import_react42.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), -+ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react42.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react42.createElement)( ++ (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_react31.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length })), - (0, import_react31.createElement)("div", { className: "bt-members", role: "list" }, members.map((member) => { - const entry = entries2.find((item) => item.id === member.id); - return (0, import_react31.createElement)( -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), -+ (0, import_react42.createElement)("div", { className: "bt-members", role: "list" }, [ -+ ...humans.map((person) => (0, import_react42.createElement)( ++ (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: member.id, role: "listitem" }, - (0, import_react31.createElement)( + { key: person.id, role: "listitem" }, -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( "div", { className: "bt-member" }, - (0, import_react31.createElement)( @@ -20544,70 +20234,70 @@ diff --git a/client.js b/client.js - { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) }, - (0, import_react31.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), - (0, import_react31.createElement)("span", null, member.name) -+ (0, import_react42.createElement)("span", { className: "bt-member-name" }, (0, import_react42.createElement)("span", null, person.displayName || person.id)), -+ (0, import_react42.createElement)("span", { className: "bt-chip" }, t("Human")), -+ person.role === "admin" ? (0, import_react42.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ++ (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_react42.createElement)( ++ ...remotes.map((person) => (0, import_react43.createElement)( + "div", + { key: person.handle || person.id, role: "listitem" }, -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( + "span", + { className: "bt-member-name" }, -+ (0, import_react42.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), -+ (0, import_react42.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) ++ (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)) ), - room2.admin === member.id ? (0, import_react31.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null - ), - entry ? (0, import_react31.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null - ); - })) -+ face ? null : (0, import_react42.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ++ 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_react42.createElement)( ++ return (0, import_react43.createElement)( + "div", + { key: member.id, role: "listitem" }, -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react42.createElement)( ++ (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_react42.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), -+ (0, import_react42.createElement)("span", null, member.name) ++ (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_react42.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ++ room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + ), -+ entry ? (0, import_react42.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null ++ entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null + ); + }) + ]) ), - (0, import_react31.createElement)( -+ face ? null : (0, import_react42.createElement)( ++ face ? null : (0, import_react43.createElement)( "div", { key: "mode" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Who answers")), - (0, import_react31.createElement)("div", { className: "bt-kv" }, (0, import_react31.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Who answers")), -+ (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) ++ (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"))) ), - usage ? (0, import_react31.createElement)( -+ face || !usage ? null : (0, import_react42.createElement)( ++ face || !usage ? null : (0, import_react43.createElement)( "div", { key: "usage" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react31.createElement)("div", { className: "bt-kv" }, (0, import_react31.createElement)("span", null, room2.name), (0, import_react31.createElement)("span", null, usage)) - ) : null -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Usage")), -+ (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, room2.name), (0, import_react42.createElement)("span", null, 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)) + ) ]; } @@ -20616,21 +20306,21 @@ diff --git a/client.js b/client.js const part = owner ? currentPart(owner) : sessionId; const isMainBot = isMainOf(roster, owner?.id); - const [tab, setTab] = (0, import_react31.useState)("details"); -+ const [tab, setTab] = (0, import_react42.useState)("details"); ++ const [tab, setTab] = (0, import_react43.useState)("details"); const room2 = roster.roomsById[sessionId]; - const [view, setView] = (0, import_react31.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); - const [editing, setEditing] = (0, import_react31.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); - const [draftName, setDraftName] = (0, import_react31.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); - const [copied, setCopied] = (0, import_react31.useState)(false); - const [error, setError] = (0, import_react31.useState)(""); -+ const [view, setView] = (0, import_react42.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); -+ const [editing, setEditing] = (0, import_react42.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); -+ const [draftName, setDraftName] = (0, import_react42.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); -+ const [copied, setCopied] = (0, import_react42.useState)(false); -+ const [error, setError] = (0, import_react42.useState)(""); ++ 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_react31.useRef)({ view, tab, move: void 0 }); -+ const last = (0, import_react42.useRef)({ view, tab, move: void 0 }); ++ 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; @@ -20640,56 +20330,56 @@ diff --git a/client.js b/client.js }; - const errorLine = error ? (0, import_react31.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; - if ((bot || room2) && view === "settings") { -+ const errorLine = error ? (0, import_react42.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; ++ 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_react31.createElement)( -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( "div", { key: "head", className: "bt-subhead", "data-move": move }, - (0, import_react31.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react31.createElement)(ChevronLeftIcon)), - (0, import_react31.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), - hasStrip ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react31.createElement)(CloseIcon)) -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react42.createElement)(ChevronLeftIcon)), -+ (0, import_react42.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), -+ hasStrip ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react42.createElement)(CloseIcon)) ++ (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_react31.createElement)( -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( "div", { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, errorLine, - bot ? (0, import_react31.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react31.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) -+ bot ? (0, import_react42.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react42.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ++ 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_react31.createElement)( -+ const top = (0, import_react42.createElement)( ++ const top = (0, import_react43.createElement)( "div", { className: "bt-drawer-top", "data-move": side }, - bot || room2 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? t("Edit Bot") : t("Group settings"), title: bot ? t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => setView("settings") }, (0, import_react31.createElement)(GearIcon)) : null, - bot && roster.browser === true && actions.openBrowser ? (0, import_react31.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_react31.createElement)(GlobeIcon)) : null, - bot || room2 ? (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react31.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react31.createElement)(ShareIcon)) : null, - hasStrip ? null : (0, import_react31.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react31.createElement)(CloseIcon)) -+ bot || room2 ? (0, import_react42.createElement)("button", { ++ 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_react42.createElement)(GearIcon)) : null, -+ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react42.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_react42.createElement)(GlobeIcon)) : null, -+ bot || room2 ? (0, import_react42.createElement)("button", { type: "button", className: "bt-round", "aria-label": copied ? t("Copied conversation") : t("Share conversation"), title: copied ? t("Copied") : t("Copy conversation as Markdown"), onClick: share }, copied ? (0, import_react42.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react42.createElement)(ShareIcon)) : null, -+ hasStrip ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react42.createElement)(CloseIcon)) ++ }, (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", "aria-label": copied ? t("Copied conversation") : t("Share 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)) ); if (!bot && !room2) { return [ - (0, import_react31.createElement)(import_react31.Fragment, { key: "top" }, top), - (0, import_react31.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react31.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) -+ (0, import_react42.createElement)(import_react42.Fragment, { key: "top" }, top), -+ (0, import_react42.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react42.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) ++ (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."))) ]; } const current = bot?.name ?? room2.name; @@ -20699,14 +20389,14 @@ diff --git a/client.js b/client.js return [ - (0, import_react31.createElement)(import_react31.Fragment, { key: "top" }, top), - (0, import_react31.createElement)( -+ (0, import_react42.createElement)(import_react42.Fragment, { key: "top" }, top), -+ (0, import_react42.createElement)( ++ (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_react31.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - editing ? (0, import_react31.createElement)("input", { -+ bot ? (0, import_react42.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react42.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), -+ bot?.remote ? (0, import_react42.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react42.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react42.createElement)("input", { ++ 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, @@ -20715,82 +20405,82 @@ diff --git a/client.js b/client.js } } - }) : (0, import_react31.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { -+ }) : (0, import_react42.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { ++ }) : (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_react31.createElement)("span", { className: "bt-drawer-role" }, subtitle) : null -+ subtitle ? (0, import_react42.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react42.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ++ 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 ), - (0, import_react31.createElement)( -+ bot?.remote ? null : (0, import_react42.createElement)( ++ bot?.remote ? null : (0, import_react43.createElement)( "div", { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side }, - (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - tabs.map((key) => (0, import_react31.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key]))) -+ (0, import_react42.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ tabs.map((key) => (0, import_react42.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)("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_react31.createElement)( -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)( "div", { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move }, errorLine, - room2 && tab === "details" ? (0, import_react31.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, - bot && tab === "details" ? [ - (0, import_react31.createElement)( -+ room2 && tab === "details" ? (0, import_react42.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, ++ 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_react42.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, -+ (0, import_react42.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) ++ 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_react42.createElement)( ++ (0, import_react43.createElement)( "div", { key: "model", className: "bt-drawer-model" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Model")), - (0, import_react31.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), - modelOf(bot, roster) ? null : (0, import_react31.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name })) -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Model")), -+ (0, import_react42.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), -+ modelOf(bot, roster) ? null : (0, import_react42.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name })) ++ (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_react31.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), - (0, import_react31.createElement)( -+ (0, import_react42.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), -+ (0, import_react42.createElement)( ++ (0, import_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), ++ (0, import_react43.createElement)( "div", { key: "folder", className: "bt-drawer-model" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Working folder")), - (0, import_react31.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) }) -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Working folder")), -+ (0, import_react42.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) }) ++ (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_react31.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react31.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), - roster.memory === false ? null : (0, import_react31.createElement)(MemoryRow, { key: "memory", bot, actions }), - usage ? (0, import_react31.createElement)( -+ bot.instructions ? (0, import_react42.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react42.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), -+ roster.memory === false ? null : (0, import_react42.createElement)(MemoryRow, { key: "memory", bot, actions }), -+ usage ? (0, import_react42.createElement)( ++ 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_react31.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react31.createElement)("div", { className: "bt-kv" }, (0, import_react31.createElement)("span", null, bot.name), (0, import_react31.createElement)("span", null, usage)) -+ (0, import_react42.createElement)("div", { className: "bt-section-title" }, t("Usage")), -+ (0, import_react42.createElement)("div", { className: "bt-kv" }, (0, import_react42.createElement)("span", null, bot.name), (0, import_react42.createElement)("span", null, 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_react31.createElement)(ScheduleSummary, { key: "routines", bot, actions }) -+ (0, import_react42.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ++ (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ] : null, - bot && tab === "browser" ? (0, import_react31.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null -+ bot && !bot.remote && tab === "browser" ? (0, import_react42.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ++ bot && !bot.remote && tab === "browser" ? (0, import_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } // src/client/browser-pane.js -var import_react32 = require("react"); -+var import_react43 = require("react"); ++var import_react44 = require("react"); var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; @@ -20807,11 +20497,11 @@ diff --git a/client.js b/client.js -var icon = (paths) => function BrowserIcon() { - return (0, import_react32.createElement)( +var icon2 = (paths) => function BrowserIcon() { -+ return (0, import_react43.createElement)( ++ 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_react32.createElement)("path", { key: index, d })) -+ paths.map((d, index) => (0, import_react43.createElement)("path", { key: index, d })) ++ paths.map((d, index) => (0, import_react44.createElement)("path", { key: index, d })) ); }; -var GlobeIcon = icon(["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"]); @@ -20834,9 +20524,9 @@ diff --git a/client.js b/client.js - const [failed, setFailed] = (0, import_react32.useState)(null); - if (!src || failed === src) return (0, import_react32.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react32.createElement)(GlobeIcon)); - return (0, import_react32.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); -+ const [failed, setFailed] = (0, import_react43.useState)(null); -+ if (!src || failed === src) return (0, import_react43.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react43.createElement)(GlobeIcon)); -+ return (0, import_react43.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); ++ 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 savedWidth = () => { try { @@ -20859,11 +20549,11 @@ diff --git a/client.js b/client.js - const spot = (0, import_react32.useRef)(null); - const under = (0, import_react32.useRef)(null); - (0, import_react32.useLayoutEffect)(() => { -+ const wrap = (0, import_react43.useRef)(null); -+ const ind = (0, import_react43.useRef)(null); -+ const spot = (0, import_react43.useRef)(null); -+ const under = (0, import_react43.useRef)(null); -+ (0, import_react43.useLayoutEffect)(() => { ++ 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; @@ -20872,7 +20562,7 @@ diff --git a/client.js b/client.js }; }); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { @@ -20881,15 +20571,15 @@ diff --git a/client.js b/client.js return () => observer.disconnect(); }, []); - return (0, import_react32.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react44.createElement)( "div", { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") }, - (0, import_react32.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react44.createElement)( "div", { className: "bt-ptabs-in", ref: wrap }, - (0, import_react32.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react44.createElement)( "button", { type: "button", @@ -20899,14 +20589,14 @@ diff --git a/client.js b/client.js }, - (0, import_react32.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), - view === "details" ? (0, import_react32.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null -+ (0, import_react43.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), -+ view === "details" ? (0, import_react43.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null ++ (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_react32.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react44.createElement)( "button", { key: tabId, @@ -20917,9 +20607,9 @@ diff --git a/client.js b/client.js - (0, import_react32.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }), - (0, import_react32.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)), - (0, import_react32.createElement)("span", { -+ (0, import_react43.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }), -+ (0, import_react43.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)), -+ (0, import_react43.createElement)("span", { ++ (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"), @@ -20928,13 +20618,13 @@ diff --git a/client.js b/client.js ); }), - (0, import_react32.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) -+ (0, import_react43.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) ++ (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_react32.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react32.createElement)(PlusTabIcon)), - (0, import_react32.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_react32.createElement)(CloseIcon2)) -+ (0, import_react43.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react43.createElement)(PlusTabIcon)), -+ (0, import_react43.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_react43.createElement)(CloseIcon2)) ++ (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)) ); } function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) { @@ -20942,12 +20632,12 @@ diff --git a/client.js b/client.js - const frame = (0, import_react32.useRef)(null); - const input = (0, import_react32.useRef)(null); - const [draft, setDraft] = (0, import_react32.useState)(null); -+ const frame = (0, import_react43.useRef)(null); -+ const input = (0, import_react43.useRef)(null); -+ const [draft, setDraft] = (0, import_react43.useState)(null); ++ 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_react32.useLayoutEffect)(() => { -+ (0, import_react43.useLayoutEffect)(() => { ++ (0, import_react44.useLayoutEffect)(() => { const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); @@ -20956,7 +20646,7 @@ diff --git a/client.js b/client.js }; }, [tabId, showGuest, leaving]); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); @@ -20965,19 +20655,19 @@ diff --git a/client.js b/client.js const editing = draft !== null; const secure = /^https:/i.test(page.url); - return (0, import_react32.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react44.createElement)( "div", { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) }, - (0, import_react32.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react44.createElement)( "div", { className: "bt-browser-bar" }, - (0, import_react32.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_react32.createElement)(BackIcon)), - (0, import_react32.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_react32.createElement)(ForwardIcon)), - (0, import_react32.createElement)("button", { -+ (0, import_react43.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_react43.createElement)(BackIcon)), -+ (0, import_react43.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_react43.createElement)(ForwardIcon)), -+ (0, import_react43.createElement)("button", { ++ (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, @@ -20986,14 +20676,14 @@ diff --git a/client.js b/client.js onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload") - }, (0, import_react32.createElement)(page.loading ? StopIcon : ReloadIcon)), - (0, import_react32.createElement)( -+ }, (0, import_react43.createElement)(page.loading ? StopIcon : ReloadIcon)), -+ (0, import_react43.createElement)( ++ }, (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_react32.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react32.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), - (0, import_react32.createElement)("input", { -+ (0, import_react43.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react43.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), -+ (0, import_react43.createElement)("input", { ++ (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, @@ -21002,34 +20692,34 @@ diff --git a/client.js b/client.js }) ), - page.loading ? (0, import_react32.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null -+ page.loading ? (0, import_react43.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ++ page.loading ? (0, import_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ), - (0, import_react32.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react44.createElement)( "div", { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 }, - page.error ? (0, import_react32.createElement)( -+ page.error ? (0, import_react43.createElement)( ++ page.error ? (0, import_react44.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react32.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), - (0, import_react32.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), - page.url ? (0, import_react32.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => { -+ (0, import_react43.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), -+ (0, import_react43.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), -+ page.url ? (0, import_react43.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => { ++ (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_react32.createElement)( -+ ) : !page.url ? (0, import_react43.createElement)( ++ ) : !page.url ? (0, import_react44.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react32.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }), - (0, import_react32.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), - (0, import_react32.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 })) -+ (0, import_react43.createElement)(BotMark, { bot, size: 48, 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-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 })) ++ (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 ) ); @@ -21045,36 +20735,36 @@ diff --git a/client.js b/client.js - const [viewport, setViewport] = (0, import_react32.useState)(() => window.innerWidth); - const [dragging, setDragging] = (0, import_react32.useState)(false); - const [resizing, setResizing] = (0, import_react32.useState)(false); -+ const root = (0, import_react43.useRef)(null); -+ const fly = (0, import_react43.useRef)(null); -+ const [landed, setLanded] = (0, import_react43.useState)(false); -+ const [settling, setSettling] = (0, import_react43.useState)(false); -+ const [width, setWidth] = (0, import_react43.useState)(savedWidth); -+ const [viewport, setViewport] = (0, import_react43.useState)(() => window.innerWidth); -+ const [dragging, setDragging] = (0, import_react43.useState)(false); -+ const [resizing, setResizing] = (0, import_react43.useState)(false); ++ 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_react32.useState)(false); -+ const [reading, setReading] = (0, import_react43.useState)(false); ++ const [reading, setReading] = (0, import_react44.useState)(false); const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport)); - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react43.useLayoutEffect)(() => { ++ (0, import_react44.useLayoutEffect)(() => { if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH); }, [view, width]); - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react43.useLayoutEffect)(() => { ++ (0, import_react44.useLayoutEffect)(() => { chatRoom.hold(instant); }, [instant]); - const [last, setLast] = (0, import_react32.useState)({ view, width: panelWidth }); -+ const [last, setLast] = (0, import_react43.useState)({ view, width: panelWidth }); ++ 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_react32.useRef)(view); - (0, import_react32.useLayoutEffect)(() => { -+ const shownView = (0, import_react43.useRef)(view); -+ (0, import_react43.useLayoutEffect)(() => { ++ 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); @@ -21082,12 +20772,12 @@ diff --git a/client.js b/client.js if (switched) chatRoom.hold(false); }, [view, panelWidth, leaving]); - (0, import_react32.useEffect)(() => () => { -+ (0, import_react43.useEffect)(() => () => { ++ (0, import_react44.useEffect)(() => () => { chatRoom.set(null); chatRoom.hold(false); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { let timer; const onResize = () => { setResizing(true); @@ -21096,34 +20786,34 @@ diff --git a/client.js b/client.js }; }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (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_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { const timer = setTimeout(() => setLanded(true), 360); return () => clearTimeout(timer); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { if (canBrowse) actions.browserEnsure(bot.id); }, [bot?.id, canBrowse]); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details"); }, [view, active, tabState.ids.length]); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react44.useEffect)(() => { if (view !== "browser" || leaving) void actions.browserShow?.(null); }, [view, leaving]); - (0, import_react32.useEffect)(() => () => { -+ (0, import_react43.useEffect)(() => () => { ++ (0, import_react44.useEffect)(() => () => { void actions.browserShow?.(null); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (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); @@ -21132,7 +20822,7 @@ diff --git a/client.js b/client.js }; } - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react43.useLayoutEffect)(() => { ++ (0, import_react44.useLayoutEffect)(() => { const move = fly.current; if (!move) return; fly.current = null; @@ -21141,7 +20831,7 @@ diff --git a/client.js b/client.js }; const hasStrip = canBrowse && tabState.ids.length > 0; - return (0, import_react32.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react44.createElement)( "aside", { ref: root, @@ -21150,19 +20840,19 @@ diff --git a/client.js b/client.js } }, - leaving ? null : (0, import_react32.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }), -+ leaving ? null : (0, import_react43.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }), ++ 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_dom8.createPortal)((0, import_react32.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null, - hasStrip ? (0, import_react32.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null, - view === "browser" && bot ? (0, import_react32.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react32.createElement)( -+ dragging ? (0, import_react_dom8.createPortal)((0, import_react43.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null, -+ hasStrip ? (0, import_react43.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null, -+ view === "browser" && bot ? (0, import_react43.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react43.createElement)( ++ dragging ? (0, import_react_dom8.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)( "div", { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" }, - (0, import_react32.createElement)(DetailsDrawer, { -+ (0, import_react43.createElement)(DetailsDrawer, { ++ (0, import_react44.createElement)(DetailsDrawer, { sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, @@ -21171,7 +20861,7 @@ diff --git a/client.js b/client.js `; +// src/client/remote-bot-settings.js -+var import_react44 = require("react"); ++var import_react45 = require("react"); +var REMOTE_BOT_SETTINGS_CSS = ` +.bt-remote-sheet-layer{position:fixed;inset:0;z-index:60;display:flex;align-items:flex-end;justify-content:center;padding:16px;pointer-events:none} +.bt-remote-sheet-layer[data-leaving]{pointer-events:none} @@ -21195,8 +20885,8 @@ diff --git a/client.js b/client.js +function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) { + const close = () => actions.closeSettings(); + useEscape(close); -+ const dialog = (0, import_react44.useRef)(null); -+ (0, import_react44.useEffect)(() => { ++ const dialog = (0, import_react45.useRef)(null); ++ (0, import_react45.useEffect)(() => { + const before = document.activeElement; + dialog.current?.focus?.({ preventScroll: true }); + return () => { @@ -21211,30 +20901,30 @@ diff --git a/client.js b/client.js + }; + if (!bot?.remote) return null; + const who = bot.remote.homeName || bot.remote.owner || bot.remote.home || t("Friend's Bot"); -+ return (0, import_react44.createElement)( ++ return (0, import_react45.createElement)( + "div", + { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" }, -+ (0, import_react44.createElement)("div", { className: "bt-scrim", onMouseDown: close }), -+ (0, import_react44.createElement)( ++ (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }), ++ (0, import_react45.createElement)( + "div", + { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown }, -+ (0, import_react44.createElement)( ++ (0, import_react45.createElement)( + "div", + { className: "bt-remote-sheet-head" }, -+ (0, import_react44.createElement)(BotMark, { bot, size: 40, live: true }), -+ (0, import_react44.createElement)( ++ (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }), ++ (0, import_react45.createElement)( + "div", + { className: "bt-remote-sheet-who" }, -+ (0, import_react44.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), -+ (0, import_react44.createElement)("span", { className: "bt-remote-sheet-sub" }, 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_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react44.createElement)(CloseIcon)) ++ (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_react44.createElement)( ++ (0, import_react45.createElement)( + "div", + { className: "bt-remote-sheet-body" }, -+ bot.remote.handle ? (0, import_react44.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, -+ (0, import_react44.createElement)(FlyBotDetails, { bot, roster, actions }) ++ 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 }) + ) + ) + ); @@ -21242,7 +20932,7 @@ diff --git a/client.js b/client.js + // src/client/overlays.js -var import_react33 = require("react"); -+var import_react45 = require("react"); ++var import_react46 = require("react"); var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; @@ -21252,8 +20942,8 @@ diff --git a/client.js b/client.js const current = useSessions((list) => Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0)?.id ?? null); - const previous = (0, import_react33.useRef)(current); - (0, import_react33.useEffect)(() => { -+ const previous = (0, import_react45.useRef)(current); -+ (0, import_react45.useEffect)(() => { ++ const previous = (0, import_react46.useRef)(current); ++ (0, import_react46.useEffect)(() => { if (previous.current === current) return; previous.current = current; if (current) actions.followPanel?.(current); @@ -21261,8 +20951,8 @@ diff --git a/client.js b/client.js const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null; - const [kept, setKept] = (0, import_react33.useState)(open); - (0, import_react33.useEffect)(() => { -+ const [kept, setKept] = (0, import_react45.useState)(open); -+ (0, import_react45.useEffect)(() => { ++ const [kept, setKept] = (0, import_react46.useState)(open); ++ (0, import_react46.useEffect)(() => { if (open) { setKept(open); return void 0; @@ -21278,8 +20968,8 @@ diff --git a/client.js b/client.js - import_react33.Fragment, + const [inbox, inboxLeaving] = useLinger(ui2.inbox ?? null, DIALOG_EXIT_MS); + const [board, boardLeaving] = useLinger(ui2.board ?? null, DIALOG_EXIT_MS); -+ return (0, import_react45.createElement)( -+ import_react45.Fragment, ++ return (0, import_react46.createElement)( ++ import_react46.Fragment, null, - pane ? (0, import_react_dom9.createPortal)((0, import_react33.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null, - ui2.newChat ? (0, import_react_dom9.createPortal)((0, import_react33.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, @@ -21289,17 +20979,17 @@ diff --git a/client.js b/client.js - menu ? (0, import_react_dom9.createPortal)((0, import_react33.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, - settings ? (0, import_react_dom9.createPortal)((0, import_react33.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, - memory && roster.byId[memory] ? (0, import_react_dom9.createPortal)((0, import_react33.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null -+ pane ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(BotPanel, { pane, leaving: open === null, roster, actions, useSessions, useSessionStatus, useActivity, useWork, useBrowserTabs, useBrowserPages }), document.body) : null, -+ ui2.newChat ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, -+ ui2.palette ? (0, import_react_dom9.createPortal)((0, import_react45.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_dom9.createPortal)((0, import_react45.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, -+ ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, -+ menu ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(ContextMenu, { key: `${menu.kind ?? ""}:${menu.id}:${menu.x}:${menu.y}`, menu, roster, actions, leaving: menuLeaving }), document.body) : null, -+ settings ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, -+ remoteBotSettings ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(RemoteBotSettingsSheet, { key: remoteBotSettings, bot: roster.byId[remoteBotSettings], roster, actions, leaving: remoteLeaving }), document.body) : null, -+ memory && roster.byId[memory] ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, -+ inbox ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, -+ board ? (0, import_react_dom9.createPortal)((0, import_react45.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ++ pane ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, ++ ui2.palette ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, ++ ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, ++ menu ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, ++ remoteBotSettings ? (0, import_react_dom9.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_dom9.createPortal)((0, import_react46.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, ++ inbox ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, ++ board ? (0, import_react_dom9.createPortal)((0, import_react46.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); } @@ -21507,7 +21197,7 @@ diff --git a/client.js b/client.js // src/client/user-message.js -var import_react34 = require("react"); -+var import_react46 = require("react"); ++var import_react47 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { @@ -21594,37 +21284,37 @@ diff --git a/client.js b/client.js - (0, import_react34.createElement)(BotMark, { bot: part.bot, size: 16 }), - part.label - ) : (0, import_react34.createElement)(import_react34.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references))); -+ const chip = (part) => [(0, import_react46.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; ++ const chip = (part) => [(0, import_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + return userTextParts(text, bots, referenceLabels).map((part, index) => { -+ if (!part.bot) return (0, import_react46.createElement)(import_react46.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); ++ if (!part.bot) return (0, import_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + const title = `@${part.bot.name}`; -+ return typeof part.bot.id === "string" ? (0, import_react46.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react46.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); ++ return typeof part.bot.id === "string" ? (0, import_react47.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react47.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); + }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; - return (0, import_react34.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { -+ return (0, import_react46.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { ++ return (0, import_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { if (block.type === "image") { - return renderMessageImages ? (0, import_react34.createElement)(import_react34.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; -+ return renderMessageImages ? (0, import_react46.createElement)(import_react46.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; ++ return renderMessageImages ? (0, import_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; } const file = block.attachment; const meta = [ui.fileExtension?.(file.name)?.toUpperCase().slice(0, 8), ui.fileSizeText?.(file.bytes)].filter(Boolean).join(" · "); - return (0, import_react34.createElement)( -+ return (0, import_react46.createElement)( ++ return (0, import_react47.createElement)( "span", { key: index, className: "bt-ufile", title: file.name }, - ui.FileTypeIcon ? (0, import_react34.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, - (0, import_react34.createElement)("span", { className: "bt-ufile-text" }, (0, import_react34.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react34.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) -+ ui.FileTypeIcon ? (0, import_react46.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, -+ (0, import_react46.createElement)("span", { className: "bt-ufile-text" }, (0, import_react46.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react46.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ++ ui.FileTypeIcon ? (0, import_react47.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, ++ (0, import_react47.createElement)("span", { className: "bt-ufile-text" }, (0, import_react47.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react47.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ); })); } function CopyButton({ text }) { - const [copied, setCopied] = (0, import_react34.useState)(false); -+ const [copied, setCopied] = (0, import_react46.useState)(false); ++ const [copied, setCopied] = (0, import_react47.useState)(false); const copy2 = () => { void navigator.clipboard?.writeText(text).then(() => { setCopied(true); @@ -21632,7 +21322,7 @@ diff --git a/client.js b/client.js }); }; - return (0, import_react34.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react34.createElement)(CheckIcon) : (0, import_react34.createElement)(CopyIcon)); -+ return (0, import_react46.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react46.createElement)(CheckIcon) : (0, import_react46.createElement)(CopyIcon)); ++ return (0, import_react47.createElement)("button", { type: "button", className: "bt-tool", "aria-label": copied ? t("Copied") : t("Copy"), title: copied ? t("Copied") : t("Copy"), onClick: copy2 }, copied ? (0, import_react47.createElement)(CheckIcon) : (0, import_react47.createElement)(CopyIcon)); } var clock3 = (time) => typeof time === "number" ? new Date(time).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" }) : void 0; function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageImages, openFile, openSkill }) { @@ -21648,7 +21338,7 @@ diff --git a/client.js b/client.js + const person = incoming && author.authorKind === "human" && !author.ownerName; + const faceMine = Boolean(room2?.origin === "face-to-face" && !incoming); + const myName = faceMine ? facePeople(room2, roster).find((entry) => entry.mine)?.name || cleanPeerName(roster.self?.displayName, "") || author?.name || "" : ""; -+ return (0, import_react46.createElement)( ++ return (0, import_react47.createElement)( "div", - { className: "bt-umsg", "data-steering": steering || void 0 }, - (0, import_react34.createElement)( @@ -21660,28 +21350,28 @@ diff --git a/client.js b/client.js + "data-author-kind": author?.authorKind, + "data-author-name": author?.name + }, -+ person ? (0, import_react46.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react46.createElement)(PersonMark, { name: author.name })) : null, -+ faceMine ? (0, import_react46.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react46.createElement)(PersonMark, { name: myName || void 0 })) : null, -+ (0, import_react46.createElement)( ++ person ? (0, import_react47.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react47.createElement)(PersonMark, { name: author.name })) : null, ++ faceMine ? (0, import_react47.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react47.createElement)(PersonMark, { name: myName || void 0 })) : null, ++ (0, import_react47.createElement)( "div", { className: "bt-ustack" }, - attachments.length > 0 ? (0, import_react34.createElement)(Attachments, { attachments, renderMessageImages }) : null, - text !== "" || rest.length > 0 ? (0, import_react34.createElement)( -+ faceMine && myName ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react46.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react46.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, -+ attachments.length > 0 ? (0, import_react46.createElement)(Attachments, { attachments, renderMessageImages }) : null, -+ text !== "" || rest.length > 0 ? (0, import_react46.createElement)( ++ faceMine && myName ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react47.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react47.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, ++ attachments.length > 0 ? (0, import_react47.createElement)(Attachments, { attachments, renderMessageImages }) : null, ++ text !== "" || rest.length > 0 ? (0, import_react47.createElement)( "div", { className: "bt-ubody", title: clock3(data.time) }, - (0, import_react34.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), - rest.map((block, index) => ui.JsonBlock ? (0, import_react34.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) -+ (0, import_react46.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), -+ rest.map((block, index) => ui.JsonBlock ? (0, import_react46.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ++ (0, import_react47.createElement)(UserText, { text, bots: mentionableBots(roster, sessionId, { fromFriend: Boolean(incoming && author.ownerName) }), referenceLabels, skillNames: data.skillNames ?? [], references, actions }), ++ rest.map((block, index) => ui.JsonBlock ? (0, import_react47.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ) : null, - referenceLabels.length > 0 ? (0, import_react34.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null -+ referenceLabels.length > 0 ? (0, import_react46.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ++ referenceLabels.length > 0 ? (0, import_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ), - text === "" ? null : (0, import_react34.createElement)("div", { className: "bt-utools" }, (0, import_react34.createElement)(CopyButton, { text })) -+ text === "" ? null : (0, import_react46.createElement)("div", { className: "bt-utools" }, (0, import_react46.createElement)(CopyButton, { text })) ++ text === "" ? null : (0, import_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text })) ); } var USER_MESSAGE_CSS = ` @@ -21783,7 +21473,7 @@ diff --git a/client.js b/client.js +} + +// src/client/stats-dock.js -+var import_react47 = require("react"); ++var import_react48 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; +var STATS_ID = "stats"; +var STATS_IDS = [STATS_ID, "activity"]; @@ -21799,17 +21489,17 @@ diff --git a/client.js b/client.js +function statsDock(entriesOf, id = STATS_ID) { + const shell = () => (entriesOf(STATS_DOCK) ?? []).find((entry) => entry?.component !== StatsDock && entry?.options?.id === id); + function StatsDock(props) { -+ const t2 = (0, import_react47.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); ++ const t2 = (0, import_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); + const entry = shell(); + if (!entry || typeof props.usePerformanceUsage !== "function") return null; -+ return (0, import_react47.createElement)(entry.component, { ...props, t: t2 }); ++ return (0, import_react48.createElement)(entry.component, { ...props, t: t2 }); + } + return { component: StatsDock, inject: (...args) => shell()?.inject?.(...args) ?? {} }; } +var statsDocks = (entriesOf) => STATS_IDS.map((id) => ({ id, ...statsDock(entriesOf, id) })); // src/client/index.js -+var import_react48 = require("react"); ++var import_react49 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { + const flybot = createClientApi2(); @@ -22077,6 +21767,15 @@ diff --git a/client.js b/client.js actions.signInEntry = () => ctx.slots.entries?.("settings.models.sign-in")?.[0] ?? null; actions.canSignIn = () => Boolean(actions.signInEntry()); actions.signIn = () => ui2.set((state) => ({ ...state, signIn: (state.signIn ?? 0) + 1 })); +@@ -13922,6 +13922,8 @@ function apply(ctx) { + wakeUpdate(); + }; + actions.repoStars = () => call("repo-stars"); ++ actions.workflows = (payload) => call("workflows", payload); ++ actions.workflowSave = (payload) => call("workflow-save", payload); + actions.noteTurnTime = (sessionId, turn, key, time) => { + if (sessionId === void 0 || !Number.isFinite(turn) || !Number.isFinite(time)) return; + turnClock.set((state) => { @@ -13964,7 +13964,13 @@ function apply(ctx) { }); const browserPages = browsers?.pages ?? createSource({}); @@ -22227,7 +21926,7 @@ diff --git a/client.js b/client.js ]; + ctx.effect(() => { + const style = installStyles(TURN_FAILURES_CSS, "turn-failures"); -+ const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react48.createElement)(TurnFailures, { ...props, work }) }); ++ const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react49.createElement)(TurnFailures, { ...props, work }) }); + return () => { + off(); + style.remove(); diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index c93465a36..608a25572 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -249,7 +249,12 @@ diff --git a/src/client/secret-card.js b/src/client/secret-card.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -5,10 +5,11 @@ import { allThemes } from './themes.js' +@@ -2,13 +2,16 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're + import { ACCENT_INKS, INKS } from './inks.js' + import { isMainOf } from './sources.js' + import { allThemes } from './themes.js' ++import { WorkflowIcon } from './icons.js' ++import { WorkflowsPage } from './workflows.js' import { CloseIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, ConnectorIcon, InfoIcon, PlugIcon, PlusIcon, SlidersIcon, SETTINGS_ICONS } from './icons.js' import { AdminStar, BotAvatar, RoomAvatar } from './mark.js' import { openHarnessSettings } from './sidebar.js' @@ -263,15 +268,17 @@ diff --git a/src/client/settings.js b/src/client/settings.js import { CreateBot } from './create-bot.js' import { KeyIcon, SecretsPage } from './secret-card.js' import { UsagePage } from './usage-page.js' -@@ -23,7 +23,7 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) +@@ -23,8 +23,8 @@ const capitalize = word => word[0].toUpperCase() + word.slice(1) // Settings dialog: a 198px page list beside the page. Bots and group chats each list // their members, and one opens on its own settings form. Usage widens the dialog. -const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About DS Bot']] -+const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] - const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } +-const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } ++const SETTINGS_PAGES = [['general', 'General'], ['bots', 'Bots'], ['groups', 'Group chats'], ['connectors', 'Connectors'], ['capabilities', 'Capabilities'], ['schedules', 'Scheduled tasks'], ['workflows', 'Workflows'], ['usage', 'Usage'], ['secrets', 'Secrets'], ['feedback', 'Feedback'], ['about', 'About FlyBot']] ++const PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, workflows: WorkflowIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon } function SetSection({ title, children }) { + return h('section', { className: 'bt-set-section' }, h('h3', null, title), h('div', { className: 'bt-set-card' }, children)) @@ -35,6 +35,13 @@ function SetRow({ label, hint, children }) { children ? h('div', { className: 'bt-set-control' }, children) : null) } @@ -388,10 +395,11 @@ diff --git a/src/client/settings.js b/src/client/settings.js h('div', { className: 'bt-settings-main' }, h('div', { className: 'bt-settings-head' }, back ? h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': back, title: back, onClick: () => actions.openSettings(shown) }, h(ChevronLeftIcon)) : null, -@@ -203,6 +203,7 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -203,6 +203,8 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = room ? h('div', { className: 'bt-settings-form' }, h(GroupSettingsForm, { key: room.id, room, roster, actions, run })) : null, shown === 'groups' && !room ? h(GroupsPage, { roster, actions, run }) : null, shown === 'connectors' ? h(ConnectorsPage, { roster, actions }) : null, ++ shown === 'workflows' ? h(WorkflowsPage, { actions }) : null, + pluginPage ? h(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call, run, t, Section: SetSection, Row: SetRow } }) : null, shown === 'capabilities' ? h(CapabilitiesPage, { roster, actions, run }) : null, shown === 'schedules' ? h(SchedulesPage, { roster, actions, start: id ?? undefined }) : null, @@ -440,7 +448,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js import { CHECKPOINT_PROMPT, STEWARD_SYSTEM, checkpointCount, checkpointTail, checkpointTailNote, checkpointText, linesFor, parseDecision, stewardRequest } from './steward.js' import { clip, createClock, escapeRegExp, estimateTokens, splitRef, textOf } from './text.js' -@@ -40,9 +40,9 @@ +@@ -40,9 +40,9 @@ export function cardText(args) { // What one event adds to a Bot's chat history: an answer of the Bot's (its text, or a // question card), or a message from the user (`who: 'user'`) or another Bot // (`who: 'bot'`) that the answers after it belong to. Context that other plugins append @@ -453,7 +461,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js // The user's answer to an ask_user card. The message carries the question with the // answer, so the answer still makes sense after a checkpoint drops the card. export function questionReply(question, answer) { -@@ -121,6 +121,20 @@ +@@ -121,6 +121,20 @@ export function canonicalHeadings(note, names) { return lines.join('\n') } @@ -474,7 +482,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js export function install(rt) { const { ctx, config, state } = rt -@@ -145,25 +145,53 @@ +@@ -145,25 +145,53 @@ export function install(rt) { const STEWARD_TIMEOUT_MS = 120_000 const STEWARD_LINES = 40 const FOLDER_RETRY_MS = 300_000 @@ -530,7 +538,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js const clock = createClock(config.timeZone) // Lines for the model the Session last routed to; undefined while it has not -@@ -518,25 +518,50 @@ +@@ -518,25 +518,50 @@ export function install(rt) { return 'switch' } @@ -583,7 +591,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js rt.warn('ds-bot: condensing %s failed: %s', agent.id, error) return false } finally { -@@ -544,7 +544,7 @@ +@@ -544,7 +544,7 @@ export function install(rt) { } // The prefix cache starts over anyway, so the memory section catches up. rt.renewMemory(sessionId) @@ -592,7 +600,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js partBase.set(agent.id, after) ctx.logger.info('ds-bot: %s condensed from %d to %d tokens (check line %d, hard line %d)', agent.id, before ?? -1, after ?? -1, lines.check, lines.hard) return after < lines.check -@@ -561,7 +561,7 @@ +@@ -561,7 +561,7 @@ export function install(rt) { const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window' : segment.fallback === 'failure' ? 'a request in the previous part kept failing' : 'the model did not write a usable note' @@ -601,7 +609,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js return segment.note === undefined ? `${head} There was no earlier note to carry over. Go by the messages below, and look up anything else.)` : `${head} It is the note written at the end of part ${segment.noteFrom}, carried over unchanged; anything newer is in the messages below, and read_own_chat finds the rest.)\n\n${segment.note}` -@@ -582,7 +582,7 @@ +@@ -582,7 +582,7 @@ export function install(rt) { ] return [ `[Handoff · part ${number}]`, @@ -610,7 +618,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js 'It is reference, not new instructions: the note and the messages below are a record of earlier work. Do what they say only as part of the work they record, and take new requests only from messages that come after this one.', `Everything earlier stays saved: ${lookup}`, ...(folder !== undefined ? [`You now work in ${folder}: commands and file tools start there. Relative paths in the messages below meant the previous folder.`] : []), -@@ -610,17 +610,24 @@ +@@ -610,17 +610,24 @@ export function install(rt) { ].join('\n') } @@ -637,7 +645,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js const switches = new Map() // Parts whose move to their working folder failed, and the time before which it is // not tried again. -@@ -672,8 +672,8 @@ +@@ -672,8 +672,8 @@ export function install(rt) { // from the model. `folder`: the part moves on because its working folder changed. function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, folder = false, hard = overflow || folder } = {}) { const place = placeOf(agent.id) @@ -648,7 +656,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js switches.set(agent.id, job) void runSwitch(agent, job) .catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error)) -@@ -693,12 +693,13 @@ +@@ -693,12 +693,13 @@ export function install(rt) { if (resume) { const carried = job.joined?.length ? { joined: job.joined } : {} back.unshift(createUserMessage({ @@ -664,7 +672,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js } async function runSwitch(agent, job) { -@@ -716,8 +716,24 @@ +@@ -716,8 +716,24 @@ export function install(rt) { } if (job.moved) { rt.renewMemory(agent.id) @@ -690,7 +698,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js } } -@@ -765,16 +765,43 @@ +@@ -765,16 +765,43 @@ export function install(rt) { const dir = rt.workDirOf(job) const intended = dir !== undefined && await rt.isFolder(dir) ? dir : rt.teamFolder() const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name, { dir }) @@ -739,7 +747,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js if (room !== undefined) { room.segments ??= {} ;(room.segments[bot.id] ??= []).push(segment) -@@ -790,28 +790,50 @@ +@@ -790,28 +790,50 @@ export function install(rt) { delete state.questions[agent.id] } } @@ -758,12 +766,6 @@ diff --git a/src/host/parts.js b/src/host/parts.js - if (intended !== undefined && cwd !== undefined && cwd !== intended) { - folderRetry.set(freshId, Date.now() + FOLDER_RETRY_MS) - rt.warn('ds-bot: the fresh part of %s did not start in %s; it works in %s', bot.name, intended, cwd) -- } -- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder }) -- freshParts.set(freshId, estimateTokens(text)) -- fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) })) -- await ctx.sessions.flush(fresh.session) -- // Then what the old part held: the stopped turn's input, then whatever waited. + try { + await rt.save() + } catch (error) { @@ -792,7 +794,12 @@ diff --git a/src/host/parts.js b/src/host/parts.js + await rt.save().catch(() => { /* the in-memory state is the old part; a failed rollback save is the worst case */ }) + await ctx.workspaceRegistry.archiveSession(freshId).catch(() => { /* best effort */ }) + throw error -+ } + } +- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare, folder }) +- freshParts.set(freshId, estimateTokens(text)) +- fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) })) +- await ctx.sessions.flush(fresh.session) +- // Then what the old part held: the stopped turn's input, then whatever waited. + // The earlier part may not be archived while this move still holds its tasks (#896), so + // the switch keeps the promise and waits for it before archiving; the handoff goes on now. + job.scheduleMove = rt.moveSchedules(agent.id, freshId) @@ -809,7 +816,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js ctx.logger.info('ds-bot: %s%s moved on to part %d at %d tokens (check line %d, hard line %d); %s; %d latest and %d earlier messages carried%s', bot.name, room ? ` in ${room.name}` : '', number + 1, tokens ?? -1, lines?.check ?? -1, lines?.hard ?? -1, written !== undefined ? `note written in ${written.attempts} attempt(s)` : `note put together by DS Bot (${noted.fallback}${noted.note ? `, carries part ${noted.noteFrom}'s` : ', nothing to carry'})`, carried.recent.length, carried.earlier.length, folder === undefined ? '' : `; now works in ${folder}`) -@@ -840,16 +840,16 @@ +@@ -840,16 +840,16 @@ export function install(rt) { freshParts.delete(agent.id) partBase.set(agent.id, (tokensOf(agent.session) ?? 0) + fresh) } @@ -830,7 +837,7 @@ diff --git a/src/host/parts.js b/src/host/parts.js // A switch may have started while the checks above awaited. const running = switches.get(agent.id) if (running !== undefined) { -@@ -858,8 +858,11 @@ +@@ -858,8 +858,11 @@ export function install(rt) { } const midTurn = payload.step > 1 const entry = rt.liveOf(agent.id) diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index eb5d5e227..1a8e49919 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -359,16 +359,47 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on // One repository's columns (#1265 step 4). The card key carries the repository, so the same issue // number in two repositories is two cards and neither is mistaken for the other. -function DeliveryColumns({ view, repo = null }) { +function DeliveryColumns({ view, repo = null, placed = null }) { const columns = view?.columns ?? {} + // A card a workflow section already draws is not drawn again here (#1272 step 3): the section owns + // it, and the five legacy columns keep every card that names no workflow. + const drawn = placed === null || placed === undefined ? null : new Set(placed) + const cards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) return h('div', { className: 'bt-del-columns' }, stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, h('h3', { className: 'bt-board-column-head' }, h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), - (columns[column.id] ?? []).length === 0 + h('span', { className: 'bt-board-count' }, String(cards(column.id).length))), + cards(column.id).length === 0 ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) + : cards(column.id).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) +} + +// The columns a workflow definition draws (#1272 step 3, acceptance 1 and 2): one block per workflow, +// the columns in the definition's own order, and every header naming the workflow it belongs to — so +// two workflows never share a column and a reader can always tell whose column it is. +function DeliveryWorkflowSections({ sections, repo = null }) { + const list = Array.isArray(sections) ? sections : [] + if (list.length === 0) return null + return h('div', { className: 'bt-del-workflows' }, list.map(section => h('section', { + key: section.id, className: 'bt-del-workflow', 'data-workflow': section.id, + }, + h('h3', { className: 'bt-del-workflow-name' }, + h('span', null, section.name), + h('span', { className: 'bt-wf-path' }, `workflows/${section.id}.json`)), + h('div', { className: 'bt-del-columns' }, section.columns.map(column => h('section', { + key: column.id, className: 'bt-del-column', 'data-column': column.id, 'data-workflow': section.id, + }, + h('h4', { className: 'bt-board-column-head' }, + h('span', null, column.label), + h('span', { className: 'bt-del-workflow-tag' }, section.name), + h('span', { className: 'bt-board-count' }, String(column.cards.length))), + column.cards.length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : column.cards.map(card => h(DeliveryCard, { key: `${section.id}-${repo ?? card.repo ?? ''}-${card.kind}-${card.number}`, card, repo: repo ?? card.repo ?? null }))))), + (section.unplaced ?? []).length > 0 + ? h('p', { className: 'bt-del-hint' }, t('{count} card(s) name a node this workflow draws no column for.', { count: section.unplaced.length })) + : null))) } // A repository that cannot be read keeps its block and says so; the readable ones are drawn beside @@ -383,7 +414,7 @@ function DeliveryRepoBlock({ entry }) { ? h('p', { className: 'bt-del-block-state', role: 'status' }, state === 'loading' ? loadingText(entry?.elapsedMs) : String(entry?.error?.message ?? t('The delivery board is not ready yet.')), entry?.error ? ` · ${hintFor(entry.error, { repo: entry.repo })}` : '') - : h(DeliveryColumns, { view: entry, repo: entry.repo })) + : h(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed })) } export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { @@ -443,8 +474,13 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet ? h('p', { className: 'bt-del-partial', role: 'status' }, missing.map(entry => missingText(entry)).join(' · ')) : null, h(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), + // A workflow's own columns come first: a card that names a workflow is drawn there and not in + // the five columns below, which keep every card that names none (#1272 step 3). + entries === null + ? h(DeliveryWorkflowSections, { sections: payload.workflows, repo: repo ?? payload.repo ?? null }) + : null, entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed }) : h('div', { className: 'bt-del-repos' }, entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) } diff --git a/patches/ds-bot/files/src/client/workflows.js b/patches/ds-bot/files/src/client/workflows.js new file mode 100644 index 000000000..a40cda9bb --- /dev/null +++ b/patches/ds-bot/files/src/client/workflows.js @@ -0,0 +1,228 @@ +// The Workflows page (#1272 step 3): the factory presets, the owner's own workflows, which one the +// project uses, and the node table that edits one. The page is a reader of the engine: the columns a +// definition draws, the flowchart's layout and the sentence a guard would print all come from +// `shared/workflow*.js` through the host, so nothing here re-decides what a workflow is. +// +// Three things the page is careful about: +// - a preset is copied, never edited: its fields are read-only and `Customize` is the only button +// that changes one into something editable; +// - a guard row shows the sentence this definition would print right now, or says the fact could +// not be read — a row with no sentence means the guard was read and holds, never a guess; +// - the flowchart is drawn from the table, so a box per node and an edge per edge row are what the +// page shows, not a second copy of the definition. +import { createElement as h, useEffect, useState } from 'react' +import { t } from './i18n.js' +import { WorkflowIcon } from './icons.js' + +// What `inputs` / `outputs` are: one comma-separated line, the spelling the definition itself uses. +export function parseList(text) { + return String(text ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +export const formatList = (list) => (Array.isArray(list) ? list.join(', ') : '') + +// A guard as the page writes it: `spec is value` — `check:tests-ok is fresh`, `verdict is equals PASS`. +// The id and the per-guard refusal are kept from the guard that was already there, so editing a +// value does not silently drop the sentence the definition refuses with. +export function guardLines(guards) { + return (Array.isArray(guards) ? guards : []) + .map((guard) => `${guard.spec} is ${guard.is}${guard.value === null || guard.value === undefined ? '' : ` ${guard.value}`}`) + .join('\n') +} + +export function parseGuards(text, previous = []) { + return String(text ?? '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const found = /^(\S+)\s+is\s+(\S+)(?:\s+(.+))?$/.exec(line) + if (found === null) return null + const [, spec, is, value] = found + const before = previous.find((guard) => guard.spec === spec && guard.is === is) + return { + id: before?.id ?? `${spec}-${is}`, + spec, + is, + value: value === undefined ? null : value.trim(), + refusal: before?.refusal ?? '', + } + }) + .filter(Boolean) +} + +// One field of one node, from a text input. The node id is not one of them: changing it would leave +// every edge naming a node that no longer exists, and a definition that cannot be drawn is exactly +// what the save refuses. +export function applyNodeField(definition, nodeId, field, value) { + if (definition === null || definition === undefined) return definition + return { + ...definition, + nodes: definition.nodes.map((node) => { + if (node.id !== nodeId) return node + if (field === 'inputs' || field === 'outputs') return { ...node, [field]: parseList(value) } + if (field === 'guards') return { ...node, guards: parseGuards(value, node.guards) } + return { ...node, [field]: value } + }), + } +} + +const field = (label, value, onChange, options = {}) => h('label', { className: 'bt-wf-field', key: label }, + h('span', { className: 'bt-wf-label' }, label), + h('input', { + className: 'bt-wf-input', value: value ?? '', disabled: options.disabled === true, + placeholder: options.placeholder ?? '', onChange: (event) => onChange(event.target.value), + })) + +const guardState = (row) => (row.state === 'holds' ? t('holds') : row.state === 'unreadable' ? t('cannot read') : t('refuses')) + +function Flow({ layout }) { + if (!layout || !Array.isArray(layout.boxes) || layout.boxes.length === 0) return null + return h('svg', { + className: 'bt-wf-flow', viewBox: `0 0 ${Math.max(layout.width, 1)} ${Math.max(layout.height, 1)}`, + role: 'img', 'aria-label': t('The flowchart, drawn from the node table'), + 'data-boxes': String(layout.boxes.length), 'data-edges': String(layout.edges.length), + }, + layout.edges.map((edge, index) => h('line', { + key: `e${index}`, className: 'bt-wf-edge', x1: edge.points?.[0]?.x ?? 0, y1: edge.points?.[0]?.y ?? 0, + x2: edge.points?.[1]?.x ?? 0, y2: edge.points?.[1]?.y ?? 0, + })), + layout.boxes.map((box) => h('g', { key: box.id, className: 'bt-wf-box', 'data-node': box.id, transform: `translate(${box.x} ${box.y})` }, + h('rect', { width: box.width, height: box.height, rx: 12 }), + h('text', { x: 14, y: 26, className: 'bt-wf-box-name' }, box.name), + h('text', { x: 14, y: 48, className: 'bt-wf-box-role' }, box.role), + h('text', { x: 14, y: 72, className: 'bt-wf-box-rails' }, `${box.inputs.join(', ') || '—'} → ${box.outputs.join(', ') || '—'}`)))) +} + +function Preview({ preview }) { + if (!preview) return null + return h('div', { className: 'bt-wf-preview', 'data-facts': preview.facts }, + preview.facts === 'unavailable' + ? h('div', { className: 'bt-wf-hint' }, t('This host could not read the facts, so these guards say what they could not read — none of them is a pass.')) + : null, + (preview.rows ?? []).map((row) => h('div', { key: row.id, className: 'bt-wf-guard', 'data-state': row.state }, + h('span', { className: 'bt-wf-badge', 'data-state': row.state }, guardState(row)), + h('span', { className: 'bt-wf-guard-spec' }, `${row.spec} is ${row.is}${row.value === null || row.value === undefined ? '' : ` ${row.value}`}`), + row.sentence ? h('span', { className: 'bt-wf-guard-line' }, row.sentence) : null))) +} + +export function WorkflowsPage({ actions }) { + const [list, setList] = useState(null) + const [opened, setOpened] = useState(null) + const [draft, setDraft] = useState(null) + const [node, setNode] = useState(null) + const [preview, setPreview] = useState(null) + const [error, setError] = useState('') + const [saved, setSaved] = useState('') + + const load = () => Promise.resolve(actions.workflows({ op: 'list' })) + .then((value) => { setList(value); return value }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + useEffect(() => { load() }, []) + + const open = (id, target) => Promise.resolve(actions.workflows({ op: 'open', id, node: target ?? null })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('Could not open this workflow')); return value } + setOpened(value) + setDraft(value.workflow.definition) + setNode(value.preview?.node ?? value.workflow.definition.nodes[0]?.id ?? null) + setPreview(value.preview) + setError('') + setSaved('') + return value + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + const showNode = (id) => { + setNode(id) + if (!opened) return + Promise.resolve(actions.workflows({ op: 'preview', id: opened.workflow.id, node: id })) + .then((value) => setPreview(value?.preview ?? null)) + .catch((failure) => setError(failure?.message ?? String(failure))) + } + + const save = () => Promise.resolve(actions.workflowSave({ op: 'save', definition: draft })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('This definition cannot be saved')); return value } + setSaved(t('Saved workflows/{id}.json', { id: value.id })) + setError('') + return load().then(() => open(value.id, node)) + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + + const customize = () => { + if (!opened) return null + const base = opened.workflow.id + const id = `${base}-copy` + return Promise.resolve(actions.workflowSave({ op: 'customize', from: base, id, name: `${opened.workflow.name} (mine)` })) + .then((value) => { + if (value?.ok === false) { setError(value.message ?? t('This definition cannot be copied')); return value } + return load().then(() => open(id)) + }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + } + + const useDefault = () => { + if (!opened) return null + return Promise.resolve(actions.workflowSave({ op: 'default', id: opened.workflow.id })) + .then((value) => { if (value?.ok !== false) setList(value); return value }) + .catch((failure) => { setError(failure?.message ?? String(failure)); return null }) + } + + const editable = opened?.workflow.editable === true + const current = draft?.nodes?.find((entry) => entry.id === node) ?? null + + return h('div', { className: 'bt-wf-page' }, + h('div', { className: 'bt-wf-head' }, + h('h3', null, h(WorkflowIcon), t('Workflows')), + h('span', { className: 'bt-wf-hint' }, t('A workflow is a file: workflows/.json. Columns, the flowchart and the refusals are all drawn from it.'))), + error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null, + saved ? h('div', { className: 'bt-wf-saved', role: 'status' }, saved) : null, + h('div', { className: 'bt-wf-list' }, + ['presets', 'own'].map((group) => h('div', { key: group, className: 'bt-wf-group' }, + h('span', { className: 'bt-wf-group-name' }, group === 'presets' ? t('Factory presets') : t('Yours')), + (list?.[group] ?? []).map((entry) => h('button', { + key: `${group}:${entry.id}`, type: 'button', className: 'bt-wf-pick', + 'aria-current': opened?.workflow.id === entry.id ? 'true' : undefined, + 'data-id': entry.id, 'data-source': entry.source, + onClick: () => open(entry.id), + }, entry.name, list?.default === entry.id ? h('span', { className: 'bt-wf-default' }, t('project default')) : null)))), + list === null ? h('span', { className: 'bt-wf-hint' }, t('Reading workflows…')) : null), + opened === null ? null : h('div', { className: 'bt-wf-doc' }, + h('div', { className: 'bt-wf-doc-head' }, + h('strong', null, opened.workflow.name), + h('span', { className: 'bt-wf-path' }, `workflows/${opened.workflow.id}.json`), + h('button', { type: 'button', className: 'bt-wf-action', onClick: customize }, t('Customize')), + editable ? h('button', { type: 'button', className: 'bt-wf-action', onClick: save }, t('Save')) : null, + h('button', { type: 'button', className: 'bt-wf-action', onClick: useDefault }, t('Use for this project'))), + h(Flow, { layout: opened.layout }), + h('div', { className: 'bt-wf-table' }, + h('div', { className: 'bt-wf-row bt-wf-row-head' }, + ['id', 'name', 'role', 'inputs', 'outputs', 'guards', 'refusal'].map((key) => h('span', { key }, key))), + (draft?.nodes ?? []).map((entry) => h('div', { + key: entry.id, className: 'bt-wf-row', 'data-node': entry.id, + 'aria-current': entry.id === node ? 'true' : undefined, + onClick: () => showNode(entry.id), + }, + h('span', { className: 'bt-wf-cell bt-wf-id' }, entry.id), + editable ? field('name', entry.name, (value) => setDraft(applyNodeField(draft, entry.id, 'name', value))) : h('span', null, entry.name), + editable ? field('role', entry.role, (value) => setDraft(applyNodeField(draft, entry.id, 'role', value))) : h('span', null, entry.role), + editable ? field('inputs', formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, 'inputs', value))) : h('span', null, formatList(entry.inputs)), + editable ? field('outputs', formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, 'outputs', value))) : h('span', null, formatList(entry.outputs)), + editable + ? h('textarea', { className: 'bt-wf-guards-input', value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, 'guards', event.target.value)) }) + : h('span', null, guardLines(entry.guards)), + editable ? field('refusal', entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, 'refusal', value))) : h('span', null, entry.refusal)))), + h('div', { className: 'bt-wf-edges' }, + h('span', { className: 'bt-wf-group-name' }, t('Edges')), + (draft?.edges ?? []).map((edge) => h('span', { key: `${edge.from}->${edge.to}`, className: 'bt-wf-edge-row' }, `${edge.from} → ${edge.to}`))), + h('div', { className: 'bt-wf-node' }, + h('span', { className: 'bt-wf-group-name' }, node === null ? t('Guards') : t('Guards of {node}', { node: current?.name ?? node })), + current === null || current.guards.length === 0 + ? h('span', { className: 'bt-wf-hint' }, t('This node has no guards, so nothing refuses it but its inputs.')) + : h(Preview, { preview })))) +} diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index d2cce743c..74148bdba 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -60,6 +60,9 @@ const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss const TASK_ENDPOINTS = new Set([ 'tasks', 'task-create', 'task-update', 'task-accept', 'task-start', 'task-return', 'task-accept-result', 'task-send-back', 'task-cancel', 'task-wait-input', 'task-answer-input', + // Saving a workflow definition writes `workflows/.json` in the workspace (#1272 step 3): the + // write needs a signed-in actor, and it is an explicit entry rather than a `task-*` prefix. + 'workflow-save', ]) const deny = (code, message) => ({ ok: false, code, message }) @@ -399,7 +402,7 @@ export function install(rt) { if (NO_ACTOR_ALLOWED.has(ep) || ep.startsWith('auth-')) return ALLOW if (ep === 'state' || ep === 'models' || ep === 'efforts' || ep === 'repo-stars' || ep === 'update-check' || ep === 'diagnostics' || ep === 'diagnostics-log' || ep === 'usage' - || ep === 'delivery-board' || ep === 'delivery-probe') return ALLOW + || ep === 'delivery-board' || ep === 'delivery-probe' || ep === 'workflows') return ALLOW return deny('bot/not-found', `Unknown endpoint ${ep}`) } diff --git a/patches/ds-bot/files/src/host/delivery-board.js b/patches/ds-bot/files/src/host/delivery-board.js index 3958dc752..5310e339d 100644 --- a/patches/ds-bot/files/src/host/delivery-board.js +++ b/patches/ds-bot/files/src/host/delivery-board.js @@ -12,6 +12,7 @@ // to do about it. "No GitHub connection yet" is reserved for a host that has never tried. import { repoSlug } from './update.js' import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' +import { boardSections } from '../shared/workflow-board.js' export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] @@ -118,12 +119,22 @@ const verdictOf = (comments) => { // A delivery card is a GitHub issue or pull request, never a Bot task record (#1173 keeps the // track_tasks board separate). export function cardOf(row, { kind, linked = null, owner = null, badge = null, sha = null } = {}) { + const labels = orderLabels(labelNames(row)) + // Which workflow and which node the card is in (#1272 step 3): two labels, the same shape as every + // other state a card carries. A card without them is today's card and keeps the five columns it + // always had (#1299 acceptance 8); nothing here invents a placement for a card that names none. + const prefixed = (prefix) => { + const found = labels.find((name) => name.startsWith(prefix)) + return found === undefined ? null : found.slice(prefix.length) || null + } return { kind, number: row.number, title: String(row.title ?? ''), url: String(row.html_url ?? ''), - labels: orderLabels(labelNames(row)), + labels, + workflow: prefixed('workflow:'), + node: prefixed('node:'), owner, created: row.created_at ?? null, updated: row.updated_at ?? null, @@ -951,8 +962,35 @@ export function install(rt) { const view = await boardFor(entry).read({ force: payload?.force === true, wait: payload?.wait === true }) views.push({ ...view, repo: entry.repo, repoSource: entry.source }) } - return combineRepos(views) - } + const decorated = views.map((view) => ({ ...view, ...withWorkflowColumns(view) })) + return { ...combineRepos(decorated), ...sectionsOf(combinedCards(decorated)) } + } + // Every card of every repository, each stamped with its own repository, so the same number in two + // repositories is two cards. + const combinedCards = (views) => { + const cards = [] + for (const view of views) { + if (view.columns === undefined || view.columns === null) continue + for (const id of DELIVERY_COLUMNS) for (const card of view.columns[id] ?? []) cards.push({ ...card, repo: view.repo ?? null }) + } + return cards + } + // Cards that name a workflow and a node are laid in a column of that workflow's definition + // (#1272 step 3, acceptance 1 and 2). The section is additive: the repository's own five columns + // keep every other card, and `placed` names the cards a section draws so the view does not draw + // one twice. A card that names no workflow, or names one this host does not have, stays in the + // columns it has always been in — losing a card would be worse than an old column. + const sectionsOf = (cards) => { + let definitions = [] + try { definitions = rt.workflowDefinitions?.() ?? [] } catch { definitions = [] } + const { sections } = boardSections(definitions, cards) + const placed = sections.flatMap((section) => section.columns.flatMap((column) => column.cards)) + .map((card) => `${card.repo ?? ''}#${card.kind}${card.number}`) + return { workflows: sections, placed } + } + const withWorkflowColumns = (view) => sectionsOf(view.columns === undefined || view.columns === null + ? [] + : DELIVERY_COLUMNS.flatMap((id) => (view.columns[id] ?? []).map((card) => ({ ...card, repo: card.repo ?? view.repo ?? null })))) // The 诊断 button and `--probe` run this one function (#1211): same reads, same statuses. One // repository answers exactly as before; more than one is one report with the repository named on // every row, because a status without its repository cannot be acted on. diff --git a/patches/ds-bot/files/src/host/workflow-store.js b/patches/ds-bot/files/src/host/workflow-store.js new file mode 100644 index 000000000..793cd0f05 --- /dev/null +++ b/patches/ds-bot/files/src/host/workflow-store.js @@ -0,0 +1,240 @@ +// Where a workflow definition lives (#1272 step 3): the two FlyBot ships as presets, plus the +// owner's own `workflows/.json` in the workspace. This module is the only writer — a page asks +// it, it validates with the engine's own `validateWorkflow`, and it writes the canonical bytes +// `workflow-doc.js` produces. Three rules are enforced here rather than trusted to the page: +// +// - **a preset is copied, never mutated.** `repo-dev` and `blank` are read from the shipped +// modules and no file with their id is ever written, so the factory preset's bytes cannot +// change by any path this host serves (`customize` is the only way to start from one). +// - **a save that changes nothing writes the same bytes** (acceptance 7): `workflowBytes` is +// canonical, so a round trip is byte-identical and a definition diffs cleanly in git. +// - **a guard preview never invents a pass** (acceptance 5): the facts come from a reader the +// caller supplies; a reader that answered nothing makes every row say it could not read. +// +// The endpoint is `workflow-save`, which is in `authz.js`'s `TASK_ENDPOINTS` allow list; `workflows` +// is the read side and is listed with the other reads. +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { layoutWorkflow, normaliseWorkflow, validateWorkflow } from '../shared/workflow.js' +import { copyOfPreset, isPresetId, isWorkflowId, workflowBytes, WORKFLOW_DIR } from '../shared/workflow-doc.js' +import { previewNode } from '../shared/workflow-preview.js' +import { repoDevWorkflow } from '../shared/workflow-repo-dev.js' +import { blankWorkflow } from '../shared/workflow-blank.js' + +// The shipped presets, as definitions. `PRESET_IDS` in `workflow-doc.js` names the same two; a test +// asserts they cannot drift apart. +export const PRESETS = Object.freeze([repoDevWorkflow, blankWorkflow]) +// The project's own answer for "which workflow does this board use": a tiny file beside the +// definitions, so it is exportable and diffable like they are. +const PROJECT_FILE = 'project.json' + +const summary = (definition, source) => ({ + id: normaliseWorkflow(definition).id, + name: normaliseWorkflow(definition).name, + source, + nodes: normaliseWorkflow(definition).nodes.length, + columns: normaliseWorkflow(definition).nodes.filter((node) => node.column !== false).length, +}) + +// A directory may not exist yet (a workspace nobody has customized anything in), and a file in it +// may be someone's hand-edit that does not parse: one broken file must not take the list down. +const ownFiles = (dir) => { + try { + return readdirSync(dir).filter((name) => name.endsWith('.json') && name !== PROJECT_FILE).sort() + } catch { + return [] + } +} + +export function createWorkflowStore({ dir, presets = PRESETS, team = () => null, factsFor = () => ({ ok: false, why: 'this host has no fact reader' }), warn = () => {} } = {}) { + if (typeof dir !== 'string' || dir === '') throw new Error('a workflow store needs a directory') + const fileFor = (id) => join(dir, `${id}.json`) + + const readOwn = (id) => { + try { + return normaliseWorkflow(JSON.parse(readFileSync(fileFor(id), 'utf8'))) + } catch { + return null + } + } + + const readDefinition = (id) => { + const preset = presets.map(normaliseWorkflow).find((definition) => definition.id === id) + if (preset !== undefined) return { definition: preset, source: 'preset' } + const own = readOwn(id) + return own === null ? null : { definition: own, source: 'workspace' } + } + + const projectDefault = () => { + try { + const value = JSON.parse(readFileSync(join(dir, PROJECT_FILE), 'utf8')) + return typeof value?.default === 'string' && value.default !== '' ? value.default : null + } catch { + return null + } + } + + const list = () => { + const own = ownFiles(dir).flatMap((name) => { + const definition = readOwn(name.slice(0, -'.json'.length)) + if (definition === null || definition.id === '') { + warn('ds-bot: workflow store: %s in %s does not parse as a definition; skipped', name, dir) + return [] + } + return [summary(definition, 'workspace')] + }) + const current = projectDefault() + return { + ok: true, + presets: presets.map((definition) => summary(definition, 'preset')), + own, + // A default naming a workflow nobody has (a deleted file) is answered as "none", never echoed + // back as a workflow the page would then fail to open. + default: current !== null && readDefinition(current) !== null ? current : null, + } + } + + const open = (id, node = null) => { + const found = readDefinition(String(id ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${id}"`, problems: [] } + const { definition, source } = found + const target = typeof node === 'string' && node !== '' ? node : definition.nodes.find((entry) => entry.column !== false)?.id ?? definition.nodes[0]?.id ?? null + const answer = factsFor({ id: definition.id, node: target }) + return { + ok: true, + ...list(), + workflow: { + id: definition.id, + name: definition.name, + source, + editable: source === 'workspace', + definition, + // The bytes a save would write, so the page can say "unchanged" before it asks. + bytes: workflowBytes(definition), + sha: null, + }, + layout: layoutWorkflow(definition), + preview: target === null ? null : previewNode(definition, target, answer), + } + } + + const validate = (definition) => { + const problems = validateWorkflow(definition, { team: team() ?? undefined }).problems + return problems + } + + const save = ({ definition } = {}) => { + const def = normaliseWorkflow(definition) + if (!isWorkflowId(def.id)) { + return { ok: false, code: 'workflow/bad-id', message: `"${def.id}" is not a file name a workflow can have (lowercase letters, digits and hyphens)`, problems: [] } + } + if (isPresetId(def.id)) { + return { ok: false, code: 'workflow/preset-read-only', message: `"${def.id}" is a preset FlyBot ships; customize it into a new workflow instead of saving over it`, problems: [] } + } + const problems = validate(def) + if (problems.length > 0) return { ok: false, code: 'workflow/invalid', message: problems[0].message, problems } + const bytes = workflowBytes(def) + const previous = (() => { + try { + return readFileSync(fileFor(def.id), 'utf8') + } catch { + return null + } + })() + if (previous !== bytes) { + mkdirSync(dir, { recursive: true }) + writeFileSync(fileFor(def.id), bytes) + } + return { ok: true, id: def.id, bytes, changed: previous !== bytes, ...list() } + } + + const customize = ({ from, id, name } = {}) => { + const found = readDefinition(String(from ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${from}" to customize`, problems: [] } + const copy = copyOfPreset(found.definition, { id, name }) + const saved = save({ definition: copy }) + if (!saved.ok) return saved + return { ...saved, copiedFrom: found.definition.id, source: 'workspace' } + } + + const setDefault = ({ id } = {}) => { + const found = readDefinition(String(id ?? '')) + if (found === null) return { ok: false, code: 'workflow/not-found', message: `there is no workflow "${id}"`, problems: [] } + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, PROJECT_FILE), `${JSON.stringify({ default: found.definition.id }, null, 2)}\n`) + return { ok: true, ...list() } + } + + return { + dir, + list, + open, + save, + customize, + setDefault, + // The definitions in play, for the board's columns. Presets always; a workspace file is read on + // every call so a save is visible to the next board read without a restart. + definitions: () => { + const own = ownFiles(dir).map((name) => readOwn(name.slice(0, -'.json'.length))).filter((definition) => definition !== null && definition.id !== '') + return [...presets.map(normaliseWorkflow), ...own] + }, + bytesOf: (id) => { + const found = readDefinition(String(id ?? '')) + return found === null ? null : { source: found.source, bytes: workflowBytes(found.definition) } + }, + } +} + +// `workflows` reads; `workflow-save` writes. Both answer the store's own objects, so a refusal is a +// code and a sentence the page can show rather than an exception in the channel. +export function install(rt) { + const dirFor = () => { + let base = null + try { base = rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { base = null } + return base === null ? null : join(base, WORKFLOW_DIR) + } + // The roster's role names are the team a definition may name; a node role that is not one of them + // is refused by `validateWorkflow` (`system` and `human:` stay allowed by the engine). + const team = () => { + try { + const roles = new Set() + for (const bot of Object.values(rt.state?.bots ?? {})) if (typeof bot?.role === 'string' && bot.role !== '') roles.add(bot.role) + // An empty roster is "no team to check against", not a team of nobody: refusing every role + // would make the first definition unsavable on a fresh host. + return roles.size === 0 ? null : [...roles] + } catch { + return null + } + } + const store = () => { + const dir = dirFor() + if (dir === null) return null + if (rt.workflowStore?.dir !== dir) rt.workflowStore = createWorkflowStore({ dir, team, factsFor: rt.workflowFactsFor ?? (() => ({ ok: false, why: 'this host has no fact reader' })) }) + return rt.workflowStore + } + rt.workflowEndpoint = async (endpoint, body = {}) => { + const found = store() + if (found === null) return { ok: false, code: 'workflow/no-workspace', message: 'this host has no workspace, so there is nowhere to read or save a workflow', problems: [] } + if (endpoint === 'workflow-save') { + const op = String(body?.op ?? 'save') + if (op === 'save') return found.save({ definition: body?.definition }) + if (op === 'customize') return found.customize({ from: body?.from, id: body?.id, name: body?.name }) + if (op === 'default') return found.setDefault({ id: body?.id }) + return { ok: false, code: 'workflow/unknown-op', message: `workflow-save has no "${op}"; it saves, customizes or sets the default`, problems: [] } + } + const op = String(body?.op ?? 'list') + if (op === 'list') return found.list() + if (op === 'open') return found.open(body?.id, body?.node) + if (op === 'preview') { + const opened = found.open(body?.id, body?.node) + if (!opened.ok) return opened + return { ok: true, id: opened.workflow.id, preview: opened.preview } + } + return { ok: false, code: 'workflow/unknown-op', message: `workflows has no "${op}"; it lists, opens or previews`, problems: [] } + } + // The board reads its columns from these definitions (acceptance 1 and 2). + rt.workflowDefinitions = () => { + try { return store()?.definitions() ?? PRESETS.map(normaliseWorkflow) } catch { return PRESETS.map(normaliseWorkflow) } + } + return rt +} diff --git a/patches/ds-bot/files/src/shared/workflow-board.js b/patches/ds-bot/files/src/shared/workflow-board.js new file mode 100644 index 000000000..1ae1ec10a --- /dev/null +++ b/patches/ds-bot/files/src/shared/workflow-board.js @@ -0,0 +1,87 @@ +// The board the definition draws (#1272 step 3, acceptance 1 and 2): which columns a workflow has, +// in which order, and which of them a card sits in. Today's five fixed columns (`raw`, `ready`, +// `working`, `review`, `done-today`) stay for cards that name no workflow — a card without a +// `node` renders exactly as it did (#1299 acceptance 8) — and a card that does name one is laid in +// a column of its own workflow, so two workflows never share a column and every header can name the +// workflow it belongs to. +// +// Pure: definitions in, sections out. The host assembles the payload, the client draws it. +import { normaliseWorkflow, nodeById, workflowColumns } from './workflow.js' + +// One workflow's columns, in the order the definition puts its nodes: a node's `column` is the lane +// its cards sit in and the node id is the lane when the definition states none. `column: false` +// draws no lane at all. Two nodes may share a lane (`build` and `verify` are one "in-progress" +// lane); the lane is then named by its id, because naming it after one of the two would say the +// other node's cards belong elsewhere. +export function columnPlan(raw) { + const def = normaliseWorkflow(raw) + const plan = [] + const byLane = new Map() + for (const column of workflowColumns(def)) { + let lane = byLane.get(column.column) + if (lane === undefined) { + lane = { id: column.column, label: column.name, nodes: [] } + byLane.set(column.column, lane) + plan.push(lane) + } + lane.nodes.push(column.id) + if (lane.nodes.length > 1) lane.label = lane.id + } + return plan +} + +// Which lane a card sits in, or `null` when this definition cannot place it: the card names another +// workflow, names no node, or names a node that draws no column. `null` is not "drop it" — the +// caller keeps the card where it draws today. +export function placeCard(raw, card) { + const def = normaliseWorkflow(raw) + if (!card || typeof card !== 'object') return null + if (card.workflow !== def.id) return null + const node = card.node ? nodeById(def, card.node) : null + if (node === null) return null + if (node.column === false) return null + return node.column ?? node.id +} + +// The board's workflow sections: one per definition that has a card, its columns in definition +// order, and a card in the column its node names. Cards this definition cannot place are in +// `unplaced` so a caller can show them instead of losing them; cards naming no workflow come back +// as `legacy`, which is what keeps a board of today's cards looking exactly like today's board. +export function boardSections(definitions, cards) { + const defs = (Array.isArray(definitions) ? definitions : []).map(normaliseWorkflow).filter((def) => def.id !== '') + const list = Array.isArray(cards) ? cards.filter((card) => card && typeof card === 'object') : [] + const claimed = new Set() + const sections = [] + for (const def of defs) { + const plan = columnPlan(def) + const lanes = new Map(plan.map((column) => [column.id, []])) + const unplaced = [] + for (const card of list) { + if (card.workflow !== def.id) continue + const lane = placeCard(def, card) + if (lane === null) { + // A card this definition cannot place is the section's `unplaced`, not a card the legacy + // columns draw a second time. + claimed.add(card) + unplaced.push(card) + continue + } + claimed.add(card) + if (!lanes.has(lane)) lanes.set(lane, []) + lanes.get(lane).push(card) + } + if (unplaced.length === 0 && [...lanes.values()].every((lane) => lane.length === 0)) continue + sections.push({ + id: def.id, + name: def.name, + columns: [...lanes].map(([id, laneCards]) => ({ + id, + label: plan.find((column) => column.id === id)?.label ?? id, + cards: laneCards, + })), + unplaced, + }) + } + const legacy = list.filter((card) => !claimed.has(card)) + return { sections, legacy } +} diff --git a/patches/ds-bot/files/src/shared/workflow-doc.js b/patches/ds-bot/files/src/shared/workflow-doc.js new file mode 100644 index 000000000..c6d3fb771 --- /dev/null +++ b/patches/ds-bot/files/src/shared/workflow-doc.js @@ -0,0 +1,85 @@ +// A workflow definition as a file (#1272 step 3): `workflows/.json` in the workspace, written +// once and read back byte for byte. The two rules that live here are the ones a page cannot keep +// for itself — +// +// - **saving an unedited definition changes nothing.** `workflowBytes` is canonical: a fixed key +// order, two spaces, a trailing newline, and fields a definition does not state left out. So a +// load → save round trip is byte-identical, a definition can be diffed in git, and a save that +// reorders keys is a defect here rather than a surprise in someone's review. +// - **a preset is copied, never mutated.** `PRESET_IDS` names the definitions FlyBot ships; a +// workspace file may not take one of those ids, and a copy differs from its preset only in +// `id` / `name` — which is the assertion `workflow-doc.test.js` makes before any page exists. +// +// Nothing here does I/O: the host writes the bytes this module produces (`host/workflow-store.js`). +import { normaliseWorkflow } from './workflow.js' + +// The folder in the workspace, and the shipped ids a workspace file may not claim. +export const WORKFLOW_DIR = 'workflows' +export const PRESET_IDS = Object.freeze(['repo-dev', 'blank']) + +// A file name, not a title: lowercase, no spaces, no path separators, so `workflows/.json` can +// never leave its folder and two workflows cannot share one file. +const ID_RULE = /^[a-z0-9][a-z0-9-]{0,62}$/ + +export const isWorkflowId = (id) => typeof id === 'string' && ID_RULE.test(id) +export const isPresetId = (id) => PRESET_IDS.includes(id) + +const guardDoc = (guard) => { + const doc = { id: guard.id, spec: guard.spec, is: guard.is } + if (guard.value !== null) doc.value = guard.value + if (guard.refusal !== '') doc.refusal = guard.refusal + return doc +} + +const nodeDoc = (node) => { + const doc = { id: node.id, name: node.name, role: node.role } + // `column: false` is a decision ("this node draws no column"), and a missing one is the other + // decision ("the node id is the column"), so the two are written differently (#1265). + if (node.column !== null) doc.column = node.column + doc.inputs = node.inputs.slice() + doc.outputs = node.outputs.slice() + doc.guards = node.guards.map(guardDoc) + doc.refusal = node.refusal + return doc +} + +// The canonical document of a definition, as an object with a fixed key order. +export function workflowDocument(raw) { + const def = normaliseWorkflow(raw) + return { + id: def.id, + name: def.name, + version: def.version, + inputs: def.inputs.slice(), + deliverables: def.deliverables.slice(), + nodes: def.nodes.map(nodeDoc), + edges: def.edges.map((edge) => ({ from: edge.from, to: edge.to })), + loops: def.loops.map((loop) => ({ from: loop.from, to: loop.to })), + } +} + +// The bytes a save writes and a load compares against. Same definition in, same bytes out. +export function workflowBytes(raw) { + return `${JSON.stringify(workflowDocument(raw), null, 2)}\n` +} + +// What a `Customize` writes: the preset's own nodes and edges, with a new id and name. Everything +// else is the preset's, which is what makes "the copy differs only in id/name" checkable. +export function copyOfPreset(preset, { id, name } = {}) { + const def = normaliseWorkflow(preset) + return normaliseWorkflow({ + ...workflowDocument(def), + id, + name: name === undefined || name === null || String(name).trim() === '' ? def.name : String(name).trim(), + }) +} + +// The fields in which two definitions differ, by name — `['id', 'name']` for a preset and its copy. +// Used by the store to refuse a save that would rewrite a shipped preset, and by the test that +// holds acceptance 6. +export function differingFields(left, right) { + const a = workflowDocument(left) + const b = workflowDocument(right) + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + return [...keys].filter((key) => JSON.stringify(a[key]) !== JSON.stringify(b[key])).sort() +} diff --git a/patches/ds-bot/files/src/shared/workflow-preview.js b/patches/ds-bot/files/src/shared/workflow-preview.js new file mode 100644 index 000000000..2def7a120 --- /dev/null +++ b/patches/ds-bot/files/src/shared/workflow-preview.js @@ -0,0 +1,41 @@ +// The guard preview an editor shows (#1272 step 3, acceptance 5): for one node, the sentence each +// of its guards would print under the facts somebody just read. Two rules, both of them the +// engine's, not the page's — +// +// - the sentence is `renderRefusal`'s, produced from the definition, so the preview and the card's +// own refusal cannot disagree; +// - a fact nobody could read is `unreadable`, never a pass. When the caller read nothing at all, +// every row says so; a row with no sentence means only "this guard was read and holds". +import { guardRows, normaliseWorkflow, nodeById } from './workflow.js' + +// The row a guard gets when the fact source answered nothing. It names the fact it could not read +// and says what that means for the card; it never says the guard passes. +export const cannotRead = (node, spec) => `${node?.name || node?.id || 'this node'} refuses: its check on ${spec ?? 'a fact'} could not be read — no answer came back, so nothing here says it holds.` + +// `answer` is `{ ok: true, facts }` from a reader that answered, or `{ ok: false, why }` from one that +// did not. The rows keep the definition's guard order. +export function previewNode(raw, nodeId, answer = {}) { + const def = normaliseWorkflow(raw) + const node = nodeById(def, nodeId) + if (node === null) return { node: nodeId, facts: 'unknown', rows: [], refusal: `there is no node "${nodeId}" in this workflow` } + if (answer?.ok !== true) { + return { + node: node.id, + facts: 'unavailable', + why: typeof answer?.why === 'string' && answer.why !== '' ? answer.why : null, + rows: node.guards.map((guard) => ({ + id: guard.id, + spec: guard.spec, + is: guard.is, + value: guard.value, + state: 'unreadable', + sentence: cannotRead(node, guard.spec), + unreadable: true, + })), + refusal: node.guards.length === 0 ? null : cannotRead(node, node.guards[0].spec), + } + } + const rows = guardRows(def, node.id, answer.facts ?? {}) + const first = rows.find((row) => row.state !== 'holds') + return { node: node.id, facts: 'read', why: null, rows, refusal: first?.sentence ?? null } +} diff --git a/patches/ds-bot/files/src/shared/workflow.js b/patches/ds-bot/files/src/shared/workflow.js index 21e321bd1..6bc1d6a8d 100644 --- a/patches/ds-bot/files/src/shared/workflow.js +++ b/patches/ds-bot/files/src/shared/workflow.js @@ -402,13 +402,13 @@ function guardRefusals(node, facts) { for (const guard of node.guards) { const spec = canonicalSpec(guard.spec) if (!spec) { - refusals.push({ guard, refusal: renderRefusal(node, { spec: guard.spec }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec: guard.spec }), unreadable: false }) continue } // Read before the fact: an unknown check is a defect in the file, and a reader has to be told // that rather than shown a refusal about a fact that was never looked at. if (!GUARD_CHECKS.includes(guard.is)) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, guard, unchecked: guard.is }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, guard, unchecked: guard.is }), unreadable: false }) continue } const have = artifactAt(present, spec) @@ -416,20 +416,45 @@ function guardRefusals(node, facts) { if (!have) { // A guard's own sentence says what the fact said, and a fact that is not there said nothing: // the node's wording answers this one, or the sentence would name two revisions that do not exist. - refusals.push({ guard, refusal: renderRefusal(node, { spec }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec }), unreadable: false }) continue } if (guard.is === 'fresh' && !state.fresh) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, stale: true, have: state.have, now: state.now }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, stale: true, have: state.have, now: state.now }), unreadable: state.reason === 'unreadable' }) continue } if (guard.is === 'equals' && String(have.value ?? '') !== String(guard.value ?? '')) { - refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, have: have.revision, want: guard.value }) }) + refusals.push({ guard, refusal: renderRefusal(node, { spec, input: have, guard, have: have.revision, want: guard.value }), unreadable: false }) } } return refusals } +// One row per guard of one node, in the order the definition lists them (#1272 step 3, acceptance +// 5): the sentence a guard would print under these facts, or no sentence when it holds. This reads +// the same function `enterNode` reads, so the editor's preview and the board's refusal cannot +// disagree; it exists because the editor shows every guard while `enterNode` answers with the first +// refusal only. +export function guardRows(raw, nodeId, facts = {}) { + const node = nodeById(normaliseWorkflow(raw), nodeId) + if (!node) return [] + const refused = new Map(guardRefusals(node, facts).map((entry) => [entry.guard.id, entry])) + return node.guards.map((guard) => { + const entry = refused.get(guard.id) + return { + id: guard.id, + spec: guard.spec, + is: guard.is, + value: guard.value, + // `holds` is a guard that was read and passes; `refuse` carries the producer's sentence, and + // `unreadable` says the source could not be read, which is never a pass. + state: entry === undefined ? 'holds' : entry.unreadable ? 'unreadable' : 'refuse', + sentence: entry?.refusal ?? null, + unreadable: entry === undefined ? false : entry.unreadable === true, + } + }) +} + // Whether this node may start: every input present, every one of them current, and every guard // satisfied. The first failure is returned as the node's own sentence, with both revisions named — // a refusal moves nothing. diff --git a/patches/ds-bot/files/test/delivery-board.test.js b/patches/ds-bot/files/test/delivery-board.test.js index 66c2b9c55..f4c97c9f2 100644 --- a/patches/ds-bot/files/test/delivery-board.test.js +++ b/patches/ds-bot/files/test/delivery-board.test.js @@ -10,7 +10,7 @@ import { test } from 'node:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { combineRepos, createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' +import { cardOf, combineRepos, createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' import { install as installAuthz } from '../src/host/authz.js' import { install as installChannel } from '../src/host/channel.js' @@ -1095,3 +1095,18 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi assert.equal(withMissing.state, 'partial') assert.deepEqual(withMissing.missing.map(entry => [entry.repo, entry.part]), [['acme/one', 'ci']]) }) + +// A card names its workflow and its node with two labels (#1272 step 3, acceptance 8): a card that +// carries them can be laid in its workflow's column, and a card that carries none is exactly today's +// card — the same fields, the five columns it always had, nothing invented. +test('a card reads its workflow and node from its labels, and a card without them is unchanged (#1272 step 3)', () => { + const tagged = cardOf({ number: 5, title: 'tagged', html_url: 'u', labels: [{ name: 'workflow:repo-dev' }, { name: 'node:build' }, { name: 'bug' }] }, { kind: 'issue' }) + assert.equal(tagged.workflow, 'repo-dev') + assert.equal(tagged.node, 'build') + assert.deepEqual([...tagged.labels], ['bug', 'node:build', 'workflow:repo-dev']) + const plain = cardOf({ number: 6, title: 'plain', html_url: 'u', labels: [{ name: 'bug' }] }, { kind: 'issue' }) + assert.equal(plain.workflow, null) + assert.equal(plain.node, null) + // A label with the prefix and nothing after it names no workflow rather than an empty one. + assert.equal(cardOf({ number: 7, labels: [{ name: 'workflow:' }] }, { kind: 'issue' }).workflow, null) +}) diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index eefd3daa2..c7d976392 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -733,3 +733,36 @@ test('no token names the GitHub side, never an empty board (#1211, #1265)', () = assert.equal(all.props['data-state'], 'failed') assert.doesNotMatch(text(all), /Nothing here\./) }) + +// A workflow's columns are the definition's (#1272 step 3, acceptance 1 and 2): the section draws its +// own columns beside the five legacy ones, every header names the workflow, and a card a section +// draws is not drawn a second time below it. +test('a workflow draws its own columns, named by the workflow, and its card once (#1272 step 3)', () => { + const placed = { kind: 'issue', number: 21, title: 'Content card', labels: ['workflow:content', 'node:草稿'], url: 'https://x/21', repo: 'o/r' } + const payload = { + ...snapshot, + repo: 'o/r', + columns: { ...snapshot.columns, ready: [...snapshot.columns.ready, placed] }, + workflows: [{ id: 'content', name: 'Content', columns: [ + { id: '选题', label: '选题', cards: [] }, + { id: '草稿', label: '草稿', cards: [placed] }, + ], unplaced: [] }], + placed: ['o/r#issue21'], + } + const board = DeliveryBoard({ snapshot: payload }) + const section = walk(board).find(node => node.props?.['data-workflow'] === 'content') + assert.ok(section, 'the workflow section is drawn') + assert.match(text(section), /Content/) + const workflowColumns = walk(section).filter(node => node.props?.['data-column']).map(node => node.props['data-column']) + assert.deepEqual([...workflowColumns], ['选题', '草稿']) + // The legacy five are still there and still named. + const legacy = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'] && node.props['data-workflow'] === undefined) + assert.deepEqual([...legacy.map(node => node.props['data-column'])], ['raw', 'ready', 'working', 'review', 'done-today']) + // The card is in the definition's column, and only there: no column holds two workflows, and the + // card is drawn once. + const drawn = walk(board).filter(node => node.props?.['data-number'] === 21) + assert.equal(drawn.length, 1) + assert.ok(walk(section).includes(drawn[0])) + const ready = legacy.find(node => node.props['data-column'] === 'ready') + assert.equal(walk(ready).some(node => node.props?.['data-number'] === 21), false) +}) diff --git a/patches/ds-bot/files/test/workflow-board.test.js b/patches/ds-bot/files/test/workflow-board.test.js new file mode 100644 index 000000000..4d99d204e --- /dev/null +++ b/patches/ds-bot/files/test/workflow-board.test.js @@ -0,0 +1,100 @@ +// The board's columns come from the definition (#1272 step 3, acceptance 1, 2 and 8): a five-node +// content workflow draws its own columns beside a repository card with no code change between them, +// two workflows never share a column, every column header names its workflow, and a card that names +// no workflow keeps the five columns it has today. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { boardSections, columnPlan, placeCard } from '../src/shared/workflow-board.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +// The content workflow from the parent issue's acceptance 1: five nodes, no repository, no `column` +// stated anywhere — so each node id is the lane, which is what "no code change between them" means. +const content = { + id: 'content', + name: 'Content', + version: 1, + inputs: ['text:topic'], + deliverables: ['link:published'], + nodes: [ + { id: '选题', name: '选题', role: 'Writer', inputs: ['text:topic'], outputs: ['text:draft'], guards: [], refusal: '' }, + { id: '草稿', name: '草稿', role: 'Writer', inputs: ['text:draft'], outputs: ['text:edited'], guards: [], refusal: '' }, + { id: '审稿', name: '审稿', role: 'Editor', inputs: ['text:edited'], outputs: ['verdict'], guards: [], refusal: '' }, + { id: '发布', name: '发布', role: 'Publisher', inputs: ['verdict'], outputs: ['link:published'], guards: [], refusal: '' }, + { id: '归档', name: '归档', role: 'Publisher', inputs: ['link:published'], outputs: [], guards: [], refusal: '' }, + ], + edges: [ + { from: '选题.text:draft', to: '草稿.text:draft' }, + { from: '草稿.text:edited', to: '审稿.text:edited' }, + { from: '审稿.verdict', to: '发布.verdict' }, + { from: '发布.link:published', to: '归档.link:published' }, + ], + loops: [], +} + +const card = (over) => ({ kind: 'issue', number: 1, title: 'a card', workflow: null, node: null, ...over }) + +test('a five-node content workflow draws its own five columns, in the definition order', () => { + assert.deepEqual(columnPlan(content).map((column) => column.id), ['选题', '草稿', '审稿', '发布', '归档']) +}) + +test('repo-dev draws the three lanes its nodes name, and the review node draws none', () => { + const plan = columnPlan(repoDevWorkflow) + assert.deepEqual(plan.map((column) => column.id), ['not-started', 'in-progress', 'done']) + // Two nodes share the middle lane, so it is named by its id rather than after one of them. + assert.equal(plan[1].label, 'in-progress') + assert.deepEqual(plan[1].nodes, ['build', 'verify', 'gate']) + // `review` carries `column: false` and is in no lane at all (#1265). + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'review' })), null) +}) + +test('a card sits in the lane its node names, and nowhere when it names no workflow', () => { + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'build' })), 'in-progress') + assert.equal(placeCard(repoDevWorkflow, card({ workflow: 'repo-dev', node: 'release' })), 'done') + assert.equal(placeCard(content, card({ workflow: 'content', node: '审稿' })), '审稿') + assert.equal(placeCard(content, card({ workflow: 'repo-dev', node: 'build' })), null) + assert.equal(placeCard(content, card()), null) + // A node the definition does not have is not placed by a guess. + assert.equal(placeCard(content, card({ workflow: 'content', node: 'nope' })), null) +}) + +test('a content card and a repo-dev card get their own columns and no column holds both', () => { + const cards = [ + card({ number: 7, workflow: 'repo-dev', node: 'build' }), + card({ number: 8, workflow: 'content', node: '草稿' }), + card({ number: 9 }), + ] + const { sections, legacy } = boardSections([repoDevWorkflow, content], cards) + assert.deepEqual(sections.map((section) => section.id), ['repo-dev', 'content']) + const repoColumn = sections[0].columns.find((column) => column.id === 'in-progress') + const contentColumn = sections[1].columns.find((column) => column.id === '草稿') + assert.deepEqual(repoColumn.cards.map((entry) => entry.number), [7]) + assert.deepEqual(contentColumn.cards.map((entry) => entry.number), [8]) + // Every column names its workflow: `data-workflow` comes from the section id, and the header text + // is the definition's own name. + assert.equal(sections[0].name, 'Repository change') + assert.equal(sections[1].name, 'Content') + // No column holds two workflows' cards: the sections are disjoint by construction. + const numbers = sections.flatMap((section) => section.columns.flatMap((column) => column.cards.map((entry) => entry.number))) + assert.deepEqual(numbers.sort(), [7, 8]) + // The card that names no workflow is still where it was, in the legacy columns. + assert.deepEqual(legacy.map((entry) => entry.number), [9]) +}) + +test('a workflow with no cards draws no section, and a blank card names its own workflow', () => { + assert.deepEqual(boardSections([repoDevWorkflow, blankWorkflow], [card({ workflow: 'blank', node: 'draft' })]).sections.map((section) => section.id), ['blank']) + const { sections } = boardSections([blankWorkflow], [card({ workflow: 'blank', node: 'draft' }), card({ number: 2, workflow: 'blank', node: 'publish' })]) + assert.deepEqual(sections[0].columns.filter((column) => column.cards.length > 0).map((column) => column.id), ['draft', 'publish']) +}) + +test('a card naming a node with no column is kept, not dropped', () => { + const { sections, legacy } = boardSections([repoDevWorkflow], [card({ number: 3, workflow: 'repo-dev', node: 'review' })]) + assert.deepEqual(sections[0].unplaced.map((entry) => entry.number), [3]) + assert.deepEqual(legacy, []) +}) + +test('an unknown workflow in a card keeps it in the five columns it had', () => { + const { sections, legacy } = boardSections([repoDevWorkflow], [card({ number: 4, workflow: 'gone', node: 'build' })]) + assert.deepEqual(sections, []) + assert.deepEqual(legacy.map((entry) => entry.number), [4]) +}) diff --git a/patches/ds-bot/files/test/workflow-doc.test.js b/patches/ds-bot/files/test/workflow-doc.test.js new file mode 100644 index 000000000..329aef82b --- /dev/null +++ b/patches/ds-bot/files/test/workflow-doc.test.js @@ -0,0 +1,59 @@ +// The definition as a file (#1272 step 3, acceptance 6 and 7): canonical bytes, a preset that a copy +// leaves byte-identical, and a load → save round trip that changes nothing. +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { copyOfPreset, differingFields, isPresetId, isWorkflowId, PRESET_IDS, workflowBytes, workflowDocument } from '../src/shared/workflow-doc.js' +import { normaliseWorkflow } from '../src/shared/workflow.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +const sha256 = (text) => createHash('sha256').update(text).digest('hex') + +test('a definition saves to canonical bytes and reads back unchanged', () => { + const bytes = workflowBytes(repoDevWorkflow) + assert.ok(bytes.endsWith('}\n')) + assert.deepEqual(JSON.parse(bytes), workflowDocument(repoDevWorkflow)) + // Saving what was read changes nothing: the same bytes, so a definition diffs cleanly in git. + assert.equal(workflowBytes(JSON.parse(bytes)), bytes) +}) + +test('the same definition in a different key order saves to the same bytes', () => { + const reordered = JSON.parse(workflowBytes(repoDevWorkflow)) + const shuffled = Object.fromEntries(Object.entries(reordered).reverse()) + shuffled.nodes = reordered.nodes.map((node) => Object.fromEntries(Object.entries(node).reverse())) + assert.equal(workflowBytes(shuffled), workflowBytes(repoDevWorkflow)) +}) + +test('a preset copy differs from its preset only in id and name, and the preset file is untouched', () => { + const before = sha256(readFileSync(new URL('../src/shared/workflow-repo-dev.js', import.meta.url), 'utf8')) + const copy = copyOfPreset(repoDevWorkflow, { id: 'repo-dev-mine', name: 'Mine' }) + assert.deepEqual(differingFields(repoDevWorkflow, copy), ['id', 'name']) + assert.equal(copy.id, 'repo-dev-mine') + assert.equal(copy.name, 'Mine') + // The file the preset lives in is not what a save touches; the copy is a value, not a write. + const after = sha256(readFileSync(new URL('../src/shared/workflow-repo-dev.js', import.meta.url), 'utf8')) + assert.equal(after, before) + // Every field but the two is the preset's, node for node. + assert.deepEqual(normaliseWorkflow({ ...copy, id: repoDevWorkflow.id, name: repoDevWorkflow.name }), normaliseWorkflow(repoDevWorkflow)) +}) + +test('an id is a file name: lowercase, digits and hyphens, and a preset id is never one of ours', () => { + assert.equal(isWorkflowId('repo-dev-copy'), true) + assert.equal(isWorkflowId('Repo Dev'), false) + assert.equal(isWorkflowId('../escape'), false) + assert.equal(isWorkflowId(''), false) + assert.equal(isPresetId('repo-dev'), true) + assert.equal(isPresetId('blank'), true) + assert.equal(isPresetId('repo-dev-copy'), false) + // The shipped presets and the ids a workspace may not claim are the same two, and cannot drift. + assert.deepEqual([...PRESET_IDS].sort(), [repoDevWorkflow.id, blankWorkflow.id].sort()) +}) + +test('a blank copy keeps its nodes and edges and nothing else moves', () => { + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: '' }) + assert.deepEqual(differingFields(blankWorkflow, copy), ['id']) + // An empty name keeps the preset's name rather than writing a definition with no name. + assert.equal(copy.name, blankWorkflow.name) +}) diff --git a/patches/ds-bot/files/test/workflow-preview.test.js b/patches/ds-bot/files/test/workflow-preview.test.js new file mode 100644 index 000000000..713e7faa0 --- /dev/null +++ b/patches/ds-bot/files/test/workflow-preview.test.js @@ -0,0 +1,93 @@ +// The guard preview (#1272 step 3, acceptance 5): the sentence this definition would print under the +// facts somebody just read, naming both revisions, and — when the reader answered nothing — a row +// that says it could not read the fact rather than a guessed pass. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { artifact } from '../src/shared/artifact.js' +import { previewNode } from '../src/shared/workflow-preview.js' + +const review = { + id: 'review-flow', + name: 'Review flow', + version: 1, + inputs: ['pr'], + deliverables: ['release'], + nodes: [ + { + id: 'gate', + name: 'Gate', + role: 'Reviewer', + column: 'in-progress', + inputs: ['pr'], + outputs: ['risk:tier'], + guards: [ + { id: 'no-hold', spec: 'pr', is: 'equals', value: 'none', refusal: 'the pull request carries the `hold` label' }, + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', refusal: 'the pull request head is {now}, not the expected {have}' }, + ], + refusal: 'Gate cannot start: its input {input} is not current.', + }, + { id: 'done', name: 'Done', role: 'Release', inputs: ['risk:tier'], outputs: ['release'], guards: [], refusal: 'Done cannot start.' }, + ], + edges: [], + loops: [], +} + +const facts = (over = {}) => ({ + present: { + pr: artifact('pr', 'abc1234', { value: 'none' }), + verdict: artifact('verdict', 'abc1234', { value: 'PASS', by: 'Reviewer' }), + }, + current: { pr: 'abc1234', verdict: 'def5678' }, + ...over, +}) + +test('a stale verdict prints the producer sentence with both revisions and no pass', () => { + const preview = previewNode(review, 'gate', { ok: true, facts: facts() }) + assert.equal(preview.facts, 'read') + const row = preview.rows.find((entry) => entry.id === 'verdict-at-tip') + assert.equal(row.state, 'refuse') + assert.equal(row.unreadable, false) + assert.match(row.sentence, /abc1234/) + assert.match(row.sentence, /def5678/) + // The first refusal is the node's own answer, which is the sentence a card would show. + assert.equal(preview.refusal, row.sentence) + // The guard that holds says nothing at all — a row with no sentence is a fact that was read. + assert.equal(preview.rows.find((entry) => entry.id === 'no-hold').state, 'holds') + assert.equal(preview.rows.find((entry) => entry.id === 'no-hold').sentence, null) +}) + +test('a fact that could not be read is named, and a reader that answered nothing says so', () => { + const unreadable = previewNode(review, 'gate', { ok: true, facts: facts({ current: { pr: 'abc1234', verdict: null } }) }) + const row = unreadable.rows.find((entry) => entry.id === 'verdict-at-tip') + assert.equal(row.state, 'unreadable') + assert.equal(row.unreadable, true) + assert.match(row.sentence, /could not be checked against the source/) + + const silent = previewNode(review, 'gate', { ok: false, why: 'no GITHUB_TOKEN' }) + assert.equal(silent.facts, 'unavailable') + assert.equal(silent.why, 'no GITHUB_TOKEN') + assert.equal(silent.rows.length, 2) + for (const entry of silent.rows) { + assert.equal(entry.state, 'unreadable') + assert.match(entry.sentence, /could not be read/) + assert.doesNotMatch(entry.sentence, /passes|it is fine/i) + } + // The definition itself is never changed by a preview. + assert.equal(silent.rows[0].spec, 'pr') +}) + +test('a check nobody implemented refuses instead of being read as present', () => { + const broken = { ...review, nodes: [{ ...review.nodes[0], guards: [{ id: 'typo', spec: 'pr', is: 'fres' }] }] } + const preview = previewNode(broken, 'gate', { ok: true, facts: facts() }) + assert.equal(preview.rows[0].state, 'refuse') + assert.match(preview.rows[0].sentence, /is not one of present, fresh, equals/) +}) + +test('a node with no guards and a node that does not exist both answer plainly', () => { + const none = previewNode(review, 'done', { ok: true, facts: facts() }) + assert.deepEqual(none.rows, []) + assert.equal(none.refusal, null) + const missing = previewNode(review, 'nope', { ok: true, facts: facts() }) + assert.deepEqual(missing.rows, []) + assert.match(missing.refusal, /there is no node "nope"/) +}) diff --git a/patches/ds-bot/files/test/workflow-store.test.js b/patches/ds-bot/files/test/workflow-store.test.js new file mode 100644 index 000000000..7669f3ae4 --- /dev/null +++ b/patches/ds-bot/files/test/workflow-store.test.js @@ -0,0 +1,153 @@ +// Where a definition lives (#1272 step 3): the workspace's `workflows/.json`, the presets that +// are read-only, and the two endpoints — `workflows` reads, `workflow-save` writes with a signed-in +// actor (acceptance 6 and 7). +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { install as installAuthz } from '../src/host/authz.js' +import { createWorkflowStore, install, PRESETS } from '../src/host/workflow-store.js' +import { copyOfPreset, differingFields, workflowBytes } from '../src/shared/workflow-doc.js' +import { repoDevWorkflow } from '../src/shared/workflow-repo-dev.js' +import { blankWorkflow } from '../src/shared/workflow-blank.js' + +const withDir = (run) => { + const root = mkdtempSync(join(tmpdir(), 'flybot-workflows-')) + try { + return run(join(root, 'workflows'), root) + } finally { + rmSync(root, { recursive: true, force: true }) + } +} + +const sha256 = (text) => createHash('sha256').update(text).digest('hex') + +test('a save writes canonical bytes, and saving an unedited definition rewrites the same bytes', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: 'Mine' }) + const first = store.save({ definition: copy }) + assert.equal(first.ok, true) + assert.equal(first.changed, true) + const written = readFileSync(join(dir, 'blank-mine.json'), 'utf8') + assert.equal(written, workflowBytes(copy)) + // Acceptance 7: normalising and re-saving an unedited definition produces identical bytes. + const opened = store.open('blank-mine') + const again = store.save({ definition: opened.workflow.definition }) + assert.equal(again.ok, true) + assert.equal(again.changed, false) + assert.equal(readFileSync(join(dir, 'blank-mine.json'), 'utf8'), written) + assert.equal(sha256(readFileSync(join(dir, 'blank-mine.json'), 'utf8')), sha256(written)) + }) +}) + +test('a preset id cannot be saved over, and an invalid definition is refused with its own sentence', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const preset = store.save({ definition: repoDevWorkflow }) + assert.equal(preset.ok, false) + assert.equal(preset.code, 'workflow/preset-read-only') + assert.equal(existsSync(join(dir, 'repo-dev.json')), false) + + const copy = copyOfPreset(blankWorkflow, { id: 'blank-mine', name: 'Mine' }) + copy.nodes[0] = { ...copy.nodes[0], role: '' } + const invalid = store.save({ definition: copy }) + assert.equal(invalid.ok, false) + assert.equal(invalid.code, 'workflow/invalid') + assert.match(invalid.problems[0].message, /names no role/) + assert.equal(existsSync(join(dir, 'blank-mine.json')), false) + }) +}) + +test('customize writes a new file and leaves the shipped preset byte-identical', () => { + withDir((dir) => { + const presetFile = new URL('../src/shared/workflow-repo-dev.js', import.meta.url) + const before = sha256(readFileSync(presetFile, 'utf8')) + const store = createWorkflowStore({ dir }) + const copied = store.customize({ from: 'repo-dev', id: 'repo-dev-mine', name: 'Mine' }) + assert.equal(copied.ok, true) + assert.equal(copied.copiedFrom, 'repo-dev') + assert.equal(sha256(readFileSync(presetFile, 'utf8')), before) + const copy = JSON.parse(readFileSync(join(dir, 'repo-dev-mine.json'), 'utf8')) + assert.deepEqual(differingFields(repoDevWorkflow, copy), ['id', 'name']) + // The preset itself is still served from its module, not from the workspace file. + assert.equal(store.open('repo-dev').workflow.source, 'preset') + assert.equal(store.open('repo-dev-mine').workflow.source, 'workspace') + }) +}) + +test('the list names the presets, the owner files and the project default', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir }) + const empty = store.list() + assert.deepEqual(empty.presets.map((entry) => entry.id), PRESETS.map((entry) => entry.id)) + assert.deepEqual(empty.own, []) + assert.equal(empty.default, null) + + store.customize({ from: 'blank', id: 'blank-mine', name: 'Mine' }) + const withDefault = store.setDefault({ id: 'blank-mine' }) + assert.equal(withDefault.ok, true) + assert.equal(withDefault.default, 'blank-mine') + assert.deepEqual(withDefault.own.map((entry) => entry.id), ['blank-mine']) + // A default naming a file somebody deleted answers "none" instead of a workflow that cannot open. + rmSync(join(dir, 'blank-mine.json')) + assert.equal(store.list().default, null) + }) +}) + +test('open answers the definition, its layout and a guard preview', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir, factsFor: () => ({ ok: true, facts: { present: { pr: { spec: 'pr', revision: 'abc1234', value: 'none' } }, current: { pr: 'abc1234' } } }) }) + const opened = store.open('repo-dev') + assert.equal(opened.ok, true) + assert.equal(opened.workflow.editable, false) + assert.equal(opened.workflow.bytes, workflowBytes(repoDevWorkflow)) + assert.equal(opened.layout.boxes.length, repoDevWorkflow.nodes.length) + assert.equal(opened.layout.edges.length, repoDevWorkflow.edges.length) + assert.equal(opened.preview.node, 'triage') + // No fact reader answers on this host: the preview says it could not read rather than guessing. + const blind = createWorkflowStore({ dir }).open('repo-dev', 'gate') + assert.equal(blind.preview.facts, 'unavailable') + assert.ok(blind.preview.rows.every((row) => row.state === 'unreadable')) + }) +}) + +test('the host endpoints read and write, and workflow-save is in the authz allow list', async () => { + await withDir(async (dir, root) => { + const rt = { state: { bots: {} }, ctx: { workspaceRegistry: { get: () => ({ path: root }) } }, warn: () => {} } + install(rt) + installAuthz(rt) + // Enforced means someone has signed in on this host; before that every endpoint answers. + rt.state.authSessions = { 'session-1': {} } + const actor = { memberId: 'm1' } + assert.equal(rt.authorize(actor, 'workflow-save', {}).ok, true) + assert.equal(rt.authorize(actor, 'workflows', {}).ok, true) + assert.equal(rt.authorize(actor, 'not-an-endpoint', {}).ok, false) + + const listed = await rt.workflowEndpoint('workflows', { op: 'list' }) + assert.equal(listed.ok, true) + assert.deepEqual(listed.presets.map((entry) => entry.id), ['repo-dev', 'blank']) + const saved = await rt.workflowEndpoint('workflow-save', { op: 'customize', from: 'blank', id: 'blank-mine', name: 'Mine' }) + assert.equal(saved.ok, true) + assert.equal(existsSync(join(dir, 'blank-mine.json')), true) + const opened = await rt.workflowEndpoint('workflows', { op: 'open', id: 'blank-mine' }) + assert.equal(opened.workflow.source, 'workspace') + // The board reads its columns from these definitions (acceptance 1 and 2). + assert.deepEqual(rt.workflowDefinitions().map((definition) => definition.id).sort(), ['blank', 'blank-mine', 'repo-dev']) + const unknown = await rt.workflowEndpoint('workflow-save', { op: 'rewrite-everything' }) + assert.equal(unknown.ok, false) + assert.equal(unknown.code, 'workflow/unknown-op') + }) +}) + +test('a workspace file that does not parse is skipped instead of breaking the list', () => { + withDir((dir) => { + const store = createWorkflowStore({ dir, warn: () => {} }) + store.customize({ from: 'blank', id: 'blank-mine', name: 'Mine' }) + writeFileSync(join(dir, 'broken.json'), '{ not json') + const listed = store.list() + assert.deepEqual(listed.own.map((entry) => entry.id), ['blank-mine']) + }) +}) diff --git a/patches/ds-bot/files/test/workflows-page.test.js b/patches/ds-bot/files/test/workflows-page.test.js new file mode 100644 index 000000000..6d90b0836 --- /dev/null +++ b/patches/ds-bot/files/test/workflows-page.test.js @@ -0,0 +1,187 @@ +// The Workflows page (#1272 step 3, acceptance 3, 4, 5 and 6): the node table, the flowchart drawn +// from it, a guard's live refusal, and a preset that is read-only until it is customized. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { WorkflowsPage, applyNodeField, formatList, guardLines, parseGuards, parseList } from './src/client/workflows.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) +const h = (type, props, ...children) => ({ type, props: { ...props, children } }) +let states = [] +const react = { + createElement: h, Fragment: Symbol('F'), useEffect() {}, useLayoutEffect() {}, useMemo: (fn) => fn(), useCallback: (fn) => fn(), + useRef: (current) => ({ current }), useSyncExternalStore: (_s, get) => get(), useState: (value) => [states.length > 0 ? states.shift() : value, () => {}], +} +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, document: { hidden: false }, navigator: { language: 'en' }, localStorage: { getItem: () => null, setItem() {} }, + setTimeout, clearTimeout, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: (node) => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { WorkflowsPage, applyNodeField, guardLines, parseGuards, parseList } = module.exports + +const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } +} +const all = (node, out = []) => { + if (node && typeof node === 'object') { + if (Array.isArray(node)) node.forEach((child) => all(child, out)) + else { out.push(node); all(node.props?.children, out) } + } + return out +} +const text = (node) => (node === null || node === undefined ? '' : Array.isArray(node) ? node.map(text).join('') : typeof node === 'object' ? text(node.props.children) : String(node)) + +const node = (id, name, role, over = {}) => ({ id, name, role, column: null, inputs: [], outputs: [], guards: [], refusal: `${name} cannot start.`, ...over }) +const definition = { + id: 'content', + name: 'Content', + version: 1, + inputs: ['text:topic'], + deliverables: ['link:published'], + nodes: [ + node('选题', '选题', 'Writer', { inputs: ['text:topic'], outputs: ['text:draft'] }), + node('草稿', '草稿', 'Writer', { inputs: ['text:draft'], outputs: ['text:edited'] }), + node('审稿', '审稿', 'Editor', { inputs: ['text:edited'], outputs: ['verdict'], guards: [{ id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: '' }] }), + node('发布', '发布', 'Publisher', { inputs: ['verdict'], outputs: ['link:published'] }), + node('归档', '归档', 'Publisher', { inputs: ['link:published'] }), + ], + edges: [ + { from: '选题.text:draft', to: '草稿.text:draft' }, + { from: '草稿.text:edited', to: '审稿.text:edited' }, + { from: '审稿.verdict', to: '发布.verdict' }, + { from: '发布.link:published', to: '归档.link:published' }, + ], + loops: [], +} +const layout = { + id: 'content', + name: 'Content', + boxes: definition.nodes.map((entry, at) => ({ id: entry.id, name: entry.name, role: entry.role, rank: at, row: 0, inputs: entry.inputs, outputs: entry.outputs, x: at * 250, y: 0, width: 220, height: 120 })), + edges: definition.edges.map((edge) => ({ from: edge.from, to: edge.to, artifact: edge.from, fromBox: '选题', toBox: '草稿', points: [{ x: 220, y: 60 }, { x: 250, y: 60 }] })), + width: 1250, + height: 120, +} +const list = { + ok: true, + presets: [{ id: 'repo-dev', name: 'Repository change', source: 'preset', nodes: 7 }, { id: 'blank', name: 'Blank', source: 'preset', nodes: 3 }], + own: [{ id: 'content', name: 'Content', source: 'workspace', nodes: 5 }], + default: 'repo-dev', +} +const preview = { + node: '审稿', + facts: 'read', + rows: [ + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, state: 'refuse', sentence: '审稿 refuses: its input verdict@abc1234 is stale', unreadable: false }, + { id: 'no-hold', spec: 'pr', is: 'equals', value: 'none', state: 'holds', sentence: null, unreadable: false }, + ], + refusal: '审稿 refuses: its input verdict@abc1234 is stale', +} +const opened = { + ok: true, + ...list, + workflow: { id: 'content', name: 'Content', source: 'workspace', editable: true, definition, bytes: '{}\n' }, + layout, + preview, +} +const render = (props, given) => { states = [...given]; const tree = expand(h(WorkflowsPage, props)); states = []; return tree } + +test('the table is one row per node with its seven fields, and the edges are listed', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const rows = all(tree).filter((entry) => entry.props?.className === 'bt-wf-row' && entry.props['data-node'] !== undefined) + assert.equal(rows.length, 5) + assert.deepEqual(rows.map((row) => row.props['data-node']), ['选题', '草稿', '审稿', '发布', '归档']) + const head = all(tree).find((entry) => entry.props?.className === 'bt-wf-row bt-wf-row-head') + assert.deepEqual(all(head).filter((entry) => entry.type === 'span').map(text), ['id', 'name', 'role', 'inputs', 'outputs', 'guards', 'refusal']) + const edges = all(tree).filter((entry) => entry.props?.className === 'bt-wf-edge-row').map(text) + assert.equal(edges.length, 4) + assert.equal(edges[0], '选题.text:draft → 草稿.text:draft') +}) + +test('the flowchart is drawn from the table: one box per node, one edge per edge row', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const svg = all(tree).find((entry) => entry.type === 'svg' && entry.props?.['data-boxes'] !== undefined) + assert.equal(svg.props['data-boxes'], '5') + assert.equal(svg.props['data-edges'], '4') + assert.equal(all(tree).filter((entry) => entry.type === 'rect').length, 5) + assert.equal(all(tree).filter((entry) => entry.type === 'line').length, 4) + // The same definition draws the same picture twice: layout is the host's, computed, never stored. + const again = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + assert.equal(all(again).find((entry) => entry.type === 'svg' && entry.props?.['data-boxes'] !== undefined).props.viewBox, svg.props.viewBox) +}) + +test('a guard row prints its refusal and never turns an unreadable fact into a pass', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + const guards = all(tree).filter((entry) => entry.props?.className === 'bt-wf-guard') + assert.equal(guards.length, 2) + assert.equal(guards[0].props['data-state'], 'refuse') + assert.match(text(guards[0]), /verdict@abc1234 is stale/) + assert.equal(guards[1].props['data-state'], 'holds') + const blind = render({ actions: {} }, [list, opened, definition, '审稿', { node: '审稿', facts: 'unavailable', rows: [{ id: 'g', spec: 'verdict', is: 'fresh', value: null, state: 'unreadable', sentence: 'could not be read', unreadable: true }], refusal: null }, '', '']) + const row = all(blind).find((entry) => entry.props?.className === 'bt-wf-guard') + assert.equal(row.props['data-state'], 'unreadable') + assert.match(text(blind), /could not read the facts/) + // The hint says none of them is a pass; no row claims the guard holds. + assert.doesNotMatch(text(row), /holds/) +}) + +test('a preset is read-only until Customize, which copies it into a new file', () => { + const calls = [] + const actions = { + workflows: (payload) => { calls.push(['workflows', payload]); return Promise.resolve(payload.op === 'list' ? list : opened) }, + workflowSave: (payload) => { calls.push(['save', payload]); return Promise.resolve({ ok: true, id: 'repo-dev-copy', ...list }) }, + } + const presetOpened = { ...opened, workflow: { id: 'repo-dev', name: 'Repository change', source: 'preset', editable: false, definition, bytes: '' } } + const tree = render({ actions }, [list, presetOpened, definition, '审稿', preview, '', '']) + const labels = all(tree).filter((entry) => entry.type === 'button').map(text) + assert.ok(labels.includes('Customize')) + assert.ok(!labels.includes('Save')) + // Every editable field is disabled, so a preset has no save path at all. + assert.equal(all(tree).filter((entry) => entry.type === 'input').length, 0) + + all(tree).filter((entry) => entry.type === 'button').find((button) => text(button) === 'Customize').props.onClick() + const save = calls.find(([kind]) => kind === 'save') + assert.equal(JSON.stringify(save[1]), JSON.stringify({ op: 'customize', from: 'repo-dev', id: 'repo-dev-copy', name: 'Repository change (mine)' })) +}) + +test('editing one field keeps the edges and the other nodes, and guards round-trip', () => { + const edited = applyNodeField(definition, '审稿', 'name', 'Re-read') + assert.equal(edited.nodes.find((entry) => entry.id === '审稿').name, 'Re-read') + assert.equal(JSON.stringify(edited.nodes.map((entry) => entry.id)), JSON.stringify(definition.nodes.map((entry) => entry.id))) + assert.equal(JSON.stringify(edited.edges), JSON.stringify(definition.edges)) + const listed = applyNodeField(definition, '审稿', 'inputs', 'text:edited, text:notes') + assert.equal(JSON.stringify(listed.nodes.find((entry) => entry.id === '审稿').inputs), JSON.stringify(['text:edited', 'text:notes'])) + assert.equal(JSON.stringify(parseList('a, b ,,c')), JSON.stringify(['a', 'b', 'c'])) + // A guard keeps its id and its own refusal, so saving a table does not drop the sentence it refuses with. + const guards = [{ id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: 'the head is {now}' }] + assert.equal(guardLines(guards), 'verdict is fresh') + assert.equal(JSON.stringify(parseGuards('verdict is fresh\npr is equals none', guards)), JSON.stringify([ + { id: 'verdict-at-tip', spec: 'verdict', is: 'fresh', value: null, refusal: 'the head is {now}' }, + { id: 'pr-equals', spec: 'pr', is: 'equals', value: 'none', refusal: '' }, + ])) +}) + +test('the page names the file a definition is, and the project default', () => { + const tree = render({ actions: {} }, [list, opened, definition, '审稿', preview, '', '']) + assert.match(text(tree), /workflows\/content\.json/) + assert.match(text(tree), /project default/) + const picks = all(tree).filter((entry) => entry.props?.['data-id'] !== undefined).map((entry) => entry.props['data-id']) + assert.deepEqual(picks, ['repo-dev', 'blank', 'content']) +})