From 3bca8c757c2cb8aada65becd22eef5b4cee1663e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 07:01:02 +0000 Subject: [PATCH] Remove the GitHub delivery-board metrics panel The owner deleted the repository metrics panel (#1363). Opening Tasks shows that Bot's task board again; Workflow stays. The delivery-board and delivery-probe endpoints, the probe script, and the delivery card skin are gone. core/test/no-delivery-board.test.js fails if they return. The unreleased #1326 changelog fragment advertised the unified board this change removes, so that fragment is dropped. Co-authored-by: Danyang Chen --- changelog.d/1326.changed.md | 1 - changelog.d/1363.removed.md | 1 + core/client.js | 4888 +++---- core/index.js | 3 +- core/scripts/delivery-probe.mjs | 56 - core/src/client/board-card.js | 29 +- core/src/client/delivery-chart.js | 243 - core/src/client/delivery-view.js | 721 - core/src/client/i18n.js | 80 - core/src/client/index.js | 2 - core/src/client/kanban.js | 43 +- core/src/host/authz.js | 2 +- core/src/host/channel.js | 10 +- core/src/host/delivery-board.js | 1031 -- core/src/host/delivery-repo.js | 163 - core/src/host/workflow-store.js | 2 +- core/src/shared/board-card.js | 65 +- core/src/shared/workflow-board.js | 87 - core/test/board-card.test.js | 69 +- core/test/delivery-board.test.js | 1112 -- core/test/delivery-chart.test.js | 249 - core/test/delivery-repo.test.js | 220 - core/test/delivery-view.test.js | 862 -- core/test/home-lock.test.js | 22 +- core/test/kanban.test.js | 54 +- core/test/no-delivery-board.test.js | 41 + core/test/workflow-board.test.js | 100 - docs/subsystems/core-client.md | 9 +- docs/subsystems/core-host.md | 29 +- docs/subsystems/install.md | 1 - patches/ds-bot/0001-core-fixes.patch | 106 +- patches/ds-bot/0002-flybot-api-host.patch | 5 +- patches/ds-bot/0003-flybot-api-client.patch | 4 +- patches/ds-bot/0005-social-seams.patch | 14 +- patches/ds-bot/0008-client-bundle.patch | 11182 +++++++--------- .../ds-bot/files/scripts/delivery-probe.mjs | 56 - patches/ds-bot/files/src/client/board-card.js | 29 +- .../ds-bot/files/src/client/delivery-chart.js | 243 - .../ds-bot/files/src/client/delivery-view.js | 721 - patches/ds-bot/files/src/client/kanban.js | 43 +- patches/ds-bot/files/src/host/authz.js | 2 +- .../ds-bot/files/src/host/delivery-board.js | 1031 -- .../ds-bot/files/src/host/delivery-repo.js | 163 - .../ds-bot/files/src/host/workflow-store.js | 2 +- patches/ds-bot/files/src/shared/board-card.js | 65 +- .../ds-bot/files/src/shared/workflow-board.js | 87 - patches/ds-bot/files/test/board-card.test.js | 69 +- .../ds-bot/files/test/delivery-board.test.js | 1112 -- .../ds-bot/files/test/delivery-chart.test.js | 249 - .../ds-bot/files/test/delivery-repo.test.js | 220 - .../ds-bot/files/test/delivery-view.test.js | 862 -- patches/ds-bot/files/test/kanban.test.js | 54 +- .../files/test/no-delivery-board.test.js | 41 + .../ds-bot/files/test/workflow-board.test.js | 100 - 54 files changed, 7088 insertions(+), 19567 deletions(-) delete mode 100644 changelog.d/1326.changed.md create mode 100644 changelog.d/1363.removed.md delete mode 100644 core/scripts/delivery-probe.mjs delete mode 100644 core/src/client/delivery-chart.js delete mode 100644 core/src/client/delivery-view.js delete mode 100644 core/src/host/delivery-board.js delete mode 100644 core/src/host/delivery-repo.js delete mode 100644 core/src/shared/workflow-board.js delete mode 100644 core/test/delivery-board.test.js delete mode 100644 core/test/delivery-chart.test.js delete mode 100644 core/test/delivery-repo.test.js delete mode 100644 core/test/delivery-view.test.js create mode 100644 core/test/no-delivery-board.test.js delete mode 100644 core/test/workflow-board.test.js delete mode 100644 patches/ds-bot/files/scripts/delivery-probe.mjs delete mode 100644 patches/ds-bot/files/src/client/delivery-chart.js delete mode 100644 patches/ds-bot/files/src/client/delivery-view.js delete mode 100644 patches/ds-bot/files/src/host/delivery-board.js delete mode 100644 patches/ds-bot/files/src/host/delivery-repo.js delete mode 100644 patches/ds-bot/files/src/shared/workflow-board.js delete mode 100644 patches/ds-bot/files/test/delivery-board.test.js delete mode 100644 patches/ds-bot/files/test/delivery-chart.test.js delete mode 100644 patches/ds-bot/files/test/delivery-repo.test.js delete mode 100644 patches/ds-bot/files/test/delivery-view.test.js create mode 100644 patches/ds-bot/files/test/no-delivery-board.test.js delete mode 100644 patches/ds-bot/files/test/workflow-board.test.js diff --git a/changelog.d/1326.changed.md b/changelog.d/1326.changed.md deleted file mode 100644 index 23ddba53a..000000000 --- a/changelog.d/1326.changed.md +++ /dev/null @@ -1 +0,0 @@ -Unified the task and delivery boards into one "Board" dialog. The Tasks/Delivery tab split is removed; both task cards and GitHub cards now appear together in a single view. diff --git a/changelog.d/1363.removed.md b/changelog.d/1363.removed.md new file mode 100644 index 000000000..3850507b3 --- /dev/null +++ b/changelog.d/1363.removed.md @@ -0,0 +1 @@ +The repository metrics panel is gone ([#1363](https://github.com/ThinkFlowLab/FlyBot/issues/1363)). Opening Tasks shows that Bot's task board: there is no Today / 7-day GitHub block, no Retry or Diagnose, and no "from the package" repository header. The per-Bot task board and Workflow stay. diff --git a/core/client.js b/core/client.js index 5ad3ec779..3c8b72e3d 100644 --- a/core/client.js +++ b/core/client.js @@ -1392,87 +1392,7 @@ var HOST_ZH = { "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", "This DeepSeek Harness cannot list folders": "这个 DeepSeek Harness 不能列出文件夹", "No FlyBot core update is available": "没有可用的 FlyBot 核心更新", - "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot", - // #1173: the delivery board — one repository-wide snapshot beside the per-Bot task board. - "Delivery": "交付", - "Board view": "看板视图", - "Original issue": "原始单", - "Ready to start": "待开工", - "In review": "审查中", - "Done today": "今天完成", - "INC · rework": "打回 · 返工", - "PASS · waiting for enqueue": "通过 · 待入队", - "PASS · waiting for merge": "通过 · 待合入", - "CI red": "CI 红", - "CI running": "CI 跑着", - "No verdict": "还没结论", - "New issues": "新单", - "Closed issues": "关单", - "New pull requests": "新 PR", - "Merged pull requests": "合入 PR", - "Median issue close": "关单中位", - "Median PR merge": "合入中位", - "Today": "今天", - "No GitHub connection yet; the board fills in once one is added.": "还没接 GitHub,接上后这里就有数了。", - "The delivery board is not ready yet.": "交付看板还没准备好。", - "Showing the last snapshot from {time}: {error}": "显示 {time} 的快照:{error}", - "linked to #{number}": "关联 #{number}", - "open on GitHub": "在 GitHub 上打开", - "CI failed {failed} of {total}": "CI 红 {failed} / 共 {total}", - "counted {counted} of {total}": "已统计 {counted} / 共 {total}", - "{count} superseded": "{count} 条被取代", - "Open: {issues} issues ({needs} need a human), {prs} pull requests": "开着:{issues} 张单({needs} 张等人),{prs} 个 PR", - "median open age {age}": "开着的单中位 {age}", - "as of {time}": "截至 {time}", - "refreshing…": "刷新中…", - // #1212: the 7-day lines. A day the host could not read, and a snapshot with no series at all. - "not available": "未取到", - "No 7-day data yet.": "7 天数据未取到", - "{label} over the last 7 days": "{label}近 7 天", - "Last 7 days": "近 7 天", - "so far": "截至现在", - "today is partial": "今天为部分日", - "* {count} of 7 days had data": "* 7 天中 {count} 天有数", - "* {count} of {days} days had data": "* {days} 天中 {count} 天有数", - "CI red share": "CI 红占比", - "No snapshot yet.": "还没有快照。", - "Nothing here.": "这里还没有。", - "First load in progress, {seconds} s elapsed": "首次加载中,已 {seconds} 秒", - "Showing the snapshot from {time} · refresh failed: {message}": "显示 {time} 的快照 · 刷新失败:{message}", - "The first round reads the repository once; the columns appear as soon as it lands.": "首轮要把这个仓读一遍,列会在拿到的同时出现。", - "While this page is open it retries on its own; press Retry to try again now.": "页面开着时自动重试,也可点重试立即再试。", - "Retrying in {seconds} s · press Retry to try again now": "{seconds} 秒后自动重试,也可点重试立即再试。", - "Checks unknown": "检查结果未取到", - "CI not available this time": "本次未取到 CI 数据", - "{count} day(s) not read": "{count} 天没取到", - "Diagnose": "诊断", - "Checking…": "检查中…", - Copy: "复制", - Close: "关闭", - "this repository": "这个仓", - // #1250: whose repository the board read, and where that name came from — a package default - // shown as a fact is what put one instance's numbers on another. - "no repository — set delivery.repo": "没有仓库 —— 请设置 delivery.repo", - "{count} repositories": "{count} 个仓库", - "from config": "来自 config", - "from remote": "来自 remote", - "from the package (default)": "来自安装包(默认值)", - "Set delivery.repo in this instance's settings to the repository this instance works in.": "在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。", - "Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.": "在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。", - "GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。", - "GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.": "GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。", - "GitHub did not answer in time; the next attempt happens while this page is open.": "GitHub 没在时限内回应,页面开着时下一次重试会自动进行。", - "Give the token Actions: Read.": "去设置里给 token 勾 Actions: Read。", - "Give the token Checks: Read.": "去设置里给 token 勾 Checks: Read。", - "Give the token Issues: Read.": "去设置里给 token 勾 Issues: Read。", - "Give the token Pull requests: Read.": "去设置里给 token 勾 Pull requests: Read。", - "Check the repository name, and that the token can see {repo}.": "检查仓库名对不对、token 有没有 {repo} 的访问权。", - "GitHub is not answering this instance right now; retry in a moment.": "GitHub 现在没回应这个实例,稍后重试。", - "The next attempt happens while this page is open; the reason above names what failed.": "页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。", - "{part} was not fetched this time: {message}": "{part}这次没取到:{message}", - "CI data": "CI 数据", - "The rest of the board": "看板剩下的部分", - "Part of the board": "看板的一部分" + "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot" }; Object.assign(ZH, HOST_ZH); var fill = (text, params) => params ? text.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text; @@ -2205,8 +2125,8 @@ function bodyOf(def, paint, flat = false) { return ""; } } -var eyeMarkup = ([cx, cy, w, h49, tilt]) => ``; -var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; +var eyeMarkup = ([cx, cy, w, h47, tilt]) => ``; +var happyEyeMarkup = ([cx, cy, w, h47, 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%"; @@ -2902,29 +2822,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: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { - if (!preview || typeof h49 !== "function") return null; +function RoomInvitePreview({ h: h47, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h47 !== "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 h49( + return h47( "div", { className: "fs-preview", "data-flybot": at, role: "status" }, - 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( + h47("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h47("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h47("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h47("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h47( "div", { className: "fs-preview-foot", "data-flybot": child("foot") }, - showClear && onClear ? h49("button", { + showClear && onClear ? h47("button", { type: "button", className: "fs-btn fs-btn-sm", "data-flybot": child("clear"), disabled: !!busy, onClick: onClear }, clearLabel) : null, - onJoin ? h49("button", { + onJoin ? h47("button", { type: "button", className: "fs-btn fs-btn-sm fs-btn-primary", "data-flybot": child("join"), @@ -3489,12 +3409,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 h49 = dt / steps; + const h47 = 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) * h49; - value.x += value.v * h49; + value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h47; + value.x += value.v * h47; } if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { value.x = value.to; @@ -8111,38 +8031,10 @@ function progressOf(task) { } // src/shared/board-card.js -var CARD_SOURCES = ["github", "task"]; -function githubCardKey(repo, number) { - const name = typeof repo === "string" ? repo.trim() : ""; - const value = Number(number); - if (name === "" || !Number.isInteger(value) || value <= 0) return null; - return `${name}#${value}`; -} function taskCardKey(id) { const text = String(id ?? "").trim(); return text === "" ? null : `task:${text}`; } -var repoOf = (repo) => typeof repo === "string" && repo.trim() !== "" ? repo.trim() : null; -function githubCard(row, repo) { - const number = Number(row?.number); - if (!Number.isInteger(number) || number <= 0) return null; - const name = repoOf(repo); - return { - source: "github", - key: githubCardKey(name, number) ?? `github:${row.kind ?? "item"}:${number}`, - repo: name, - number, - kind: row.kind ?? null, - title: String(row.title ?? ""), - badge: row.badge ?? null, - labels: Array.isArray(row.labels) ? row.labels.slice() : [], - owner: row.owner ?? null, - ownerKind: row.ownerKind ?? null, - linked: row.linked ?? null, - sha: row.sha ?? null, - url: row.url ?? null - }; -} function taskCard(task) { const key = taskCardKey(task?.id); if (key === null) return null; @@ -8150,37 +8042,12 @@ function taskCard(task) { source: "task", key, id: String(task.id), - repo: repoOf(task.repo), - number: null, - kind: "task", - title: String(task.title ?? ""), - badge: null, - labels: [], - owner: null, - ownerKind: null, - linked: null, - sha: null, - url: null + title: String(task.title ?? "") }; } -function sameCard(a, b) { - return Boolean(a?.key) && a.key === b?.key; -} // src/client/board-card.js var import_react14 = require("react"); -var DELIVERY_SKIN = { - card: "bt-del-card", - top: "bt-del-card-top", - id: "bt-del-id", - badge: "bt-del-badge", - title: "bt-del-title", - labels: "bt-del-labels", - label: "bt-del-label", - owner: "bt-del-owner", - linked: "bt-del-linked", - sha: "bt-del-sha" -}; var TASK_SKIN = { card: "bt-board-card", top: "bt-board-card-top", @@ -8190,13 +8057,11 @@ var TASK_SKIN = { labels: "bt-board-labels", label: "bt-board-label", owner: "bt-board-who", - linked: "bt-board-linked", - sha: "bt-board-sha", note: "bt-board-note" }; function BoardCard({ card, - skin = DELIVERY_SKIN, + skin = TASK_SKIN, attrs = {}, idText = "", topExtra = null, @@ -8237,891 +8102,23 @@ function BoardCard({ (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, - note ? (0, import_react14.createElement)("span", { className: skin.note ?? skin.linked }, note) : null, + note ? (0, import_react14.createElement)("span", { className: skin.note }, note) : null, meta, - card?.linked ? (0, import_react14.createElement)("span", { className: skin.linked }, t("linked to #{number}", { number: card.linked })) : null, - card?.sha ? (0, import_react14.createElement)("span", { className: skin.sha }, card.sha) : null, - card?.url ? (0, import_react14.createElement)("a", { className: skin.sha, href: card.url, target: "_blank", rel: "noreferrer" }, t("open on GitHub")) : null, footer, actions ); } -// src/client/delivery-chart.js -var import_react15 = require("react"); -var CHART_WIDTH = 168; -var CHART_HEIGHT = 28; -var PAD = 3; -var DOT_RADIUS = 2; -var HOLLOW_RADIUS = 2.5; -var CHART_DAYS = 7; -var CHART_KEYS = ["newIssues", "closedIssues", "newPullRequests", "mergedPullRequests", "ci"]; -var COUNTING_KEYS = ["newIssues", "closedIssues", "newPullRequests", "mergedPullRequests"]; -function chartDays(daily) { - if (!Array.isArray(daily)) return null; - const days = daily.filter((day) => day && typeof day === "object"); - return days.length ? days.slice(-CHART_DAYS) : null; -} -function todayPartial(daily) { - const days = chartDays(daily); - return Boolean(days && days.length && days[days.length - 1].partial === true); -} -function dayValue(day, key) { - if (!day || typeof day !== "object") return null; - if (key === "ci") { - const failed = day.ci?.failed; - const counted = day.ci?.counted; - const total = day.ci?.total; - 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)); - } - const value = day[key]; - return Number.isFinite(value) ? value : null; -} -function dayLabel2(day) { - if (typeof day?.date === "string" && /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(day.date)) return day.date; - const at = Date.parse(day?.start ?? ""); - if (!Number.isFinite(at)) return ""; - return new Date(at + 8 * 36e5).toISOString().slice(0, 10); -} -function pointTitle(day, key) { - const date = dayLabel2(day); - const value = dayValue(day, key); - const shown = value === null ? t("not available") : key === "ci" ? `${Math.round(value * 100)}%` : String(value); - return date ? `${date} · ${shown}` : shown; -} -function knownDays(daily, key) { - const days = chartDays(daily) ?? []; - return days.filter((day) => dayValue(day, key) !== null).length; -} -var round = (value) => Math.round(value * 100) / 100; -function chartGeometry(daily, key, { width = CHART_WIDTH, height = CHART_HEIGHT } = {}) { - const days = chartDays(daily); - if (!days) return null; - const values = days.map((day) => dayValue(day, key)); - const known = values.filter((value) => value !== null); - const top = known.length ? Math.max(...known, 0) : 0; - const span = top > 0 ? top : 1; - const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0; - const dots = days.map((day, i) => { - const value = values[i]; - const last = i === days.length - 1; - const state = value === null ? "missing" : last && day.partial === true ? "partial" : "full"; - return { - index: i, - x: round(PAD + step * i), - y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), - today: last, - // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. - hollow: state === "partial", - partial: state === "partial", - state, - title: pointTitle(day, key), - value - }; - }); - const segments = []; - let run = null; - for (const dot of dots) { - if (dot.y === null) { - if (run) { - segments.push(run); - run = null; - } - continue; - } - if (!run) run = { dots: [] }; - run.dots.push(dot); - } - if (run) segments.push(run); - return { - width, - height, - dots, - known: known.length, - segments: segments.map((segment) => ({ - d: segment.dots.map((dot, i) => `${i ? "L" : "M"}${dot.x} ${dot.y}`).join(" "), - // Only the stretch that ends today is the day that is not over yet. - dashed: segment.dots.some((dot) => dot.partial), - today: segment.dots.some((dot) => dot.today) - })) - }; -} -function DeliveryChart({ daily, metricKey, label }) { - const geo = chartGeometry(daily, metricKey); - if (!geo || !geo.known) return null; - const name = label ? t("{label} over the last 7 days", { label }) : t("Last 7 days"); - return (0, import_react15.createElement)( - "svg", - { - className: "bt-del-chart", - "data-flybot": "delivery-chart", - "data-metric": metricKey, - width: geo.width, - height: geo.height, - viewBox: `0 0 ${geo.width} ${geo.height}`, - role: "img", - "aria-label": name - }, - geo.segments.map((segment, i) => (0, import_react15.createElement)("path", { - key: `s${i}`, - className: "bt-del-chart-line", - "data-today": segment.today ? "true" : void 0, - ...segment.dashed ? { "stroke-dasharray": "2 2" } : {}, - d: segment.d - })), - geo.dots.filter((dot) => dot.y !== null).map((dot) => (0, import_react15.createElement)("circle", { - key: `d${dot.index}`, - className: "bt-del-chart-dot", - "data-state": dot.state, - "data-hollow": dot.hollow ? "true" : void 0, - cx: dot.x, - cy: dot.y, - r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS - }, (0, import_react15.createElement)("title", null, dot.title))) - ); -} -function DeliveryMetrics({ rows = [], daily }) { - const days = chartDays(daily); - const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); - const fewest = short.length ? Math.min(...short) : 0; - const head = (0, import_react15.createElement)( - "div", - { className: "bt-del-dm-head", "data-flybot": "delivery-metrics-head" }, - (0, import_react15.createElement)("span", { className: "bt-del-dm-label" }, ""), - (0, import_react15.createElement)("span", { className: "bt-del-dm-chart-head" }, ""), - (0, import_react15.createElement)("span", { className: "bt-del-dm-value" }, t("Today")), - (0, import_react15.createElement)("span", { className: "bt-del-dm-value bt-del-dm-week" }, t("7 days")) - ); - const body = rows.map((row, index) => { - const key = CHART_KEYS.includes(row?.key) ? row.key : null; - const chart = days && key ? (0, import_react15.createElement)(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) : null; - const counted = key !== null && COUNTING_KEYS.includes(key); - const star = days && counted && knownDays(days, key) < days.length ? "*" : ""; - const cells = [ - (0, import_react15.createElement)("span", { key: "label", className: "bt-del-dm-label" }, row?.label ?? ""), - // A median row gets no chart cell at all: no line, no placeholder box, no total. - ...key ? [(0, import_react15.createElement)("span", { key: "chart", className: "bt-del-dm-chart" }, chart)] : [], - (0, import_react15.createElement)("span", { key: "today", className: "bt-del-dm-value bt-del-dm-today" }, row?.today ?? "—"), - (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") - ]; - return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); - }); - return (0, import_react15.createElement)( - "div", - { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, - head, - body, - days ? null : (0, import_react15.createElement)("p", { className: "bt-del-chart-empty", "data-flybot": "delivery-chart-empty" }, t("No 7-day data yet.")), - days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : null - ); -} -var DELIVERY_CHART_CSS = ` -.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} -.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} -.bt-del-dm-head{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -.bt-del-dm-label{flex:1 1 auto;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -.bt-del-dm-head .bt-del-dm-label{color:inherit} -.bt-del-dm-chart,.bt-del-dm-chart-head{flex:0 0 168px;display:flex;align-items:center;min-height:28px} -.bt-del-dm-value{flex:0 0 auto;min-width:56px;text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--bt-ink)} -.bt-del-dm-head .bt-del-dm-value{color:inherit;font-weight:inherit} -.bt-del-dm-today{color:var(--bt-ink);font-weight:600} -.bt-del-dm-week{color:var(--bt-ink-3);font-weight:400} -.bt-del-chart{display:block;overflow:visible} -.bt-del-chart-line{fill:none;stroke:var(--bt-accent);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} -.bt-del-chart-dot{fill:var(--bt-accent);stroke:none} -.bt-del-chart-dot[data-state=partial]{fill:none;stroke:var(--bt-accent);stroke-width:1.5} -.bt-del-chart-empty,.bt-del-chart-foot{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -@media (max-width:899.98px){ - .bt-del-dm-head{display:none} - .bt-del-dm-row{flex-wrap:wrap;gap:4px 8px;min-height:44px;align-content:center} - .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} - .bt-del-dm-value{min-width:0} -} -`; - -// src/client/delivery-view.js -var import_react16 = require("react"); -var POLL_MS3 = 6e4; -var LOADING_POLL_MS = 2e3; -var RETRY_FLOOR_MS = 1e3; -var TIMER_MAX_MS = 2 ** 31 - 1; -function pollDelay(state, payload = null, now = Date.now()) { - if (state === "loading" || state === "idle") return LOADING_POLL_MS; - const at = Number(payload?.retryAt); - if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { - return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); - } - return POLL_MS3; -} -var DELIVERY_COLUMNS = [ - { id: "not-started", label: "Not started" }, - { id: "in-progress", label: "In progress" }, - { id: "done", label: "Done" } -]; -function stagesOf(view) { - const columns = view?.columns; - if (columns === null || typeof columns !== "object") return DELIVERY_COLUMNS; - const present = Object.keys(columns); - if (present.length === 0) return DELIVERY_COLUMNS; - const known = DELIVERY_COLUMNS.filter((column) => present.includes(column.id)); - const unlabelled = present.filter((id) => !DELIVERY_COLUMNS.some((column) => column.id === id)).map((id) => ({ id, label: id })); - return [...known, ...unlabelled]; -} -var BADGES = { - inc: "INC · rework", - "pass-enqueue": "PASS · waiting for enqueue", - "pass-merge": "PASS · waiting for merge", - "ci-red": "CI red", - "ci-running": "CI running", - "no-verdict": "No verdict", - // The checks could not be read: that is not "no verdict", and saying so blamed the pull request - // for a permission the instance is missing (#1211). - "checks-unknown": "Checks unknown" -}; -var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; -function taskColumnOf(task) { - const col = columnOf(task); - if (col === "not-started") return "not-started"; - if (col === "in-progress") return "in-progress"; - return "done"; -} -var dropProps = (column, { dragging, onMove, onDrag }) => ({ - onDragOver: (event) => { - if (!dragging) return; - event.preventDefault(); - }, - onDrop: (event) => { - event.preventDefault(); - const id = event.dataTransfer?.getData("text/plain") ?? ""; - if (id) onMove?.(id, column); - onDrag?.(null); - } -}); -function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { - const columns = { "not-started": [], "in-progress": [], "done": [] }; - for (const task of tasks ?? []) { - const col = taskColumnOf(task); - if (columns[col]) columns[col].push(task); - } - const columnTitles = { - "not-started": t("Not started"), - "in-progress": t("In progress"), - "done": t("Done") - }; - return (0, import_react16.createElement)( - "div", - { className: "bt-board-columns" }, - ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( - "section", - { - key: column, - className: "bt-board-column", - "data-column": column, - "aria-label": columnTitles[column], - ...dropProps(column, { dragging, onMove, onDrag }) - }, - (0, import_react16.createElement)( - "h3", - { className: "bt-board-column-head" }, - (0, import_react16.createElement)("span", null, columnTitles[column]), - (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) - ), - columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => (0, import_react16.createElement)(BoardCard2, { - key: task.id, - task, - roster, - movable: canMoveTask(roster, task), - onMove, - onDrag - })) - )) - ); -} -function duration(ms) { - if (!Number.isFinite(ms) || ms < 0) return "—"; - const hours = ms / 36e5; - if (hours < 1) return `${Math.max(1, Math.round(ms / 6e4))}m`; - if (hours < 48) return `${Math.round(hours)}h`; - return `${Math.round(hours / 24)}d`; -} -var secondsOf = (ms) => String(Math.max(0, Math.round((Number(ms) || 0) / 1e3))); -function retryCountdown(retryAt, now = Date.now()) { - const at = Number(retryAt); - if (!Number.isFinite(at) || at <= 0) return null; - return Math.max(0, Math.round((at - now) / 1e3)); -} -var loadingText = (elapsedMs) => t("First load in progress, {seconds} s elapsed", { seconds: secondsOf(elapsedMs) }); -var staleText = (asOf, error) => t("Showing the snapshot from {time} · refresh failed: {message}", { - time: clockOf(asOf), - message: String(error?.message ?? error ?? "") -}); -function hintFor(error, { repo = null } = {}) { - if (!error) return ""; - const scope = error.scope ?? null; - const kind = error.kind ?? null; - const target = repo ?? t("this repository"); - if (kind === "no-repo") return t("Set delivery.repo in this instance's settings to the repository this instance works in."); - if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); - if (kind === "rate-limit") { - return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); - } - if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); - if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); - if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); - if ((scope === "comments" || scope === "issues") && (kind === "not-found" || kind === "forbidden")) return t("Give the token Issues: Read."); - if (scope === "pulls" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Pull requests: Read."); - if (scope === "repo" || kind === "not-found" || kind === "forbidden" || kind === "auth") { - return t("Check the repository name, and that the token can see {repo}.", { repo: target }); - } - if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); - return t("The next attempt happens while this page is open; the reason above names what failed."); -} -function missingText(entry) { - const status = entry?.status === null || entry?.status === void 0 ? "" : ` · ${entry.status}`; - const part = entry?.part === "ci" ? t("CI data") : entry?.part === "budget" ? t("The rest of the board") : entry?.part ?? t("Part of the board"); - const where = entry?.repo ? `${entry.repo} · ` : ""; - return t("{part} was not fetched this time: {message}", { part: `${where}${part}`, message: `${String(entry?.message ?? "")}${status}` }); -} -var clockOf = (iso) => { - const at = Date.parse(iso ?? ""); - if (!Number.isFinite(at)) return "—"; - return new Date(at).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit" }); -}; -var timeOf = (iso) => { - const at = Date.parse(iso ?? ""); - if (!Number.isFinite(at)) return "—"; - return new Date(at).toLocaleString(void 0, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -}; -function metricRows(snapshot) { - const today = snapshot?.today ?? {}; - const week = snapshot?.week ?? {}; - const ci = today.ci ?? null; - const share = (entry) => { - if (!entry || !Number.isFinite(entry.failed)) return "—"; - const denominator = Number.isFinite(entry.counted) && entry.counted > 0 ? entry.counted : Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null; - return denominator === null ? "—" : `${Math.round(entry.failed / denominator * 100)}%`; - }; - const count = (value) => value === null || value === void 0 ? "—" : String(value); - const rows = [ - { label: t("New issues"), key: "newIssues" }, - { label: t("Closed issues"), key: "closedIssues" }, - { label: t("New pull requests"), key: "newPullRequests" }, - { label: t("Merged pull requests"), key: "mergedPullRequests" }, - { key: "ci", label: t("CI red share"), today: share(today.ci), week: share(week.ci) }, - { label: t("Median issue close"), key: "medianIssueCloseMs", format: duration }, - { label: t("Median PR merge"), key: "medianPullMergeMs", format: duration } - ].map((row) => { - if (row.today !== void 0) return { key: row.key, label: row.label, today: row.today, week: row.week }; - const format = row.format ?? count; - return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) }; - }); - return { - dayStart: snapshot?.dayStart ?? null, - rows, - // `null` is "not read this time", which the header prints as unknown; a real 0 stays a 0. - ci: ci === null ? null : { - failed: ci.failed ?? 0, - total: ci.total ?? 0, - counted: ci.counted ?? 0, - caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total, - superseded: ci.superseded ?? 0, - unknownDays: ci.unknownDays ?? [] - }, - open: snapshot?.open ?? {} - }; -} -function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { - let stopped = false; - let timer = null; - let state = "idle"; - let last = null; - const schedule = () => { - if (stopped) return; - if (timer !== null) clearTimer(timer); - timer = setTimer(() => { - timer = null; - void run(); - }, pollDelay(state, last, now())); - }; - const run = async (force = false) => { - try { - const next = await load(force); - if (stopped) return; - if (next && typeof next === "object") { - last = next; - state = typeof next.state === "string" ? next.state : "fresh"; - onView(next); - } else { - state = "failed"; - last = null; - onView({ state: "failed", error: { message: "The host did not answer", kind: "network", endpoint: null, status: null, at: null } }); - } - schedule(); - } catch (error) { - if (stopped) return; - state = "failed"; - last = null; - onView({ state: "failed", error: { message: String(error?.message ?? error), kind: "network", endpoint: null, status: null, at: null } }); - schedule(); - } - }; - return { - start: () => { - void run(); - }, - // Retry: the screen changes now, then the force read runs; the old view is kept so its columns - // do not flash away while the round starts. - retry: (from = null) => { - if (stopped) return; - state = "loading"; - last = null; - onView({ ...from && typeof from === "object" ? from : {}, state: "loading", since: new Date(now()).toISOString(), elapsedMs: 0, refreshing: false }); - if (timer !== null) { - clearTimer(timer); - timer = null; - } - void run(true); - }, - stop: () => { - stopped = true; - if (timer !== null) clearTimer(timer); - timer = null; - } - }; -} -function repoSourceText(source) { - if (source === "config") return t("from config"); - if (source === "remote") return t("from remote"); - if (source === "package") return t("from the package (default)"); - return null; -} -function useDeliveryBoard(actions) { - const [view, setView] = (0, import_react16.useState)({ state: "idle" }); - const [retry, setRetry] = (0, import_react16.useState)(() => () => { - }); - (0, import_react16.useEffect)(() => { - let current = { state: "idle" }; - const poller = createPoller({ - load: (force) => typeof actions?.deliveryBoard === "function" ? actions.deliveryBoard(force ? { force: true } : {}) : Promise.resolve(null), - onView: (next) => { - current = next; - setView(next); - } - }); - setRetry(() => () => poller.retry(current)); - poller.start(); - return () => poller.stop(); - }, [actions]); - return [view, retry]; -} -function probeText(report) { - if (!report) return ""; - const repos = Array.isArray(report.repos) ? report.repos : []; - const head = report.repo ?? `${repos.length} repositories`; - const lines = [`${head}${report.repoSource ? ` · ${report.repoSource}` : ""} · ${timeOf(report.at)}`]; - for (const row of report.rows ?? []) { - const status = row.status === null || row.status === void 0 ? "—" : String(row.status); - const where = report.repo === null && row.repo ? `${row.repo} ` : ""; - lines.push(`${where}${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); - } - if (report.error) lines.push(String(report.error)); - return lines.join("\n"); -} -function Diagnose({ onProbe }) { - const [report, setReport] = (0, import_react16.useState)(null); - const [busy, setBusy] = (0, import_react16.useState)(false); - const run = () => { - if (busy) return; - setBusy(true); - Promise.resolve(onProbe?.()).then((next) => { - setReport(next ?? { rows: [], error: "no answer" }); - }).catch((error) => setReport({ rows: [], error: String(error?.message ?? error) })).finally(() => setBusy(false)); - }; - return (0, import_react16.createElement)( - "span", - { className: "bt-del-diag" }, - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: run, "aria-label": t("Diagnose") }, busy ? t("Checking…") : t("Diagnose")), - report ? (0, import_react16.createElement)( - "div", - { className: "bt-del-diag-box" }, - (0, import_react16.createElement)("pre", { className: "bt-del-diag-text", "data-probe": "report" }, probeText(report)), - (0, import_react16.createElement)("button", { - type: "button", - className: "bt-soft bt-soft-sm", - onClick: () => { - try { - globalThis.navigator?.clipboard?.writeText?.(probeText(report)); - } catch { - } - } - }, t("Copy")), - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setReport(null) }, t("Close")) - ) : null - ); -} -function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { - const payload = view ?? snapshot; - if (!payload) return null; - const metricsPayload = payload.metrics ?? payload; - const { rows, ci, open } = metricRows(metricsPayload); - const error = payload.error ?? null; - const state = payload.state ?? "fresh"; - const repoName = payload.repo ?? repo ?? null; - const repoSource = payload.repoSource ?? null; - const sourceText = repoSourceText(repoSource); - const repoCount = Array.isArray(payload.repos) ? payload.repos.length : 0; - const shared2 = repoCount > 1; - const openCount = (value) => value === null || value === void 0 ? "—" : String(value); - const partial = state !== "loading" && todayPartial(metricsPayload.daily); - return (0, import_react16.createElement)( - "header", - { className: "bt-del-head" }, - // Whose numbers these are, and where the name came from (#1250): a package default that looks - // like a fact is what showed one instance another repository's board. - (0, import_react16.createElement)( - "p", - { className: "bt-del-repo", "data-repo": shared2 ? void 0 : repoName ?? void 0, "data-repo-source": shared2 ? void 0 : repoSource ?? void 0, "data-repos": shared2 ? String(repoCount) : void 0 }, - shared2 ? t("{count} repositories", { count: repoCount }) : repoName === null ? t("no repository — set delivery.repo") : repoName, - shared2 || sourceText === null ? "" : ` · ${sourceText}` - ), - (0, import_react16.createElement)( - "div", - { className: "bt-del-metrics" }, - (0, import_react16.createElement)(DeliveryMetrics, { rows, daily: metricsPayload.daily }), - // An unreadable CI number is "unknown", never "0 of 0" (#1211). - (0, import_react16.createElement)( - "p", - { className: "bt-del-ci", "data-ci": ci === null ? "unknown" : "read" }, - ci === null ? t("CI not available this time") : t("CI failed {failed} of {total}", { failed: ci.failed, total: ci.total }), - ci !== null && ci.caveat ? ` · ${t("counted {counted} of {total}", { counted: ci.counted, total: ci.total })}` : "", - ci !== null && ci.superseded ? ` · ${t("{count} superseded", { count: ci.superseded })}` : "", - ci !== null && ci.unknownDays?.length ? ` · ${t("{count} day(s) not read", { count: ci.unknownDays.length })}` : "" - ), - (0, import_react16.createElement)("p", { className: "bt-del-open" }, t("Open: {issues} issues ({needs} need a human), {prs} pull requests", { - issues: openCount(open.issues), - needs: openCount(open.needsHuman), - prs: openCount(open.pullRequests) - }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== void 0 ? ` · ${t("median open age {age}", { age: duration(open.medianIssueAgeMs) })}` : "") - ), - (0, import_react16.createElement)( - "p", - { className: "bt-del-asof" }, - state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), - partial ? ` · ${t("today is partial")}` : "", - payload.refreshing ? ` · ${t("refreshing…")}` : "", - error ? ` · ${hintFor(error, { repo })}` : "", - onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, - onProbe ? (0, import_react16.createElement)(Diagnose, { onProbe }) : null - ) - ); -} -var BOARD_FILTERS = ["all", "review", "done-today"]; -function BoardFilters({ filter, onFilter }) { - return (0, import_react16.createElement)( - "div", - { className: "bt-del-filters", role: "group", "aria-label": t("Filter") }, - BOARD_FILTERS.map((id) => (0, import_react16.createElement)("button", { - key: id, - type: "button", - className: "bt-soft bt-soft-sm", - "data-filter": id, - "aria-pressed": filter === id ? "true" : "false", - onClick: () => onFilter(id) - }, id === "all" ? t("All") : id === "review" ? t("Review") : t("Done today"))) - ); -} -var stageOf = (card, column) => card?.stage ?? column; -var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; -function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { - const columns = view?.columns ?? {}; - const drawn = placed === null || placed === void 0 ? null : new Set(placed); - const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); - const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; - for (const task of filter === "all" ? tasks ?? [] : []) { - const col = taskColumnOf(task); - if (tasksByColumn[col]) tasksByColumn[col].push(task); - } - const allCards = (id) => { - const delivery = deliveryCards(id); - const taskList = tasksByColumn[id] ?? []; - return [...taskList, ...delivery]; - }; - const taskIds = new Set((tasks ?? []).map((task) => task?.id)); - const isTask = (item) => taskIds.has(item?.id); - return (0, import_react16.createElement)( - "div", - { className: "bt-del-columns" }, - stagesOf(view).map((column) => (0, import_react16.createElement)( - "section", - { - key: column.id, - className: "bt-del-column", - "data-column": column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }) - }, - (0, import_react16.createElement)( - "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(allCards(column.id).length)) - ), - allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) - )) - ); -} -function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { - 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) => { - const cards = column.cards.filter((card) => matchesFilter(card, column.id, filter)); - return (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(cards.length)) - ), - cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : 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 multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) { - const successful = (entries2 ?? []).filter((entry) => entry?.columns !== void 0); - const failed = (entries2 ?? []).filter((entry) => entry?.columns === void 0); - const merged = { columns: { "not-started": [], "in-progress": [], "done": [] } }; - for (const entry of successful) { - for (const [column, cards] of Object.entries(entry.columns)) { - if (merged.columns[column]) { - merged.columns[column].push(...cards.map((card) => ({ ...card, repo: card.repo ?? entry.repo }))); - } - } - } - return (0, import_react16.createElement)( - "div", - { className: "bt-del-multi" }, - // Successful repos: one shared column set with all cards and tasks once - successful.length > 0 ? (0, import_react16.createElement)(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) : null, - // Failed repos: show error blocks - failed.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { - key: entry.repo ?? "unknown", - entry, - tasks: [], - // Don't pass tasks to failed repo blocks - roster, - dragging, - onMove, - onDrag, - filter - })) - ); -} -function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { - const state = entry?.state ?? "fresh"; - const source = repoSourceText(entry?.repoSource); - return (0, import_react16.createElement)( - "section", - { className: "bt-del-repo-block", "data-repo": entry?.repo ?? void 0, "data-state": state }, - (0, import_react16.createElement)( - "h3", - { className: "bt-del-repo-head" }, - entry?.repo ?? t("no repository — set delivery.repo"), - source === null ? "" : ` · ${source}` - ), - entry?.columns === void 0 ? (0, import_react16.createElement)( - "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 })}` : "" - ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) - ); -} -function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { - const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); - const state = payload.state ?? "fresh"; - const retryAt = Number(payload.retryAt); - const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; - const [now, setNow] = (0, import_react16.useState)(() => Date.now()); - (0, import_react16.useEffect)(() => { - if (!ticking) return void 0; - setNow(Date.now()); - const timer = setInterval(() => setNow(Date.now()), 1e3); - return () => clearInterval(timer); - }, [ticking, retryAt]); - const countdown = retryCountdown(payload.retryAt, now); - const [filter, setFilter] = (0, import_react16.useState)("all"); - const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; - const hasTasks = Array.isArray(tasks) && tasks.length > 0; - if (state === "idle") { - return (0, import_react16.createElement)( - "div", - { className: "bt-del-body", "data-view": "board", "data-state": state }, - (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) - ); - } - if (state === "loading") { - return (0, import_react16.createElement)( - "div", - { className: "bt-del-body", "data-view": "board", "data-state": state }, - (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - (0, import_react16.createElement)("p", { className: "bt-del-loading", role: "status" }, loadingText(payload.elapsedMs)), - (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("The first round reads the repository once; the columns appear as soon as it lands.")) - ); - } - const hasBoard = Boolean(payload.columns) || entries2 !== null && entries2.some((entry) => entry.columns !== void 0); - if (state === "failed" || !hasBoard) { - const error = payload.error ?? null; - return (0, import_react16.createElement)( - "div", - { className: "bt-del-body", "data-view": "board", "data-state": state }, - (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - (0, import_react16.createElement)( - "div", - { className: "bt-board-error", role: "alert" }, - (0, import_react16.createElement)("p", null, String(error?.message ?? message ?? t("The delivery board is not ready yet."))), - error ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, hintFor(error, { repo })) : null, - (0, import_react16.createElement)( - "p", - { className: "bt-del-hint", "data-retry-at": payload.retryAt ?? void 0 }, - countdown === null ? t("While this page is open it retries on its own; press Retry to try again now.") : t("Retrying in {seconds} s · press Retry to try again now", { seconds: countdown }) - ), - onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null - ) - ); - } - const missing = payload.missing ?? []; - return (0, import_react16.createElement)( - "div", - { className: "bt-del-body", "data-view": "board", "data-state": state }, - (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), - message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : null, - state === "stale" && payload.error ? (0, import_react16.createElement)( - "p", - { className: "bt-del-stale", role: "status" }, - staleText(payload.asOf, payload.error), - ` · ${hintFor(payload.error, { repo })}`, - countdown === null ? "" : ` · ${t("Retrying in {seconds} s · press Retry to try again now", { seconds: countdown })}` - ) : 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 }), - // 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, filter }) : null, - entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) - ); -} -function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { - const [view, retry] = useDeliveryBoard(actions); - return (0, import_react16.createElement)(DeliveryBoard, { - view, - repo: view?.repo ?? null, - tasks, - roster, - dragging, - onMove, - onDrag, - 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, "data-stage": card?.stage ?? 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-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} -.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} -.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; +var import_react15 = require("react"); +var POLL_MS3 = 15e3; function BoardIcon() { - return (0, import_react17.createElement)( + return (0, import_react15.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 }) + (0, import_react15.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), + (0, import_react15.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), + (0, import_react15.createElement)("rect", { x: 13.5, y: 3, width: 4, height: 12, rx: 1 }) ); } function shortId(id) { @@ -9256,11 +8253,11 @@ function lanesOf(tasks, roster) { var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; var without = (list, id) => list.filter((item) => item.id !== id); function useBoardTasks(actions, scope) { - const [own, setOwn] = (0, import_react17.useState)([]); - const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]); - const [archive, setArchive] = (0, import_react17.useState)([]); + const [own, setOwn] = (0, import_react15.useState)([]); + const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); + const [archive, setArchive] = (0, import_react15.useState)([]); const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; - (0, import_react17.useEffect)(() => { + (0, import_react15.useEffect)(() => { let stopped = false; const load = () => { const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; @@ -9277,7 +8274,7 @@ function useBoardTasks(actions, scope) { }).catch(() => void 0); }; load(); - const timer = setInterval(load, POLL_MS4); + const timer = setInterval(load, POLL_MS3); return () => { stopped = true; clearInterval(timer); @@ -9294,7 +8291,7 @@ function useBoardTasks(actions, scope) { return { own, tasks, archive: done, replace }; } function RailButton({ row, selected, meta, onSelect }) { - return (0, import_react17.createElement)( + return (0, import_react15.createElement)( "button", { type: "button", @@ -9306,8 +8303,8 @@ function RailButton({ row, selected, meta, onSelect }) { style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) }, - (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), - meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null + (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), + meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ); } function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { @@ -9317,7 +8314,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) const who = botName(roster, task); const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; const overall = task.kind === "group"; - return (0, import_react17.createElement)(BoardCard, { + return (0, import_react15.createElement)(BoardCard, { card: taskCard(task), skin: TASK_SKIN, attrs: { @@ -9329,33 +8326,33 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) idText: shortId(task.id), emptyTitle: t("Untitled task"), topExtra: [ - (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), - (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + (0, import_react15.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), + (0, import_react15.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) ], note: task.note ?? "", meta: [ - overall ? (0, import_react17.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, - partOf2 ? (0, import_react17.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, - question ? (0, import_react17.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null + overall ? (0, import_react15.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, + partOf2 ? (0, import_react15.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, + question ? (0, import_react15.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null ], - footer: (0, import_react17.createElement)( + footer: (0, import_react15.createElement)( "span", { className: "bt-board-foot" }, - who ? (0, import_react17.createElement)("span", { className: "bt-board-who" }, who) : null, - (0, import_react17.createElement)( + who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, + (0, import_react15.createElement)( "span", { className: "bt-board-progress" }, - (0, import_react17.createElement)( + (0, import_react15.createElement)( "span", { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, - (0, import_react17.createElement)("span", { style: { width: `${progress}%` } }) + (0, import_react15.createElement)("span", { style: { width: `${progress}%` } }) ), - (0, import_react17.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) + (0, import_react15.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) ) ), actions: [ - movable && column === "not-started" ? (0, import_react17.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, - movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react17.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null + movable && column === "not-started" ? (0, import_react15.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, + movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react15.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null ], draggable: movable, dragValue: task.id, @@ -9370,7 +8367,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) const tree = lanes ? columnTree(tasks) : null; const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; - return (0, import_react17.createElement)( + return (0, import_react15.createElement)( "section", { className: "bt-board-column", @@ -9387,31 +8384,31 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) onDrag?.(null); } }, - (0, import_react17.createElement)( + (0, import_react15.createElement)( "h3", { className: "bt-board-column-head" }, - (0, import_react17.createElement)("span", null, columnTitle(column)), - (0, import_react17.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) + (0, import_react15.createElement)("span", null, columnTitle(column)), + (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) ), - dragging ? (0, import_react17.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, - tasks.length === 0 && !dragging ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, - tree ? tree.nests.map((nest) => (0, import_react17.createElement)( + dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, + tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, + tree ? tree.nests.map((nest) => (0, import_react15.createElement)( "div", { key: nest.task.id, className: "bt-board-nest" }, - (0, import_react17.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), - nest.children.map((child) => (0, import_react17.createElement)( + (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), + nest.children.map((child) => (0, import_react15.createElement)( "div", { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, - (0, import_react17.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) + (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) )) )) : null, - linked.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), - lanes ? lanesOf(plain, roster).map((lane) => (0, import_react17.createElement)( + linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), + lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( "div", { key: lane.id, className: "bt-board-lane" }, - (0, import_react17.createElement)("span", { className: "bt-board-lane-name" }, lane.name), - lane.tasks.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) - )) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), + lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) ); } function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { @@ -9419,16 +8416,16 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro const lanes = scope?.kind === "group"; const selectedBot = scope?.kind === "bot" ? scope.botId : ""; const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; - return (0, import_react17.createElement)( + return (0, import_react15.createElement)( "div", { className: "bt-board-body" }, - (0, import_react17.createElement)( + (0, import_react15.createElement)( "aside", { className: "bt-board-rail", "aria-label": t("Task board") }, - (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), - rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), + rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { const count = counts2?.[row.id] ?? { done: 0, total: 0 }; - return (0, import_react17.createElement)(RailButton, { + return (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedBot, @@ -9436,27 +8433,27 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" }); }), - rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, - rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) + rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, + rail.groups.map((row) => (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) ), - (0, import_react17.createElement)( + (0, import_react15.createElement)( "div", { className: "bt-board-main" }, - error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, - lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, - scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( + error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, + scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( "form", { className: "bt-board-add", onSubmit: (event) => { event.preventDefault(); onCreate(draft); } }, - (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), - (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) + (0, import_react15.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), + (0, import_react15.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) ) : null, - (0, import_react17.createElement)( + (0, import_react15.createElement)( "div", { className: "bt-board-columns" }, - ["not-started", "in-progress", "done"].map((column) => (0, import_react17.createElement)(BoardColumn, { + ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { key: column, column, tasks: columns[column], @@ -9473,7 +8470,7 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro function KanbanRow({ wide, useUi, actions }) { const open = useUi ? useUi((value) => Boolean(value.board)) : false; const label = t("Tasks"); - return (0, import_react17.createElement)( + return (0, import_react15.createElement)( "button", { type: "button", @@ -9484,8 +8481,8 @@ function KanbanRow({ wide, useUi, actions }) { "aria-expanded": open, onClick: () => actions.openBoard() }, - (0, import_react17.createElement)("span", { className: "bt-board-mark" }, (0, import_react17.createElement)(BoardIcon)), - wide ? (0, import_react17.createElement)("span", { className: "bt-board-label" }, label) : null + (0, import_react15.createElement)("span", { className: "bt-board-mark" }, (0, import_react15.createElement)(BoardIcon)), + wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null ); } function scopeName(roster, scope) { @@ -9493,57 +8490,15 @@ function scopeName(roster, scope) { if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; return ""; } -function UnifiedBoard({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { - const selectedBot = scope?.kind === "bot" ? scope.botId : ""; - const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; - const lanes = scope?.kind === "group"; - return (0, import_react17.createElement)( - "div", - { className: "bt-board-body" }, - (0, import_react17.createElement)( - "aside", - { className: "bt-board-rail", "aria-label": t("Task board") }, - (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), - rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { - const count = counts2?.[row.id] ?? { done: 0, total: 0 }; - return (0, import_react17.createElement)(RailButton, { - key: row.id, - row, - selected: row.id === selectedBot, - onSelect, - meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" - }); - }), - rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, - rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) - ), - (0, import_react17.createElement)( - "div", - { className: "bt-board-main" }, - scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( - "form", - { className: "bt-board-add", onSubmit: (event) => { - event.preventDefault(); - onCreate(draft); - } }, - (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), - (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) - ) : null, - error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, - lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, - (0, import_react17.createElement)(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }) - ) - ); -} function KanbanDialog({ roster, actions, leaving = false }) { const rail = boardRail(roster); - const [scope, setScope] = (0, import_react17.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); - const [dragging, setDragging] = (0, import_react17.useState)(null); - const [error, setError] = (0, import_react17.useState)(""); - const [draft, setDraft] = (0, import_react17.useState)(""); + const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); + const [dragging, setDragging] = (0, import_react15.useState)(null); + const [error, setError] = (0, import_react15.useState)(""); + const [draft, setDraft] = (0, import_react15.useState)(""); const { own, tasks, archive, replace } = useBoardTasks(actions, scope); const boardTasks = boardTasksFor(tasks, archive); - const close = (0, import_react17.useCallback)(() => actions.closeBoard(), []); + const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); useEscape(close); const counts2 = {}; for (const task of [...own, ...archive]) { @@ -9575,22 +8530,22 @@ function KanbanDialog({ roster, actions, leaving = false }) { } }; const name = scopeName(roster, scope); - return (0, import_react17.createElement)( + return (0, import_react15.createElement)( "div", { className: "bt-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, - (0, import_react17.createElement)("div", { className: "bt-scrim", onClick: close }), - (0, import_react17.createElement)( + (0, import_react15.createElement)("div", { className: "bt-scrim", onClick: close }), + (0, import_react15.createElement)( "div", { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, - (0, import_react17.createElement)( + (0, import_react15.createElement)( "div", { className: "bt-mem-head" }, - (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, t("Board")), - (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name), - (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) + (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), + (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), + (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) ), - (0, import_react17.createElement)(UnifiedBoard, { + (0, import_react15.createElement)(BoardView, { roster, tasks: boardTasks, rail, @@ -9599,7 +8554,6 @@ function KanbanDialog({ roster, actions, leaving = false }) { dragging, error, draft, - actions, onDraft: setDraft, onSelect: setScope, onMove: move, @@ -9611,8 +8565,6 @@ function KanbanDialog({ roster, actions, leaving = false }) { } var KANBAN_CSS = ` .bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -.bt-board-views{display:flex;gap:6px;align-items:center} -.bt-board-views .bt-soft[aria-selected=true]{background:var(--bt-active,var(--bt-hover));color:var(--bt-ink)} .bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} .bt-board-mark svg{width:18px;height:18px} .bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -9673,25 +8625,25 @@ var KANBAN_CSS = ` .bt-board-columns{flex-direction:column} .bt-board-column{min-width:0} } -` + DELIVERY_CSS; +`; // src/client/sidebar.js -var import_react18 = require("react"); +var import_react16 = require("react"); var import_react_dom3 = require("react-dom"); var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); function useMarkedUnread(id, active, marked, actions) { - (0, import_react18.useEffect)(() => { + (0, import_react16.useEffect)(() => { if (active && marked) actions.clearUnread(id); }, [active, marked]); } -var PinMark = () => (0, import_react18.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react18.createElement)(PinMarkIcon)); +var PinMark = () => (0, import_react16.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react16.createElement)(PinMarkIcon)); var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { const [id, tool] = key.split("|"); return { id, tool: tool || void 0 }; }); -var workLine = (lead, tool, more) => (0, import_react18.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +var workLine = (lead, tool, more) => (0, import_react16.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { const id = bot.id; const part = currentPart(bot); @@ -9722,7 +8674,7 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio const secondLine = awayLine || preview || (remote ? bot.remote?.gone ? t("no longer shared") : t("Friend's Bot") : ""); const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); if (tile && wide) { - return (0, import_react18.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react18.createElement)( + return (0, import_react16.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react16.createElement)( "button", { type: "button", @@ -9734,15 +8686,15 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, - (0, import_react18.createElement)(BotAvatar, { bot, size: 60, state, main: true }), - (0, import_react18.createElement)("span", { className: "bt-tile-name" }, shown), + (0, import_react16.createElement)(BotAvatar, { bot, size: 60, state, main: true }), + (0, import_react16.createElement)("span", { className: "bt-tile-name" }, shown), // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. - unread && !waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, - bot.role ? (0, import_react18.createElement)("span", { className: "bt-chip" }, bot.role) : null + unread && !waiting && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, + bot.role ? (0, import_react16.createElement)("span", { className: "bt-chip" }, bot.role) : null )); } - const unreadMark = wide && waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; - const row = (0, import_react18.createElement)( + const unreadMark = wide && waiting && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; + const row = (0, import_react16.createElement)( "button", { type: "button", @@ -9755,24 +8707,24 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio onClick: () => actions.openSession(id), onContextMenu }, - (0, import_react18.createElement)(BotAvatar, { bot, size: 36, state, main }), - pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, - wide ? (0, import_react18.createElement)( + (0, import_react16.createElement)(BotAvatar, { bot, size: 36, state, main }), + pinned && !wide ? (0, import_react16.createElement)(PinMark) : null, + wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "span", { className: "bt-row-name" }, - (0, import_react18.createElement)("span", { className: "bt-row-title" }, shown), - bot.remote ? (0, import_react18.createElement)(RemoteMark, { remote: bot.remote }) : null, - pinned ? (0, import_react18.createElement)(PinMark) : null + (0, import_react16.createElement)("span", { className: "bt-row-title" }, shown), + bot.remote ? (0, import_react16.createElement)(RemoteMark, { remote: bot.remote }) : null, + pinned ? (0, import_react16.createElement)(PinMark) : null ), - secondLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, secondLine) : null + secondLine ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, secondLine) : null ) : null, unreadMark ); const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); - const settings = wide ? (0, import_react18.createElement)("button", { + const settings = wide ? (0, import_react16.createElement)("button", { type: "button", className: "bt-row-settings", "aria-label": settingsLabel, @@ -9783,8 +8735,8 @@ function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessio keepSidebarStill(event.currentTarget); actions.openSettings("bots", id); } - }, (0, import_react18.createElement)(GearIcon)) : null; - if (settings) return (0, import_react18.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); + }, (0, import_react16.createElement)(GearIcon)) : null; + if (settings) return (0, import_react16.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); return row; } function keepSidebarStill(node) { @@ -9797,14 +8749,14 @@ function keepSidebarStill(node) { if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); } function RemoteMark({ remote }) { - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "span", { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "svg", { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react18.createElement)("circle", { cx: 12, cy: 12, r: 9 }), - (0, import_react18.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + (0, import_react16.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react16.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ) ); } @@ -9816,15 +8768,15 @@ function isPastFaceRoom(room2) { return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); } function FaceMark() { - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "span", { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "svg", { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react18.createElement)("circle", { cx: 9, cy: 8, r: 3 }), - (0, import_react18.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), - (0, import_react18.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + (0, import_react16.createElement)("circle", { cx: 9, cy: 8, r: 3 }), + (0, import_react16.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), + (0, import_react16.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) ) ); } @@ -9849,7 +8801,7 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, const alert = question || waiting; const face = room2.origin === "face-to-face"; const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "button", { type: "button", @@ -9866,20 +8818,20 @@ function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, actions.openMenu({ kind: "room", id, x: event.clientX, y: event.clientY }); } }, - (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), - pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, - wide ? (0, import_react18.createElement)( + (0, import_react16.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), + pinned && !wide ? (0, import_react16.createElement)(PinMark) : null, + wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react18.createElement)("span", { className: "bt-row-name" }, (0, import_react18.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react18.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react18.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react18.createElement)(PinMark) : null), - workingLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null + (0, import_react16.createElement)("span", { className: "bt-row-name" }, (0, import_react16.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react16.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react16.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react16.createElement)(PinMark) : null), + workingLine ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && alert && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + wide && alert && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ); } function AgentSessionRow({ session, agent, actions, renaming, index }) { - const input = (0, import_react18.useRef)(null); - (0, import_react18.useLayoutEffect)(() => { + const input = (0, import_react16.useRef)(null); + (0, import_react16.useLayoutEffect)(() => { if (!renaming) return; input.current?.focus(); input.current?.select(); @@ -9900,10 +8852,10 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { } })(); }; - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "div", { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, - (0, import_react18.createElement)("input", { + (0, import_react16.createElement)("input", { ref: input, defaultValue: session.title, "aria-label": t("Rename"), @@ -9916,7 +8868,7 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { }) ); } - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "button", { type: "button", @@ -9930,8 +8882,8 @@ function AgentSessionRow({ session, agent, actions, renaming, index }) { actions.openMenu({ kind: "agent-session", id: session.id, agentId: agent.id, x: event.clientX, y: event.clientY }); } }, - (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, session.title), - (0, import_react18.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") + (0, import_react16.createElement)("span", { className: "bt-agent-sub-title" }, session.title), + (0, import_react16.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { @@ -9966,10 +8918,10 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, const preview = sessions[0]?.title ?? t("New session"); const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "div", { className: "bt-agent" }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "button", { type: "button", @@ -9984,30 +8936,30 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, actions.openMenu({ kind: "agent", id: agent.id, x: event.clientX, y: event.clientY }); } }, - (0, import_react18.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react18.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), - wide ? (0, import_react18.createElement)( + (0, import_react16.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react16.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), + wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react18.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), - preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null + (0, import_react16.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), + preview ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ), - (0, import_react18.createElement)( + (0, import_react16.createElement)( "div", { className: "bt-agent-sub", "data-open": open || void 0 }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "div", { className: "bt-agent-sub-in" }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "button", { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); } }, - (0, import_react18.createElement)(PlusIcon), - (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) + (0, import_react16.createElement)(PlusIcon), + (0, import_react16.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) ), - sessions.map((session, index) => (0, import_react18.createElement)(AgentSessionRow, { + sessions.map((session, index) => (0, import_react16.createElement)(AgentSessionRow, { key: session.id, session, agent, @@ -10021,8 +8973,8 @@ function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, } var GLIDE = springEasing(1e3, 63); function useListGlide(listRef, layout, key) { - const tops = (0, import_react18.useRef)({ layout, map: /* @__PURE__ */ new Map() }); - (0, import_react18.useLayoutEffect)(() => { + const tops = (0, import_react16.useRef)({ layout, map: /* @__PURE__ */ new Map() }); + (0, import_react16.useLayoutEffect)(() => { const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); @@ -10039,7 +8991,7 @@ function useListGlide(listRef, layout, key) { tops.current = { layout, map: next }; }, [layout, key]); } -var NodeActions = class extends import_react18.Component { +var NodeActions = class extends import_react16.Component { constructor(props) { super(props); this.state = { failed: false }; @@ -10051,32 +9003,32 @@ var NodeActions = class extends import_react18.Component { console.error("flybot: nodeActions failed", error); } render() { - return this.state.failed ? null : (0, import_react18.createElement)(this.props.component, { bot: this.props.bot }); + return this.state.failed ? null : (0, import_react16.createElement)(this.props.component, { bot: this.props.bot }); } }; function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { const { bot, depth, children } = node; const closed = collapsed[bot.id] === true; - return (0, import_react18.createElement)( - import_react18.Fragment, + return (0, import_react16.createElement)( + import_react16.Fragment, null, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "div", { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, - children.length ? (0, import_react18.createElement)( + children.length ? (0, import_react16.createElement)( "button", { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, - (0, import_react18.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") - ) : (0, import_react18.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), - (0, import_react18.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), - typeof actions === "function" ? (0, import_react18.createElement)(NodeActions, { component: actions, bot }) : null + (0, import_react16.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") + ) : (0, import_react16.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), + (0, import_react16.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), + typeof actions === "function" ? (0, import_react16.createElement)(NodeActions, { component: actions, bot }) : null ), - closed ? null : children.map((child) => (0, import_react18.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) + closed ? null : children.map((child) => (0, import_react16.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) ); } function TeamSidebar(props) { const { wide, useRoster, useUi, useSessions, actions } = props; - const listRef = (0, import_react18.useRef)(null); + const listRef = (0, import_react16.useRef)(null); const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); @@ -10085,7 +9037,7 @@ function TeamSidebar(props) { for (const id of list.ids) stamps[id] = list.byId[id]?.updatedAt ?? 0; return JSON.stringify(stamps); }); - const order = (0, import_react18.useMemo)(() => { + const order = (0, import_react16.useMemo)(() => { const stamps = JSON.parse(updated); const entries2 = [ // Own Bots, friends' Bots, groups and face rooms share one list. A remote chat @@ -10100,7 +9052,7 @@ function TeamSidebar(props) { }, [roster, updated]); const main = roster.byId[roster.mainBotId]; const structure = useSidebarStructure(); - const [collapsed, setCollapsed] = (0, import_react18.useState)({}); + const [collapsed, setCollapsed] = (0, import_react16.useState)({}); const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; @@ -10114,42 +9066,42 @@ function TeamSidebar(props) { event.preventDefault(); actions.openPalette(event.key); }; - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "div", { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, - wide ? (0, import_react18.createElement)( + wide ? (0, import_react16.createElement)( "div", { className: "bt-side-top" }, - (0, import_react18.createElement)("span", { className: "bt-brand" }, (0, import_react18.createElement)(BrandMark, { size: 22 }), (0, import_react18.createElement)(BrandName)), - (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), - (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react18.createElement)(SearchIcon)), - (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) - ) : (0, import_react18.createElement)( - import_react18.Fragment, + (0, import_react16.createElement)("span", { className: "bt-brand" }, (0, import_react16.createElement)(BrandMark, { size: 22 }), (0, import_react16.createElement)(BrandName)), + (0, import_react16.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react16.createElement)(SearchIcon)), + (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react16.createElement)(PlusIcon)) + ) : (0, import_react16.createElement)( + import_react16.Fragment, null, - (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), - (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) + (0, import_react16.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), + (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react16.createElement)(PlusIcon)) ), - wide && roster.readOnly ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, - (0, import_react18.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), - (0, import_react18.createElement)(KanbanRow, { wide, useUi, actions }), - (0, import_react18.createElement)( + wide && roster.readOnly ? (0, import_react16.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + (0, import_react16.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), + (0, import_react16.createElement)(KanbanRow, { wide, useUi, actions }), + (0, import_react16.createElement)( "div", { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, - tree ? (0, import_react18.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react18.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react18.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react18.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react18.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react18.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react18.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), - wide ? (0, import_react18.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react18.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null + tree ? (0, import_react16.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react16.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react16.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react16.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), + order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react16.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react16.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react16.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), + wide ? (0, import_react16.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react16.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); } function UpdateButton({ useUpdate, actions }) { const update = useUpdate((value) => value); - const [open, setOpen] = (0, import_react18.useState)(false); - const [box, setBox] = (0, import_react18.useState)(null); - const [error, setError] = (0, import_react18.useState)(""); - const [dshError, setDshError] = (0, import_react18.useState)(""); - const button = (0, import_react18.useRef)(null); - (0, import_react18.useEffect)(() => { + const [open, setOpen] = (0, import_react16.useState)(false); + const [box, setBox] = (0, import_react16.useState)(null); + const [error, setError] = (0, import_react16.useState)(""); + const [dshError, setDshError] = (0, import_react16.useState)(""); + const button = (0, import_react16.useRef)(null); + (0, import_react16.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; @@ -10192,10 +9144,10 @@ function UpdateButton({ useUpdate, actions }) { const message = error || (phase === "failed" ? update.error : ""); const dshMessage = dshError || (dshPhase === "failed" ? dsh?.error : ""); const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); - return (0, import_react18.createElement)( - import_react18.Fragment, + return (0, import_react16.createElement)( + import_react16.Fragment, null, - (0, import_react18.createElement)("button", { + (0, import_react16.createElement)("button", { ref: button, type: "button", className: "bt-round bt-update", @@ -10205,21 +9157,21 @@ function UpdateButton({ useUpdate, actions }) { "aria-haspopup": "dialog", "aria-expanded": open, onClick: toggle - }, (0, import_react18.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react18.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), - open && box ? (0, import_react_dom3.createPortal)((0, import_react18.createElement)( + }, (0, import_react16.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react16.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), + open && box ? (0, import_react_dom3.createPortal)((0, import_react16.createElement)( "div", { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, - update?.available || phase !== "idle" ? (0, import_react18.createElement)( - import_react18.Fragment, + update?.available || phase !== "idle" ? (0, import_react16.createElement)( + import_react16.Fragment, null, - (0, import_react18.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), - (0, import_react18.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), - message ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, - (0, import_react18.createElement)( + (0, import_react16.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), + (0, import_react16.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), + message ? (0, import_react16.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, + (0, import_react16.createElement)( "div", { className: "bt-update-actions" }, - update.notesUrl ? (0, import_react18.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, - phase === "installed" ? null : (0, import_react18.createElement)("button", { + update.notesUrl ? (0, import_react16.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, + phase === "installed" ? null : (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", disabled: phase === "installing", @@ -10227,17 +9179,17 @@ function UpdateButton({ useUpdate, actions }) { }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update")) ) ) : null, - dshRow ? (0, import_react18.createElement)( + dshRow ? (0, import_react16.createElement)( "div", { className: "bt-update-dsh" }, - (0, import_react18.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), - (0, import_react18.createElement)("div", { className: "bt-update-text" }, dshText), - dshMessage ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, - (0, import_react18.createElement)( + (0, import_react16.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), + (0, import_react16.createElement)("div", { className: "bt-update-text" }, dshText), + dshMessage ? (0, import_react16.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, + (0, import_react16.createElement)( "div", { className: "bt-update-actions" }, - dsh.kind === "app" ? dsh.available ? (0, import_react18.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { - }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react18.createElement)("button", { + dsh.kind === "app" ? dsh.available ? (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { + }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", disabled: dshPhase === "installing", @@ -10250,23 +9202,23 @@ function UpdateButton({ useUpdate, actions }) { } function ConnectApps({ wide, actions }) { if (!wide) return null; - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "span", { className: "bt-connect-wrap" }, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "button", { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, - (0, import_react18.createElement)(PlugIcon), + (0, import_react16.createElement)(PlugIcon), t("Connect apps") ) ); } function YouAvatar() { - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "span", { className: "bt-you-wrap" }, - (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), - (0, import_react18.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) + (0, import_react16.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react16.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), + (0, import_react16.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ); } var openShellSettings = null; @@ -10298,7 +9250,7 @@ function openHarnessOnboarding(id) { var MODELS_STEP_ID = "flybot-models"; var MODELS_STEP_ORDER = 1e3; function ModelsSettingsStep({ explicit, complete, openSection }) { - (0, import_react18.useLayoutEffect)(() => { + (0, import_react16.useLayoutEffect)(() => { if (explicit) openSection("models"); complete(); }, []); @@ -10336,32 +9288,32 @@ function pressSettingsShortcut() { })); } function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { - const [open, setOpen] = (0, import_react18.useState)(false); - (0, import_react18.useLayoutEffect)(() => { + const [open, setOpen] = (0, import_react16.useState)(false); + (0, import_react16.useLayoutEffect)(() => { if (!seat) return void 0; actions.seatAccount(true); return () => actions.seatAccount(false); }, [seat]); - const [box, setBox] = (0, import_react18.useState)(null); - const button = (0, import_react18.useRef)(null); - (0, import_react18.useEffect)(() => { + const [box, setBox] = (0, import_react16.useState)(null); + const button = (0, import_react16.useRef)(null); + (0, import_react16.useEffect)(() => { if (settingsOpen) setOpen(false); }, [settingsOpen]); - (0, import_react18.useEffect)(() => { + (0, import_react16.useEffect)(() => { if (!openSettings) return void 0; openShellSettings = openSettings; return () => { if (openShellSettings === openSettings) openShellSettings = null; }; }, [openSettings]); - (0, import_react18.useEffect)(() => { + (0, import_react16.useEffect)(() => { if (!openOnboarding) return void 0; openShellOnboarding = openOnboarding; return () => { if (openShellOnboarding === openOnboarding) openShellOnboarding = null; }; }, [openOnboarding]); - (0, import_react18.useEffect)(() => { + (0, import_react16.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; @@ -10398,23 +9350,23 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi [t("Switch to classic Agent mode"), SwitchIcon, () => actions.setSurface("agent")] ] ]; - return (0, import_react18.createElement)( - import_react18.Fragment, + return (0, import_react16.createElement)( + import_react16.Fragment, null, - (0, import_react18.createElement)( + (0, import_react16.createElement)( "button", { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, - (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) + (0, import_react16.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react16.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ), - open && box ? (0, import_react_dom3.createPortal)((0, import_react18.createElement)( + open && box ? (0, import_react_dom3.createPortal)((0, import_react16.createElement)( "div", { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, - (0, import_react18.createElement)(FlyBotAccountHeader, { actions }), - groups.map((group, index) => (0, import_react18.createElement)( - import_react18.Fragment, + (0, import_react16.createElement)(FlyBotAccountHeader, { actions }), + groups.map((group, index) => (0, import_react16.createElement)( + import_react16.Fragment, { key: index }, - index > 0 ? (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, hint]) => (0, import_react18.createElement)("button", { + index > 0 ? (0, import_react16.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, + group.map(([label, Icon, run, hint]) => (0, import_react16.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -10422,7 +9374,7 @@ function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboardi setOpen(false); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } - }, (0, import_react18.createElement)(Icon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) + }, (0, import_react16.createElement)(Icon), (0, import_react16.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react16.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) )) ), document.body) : null ); @@ -10432,8 +9384,8 @@ function useLandOnMainBot(roster, useSessions, actions) { const row = Object.values(list.byId).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); return row ? `${row.id}|${row.blank ? 1 : 0}` : ""; }); - const lastBot = (0, import_react18.useRef)(null); - (0, import_react18.useEffect)(() => { + const lastBot = (0, import_react16.useRef)(null); + (0, import_react16.useEffect)(() => { if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; @@ -10454,16 +9406,16 @@ function useLandOnMainBot(roster, useSessions, actions) { function FooterAccount(props) { const seated = props.useUi((value) => value.accountSeated === true); if (seated) return null; - return (0, import_react18.createElement)("span", { className: "bt-foot-account" }, (0, import_react18.createElement)(AccountLauncher, { ...props, seat: false })); + return (0, import_react16.createElement)("span", { className: "bt-foot-account" }, (0, import_react16.createElement)(AccountLauncher, { ...props, seat: false })); } function BotReturn({ wide, useSurface, actions }) { const mode = useSurface((value) => value.mode); if (mode !== "agent") return null; - return (0, import_react18.createElement)( + return (0, import_react16.createElement)( "button", { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, - (0, import_react18.createElement)(FlyBotMark, { size: 20 }), - wide ? (0, import_react18.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null + (0, import_react16.createElement)(FlyBotMark, { size: 20 }), + wide ? (0, import_react16.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null ); } var RETURN_CSS = ` @@ -10477,10 +9429,10 @@ var RETURN_CSS = ` .bt-return-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} `; function BrandMark({ size = 24 }) { - return (0, import_react18.createElement)("span", { className: "bt-brand-mark" }, (0, import_react18.createElement)(FlyBotMark, { size })); + return (0, import_react16.createElement)("span", { className: "bt-brand-mark" }, (0, import_react16.createElement)(FlyBotMark, { size })); } function BrandName() { - return (0, import_react18.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); + return (0, import_react16.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} @@ -10554,15 +9506,15 @@ var SIDEBAR_CSS = ` `; // src/client/header.js -var import_react19 = require("react"); +var import_react17 = require("react"); var import_react_dom4 = require("react-dom"); -var GoArrow = () => (0, import_react19.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); +var GoArrow = () => (0, import_react17.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react17.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); var MEMORY_NOTICE_MS = 3200; var noNews = (select) => select({}); function useMemoryNotice(ids, useMemoryNews) { const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); - const [shown, setShown] = (0, import_react19.useState)(false); - (0, import_react19.useEffect)(() => { + const [shown, setShown] = (0, import_react17.useState)(false); + (0, import_react17.useEffect)(() => { const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; @@ -10572,16 +9524,16 @@ function useMemoryNotice(ids, useMemoryNews) { return shown; } function MemoryNotice({ shown }) { - const inner = (0, import_react19.useRef)(null); - const [width, setWidth] = (0, import_react19.useState)(0); + const inner = (0, import_react17.useRef)(null); + const [width, setWidth] = (0, import_react17.useState)(0); const label = t("Memory updated"); - (0, import_react19.useLayoutEffect)(() => { + (0, import_react17.useLayoutEffect)(() => { if (shown) setWidth(inner.current?.scrollWidth ?? 0); }, [label, shown]); - return (0, import_react19.createElement)( + return (0, import_react17.createElement)( "span", { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, - (0, import_react19.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react19.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react19.createElement)(MemoryIcon)), label) + (0, import_react17.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react17.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react17.createElement)(MemoryIcon)), label) ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { @@ -10597,7 +9549,7 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const first = openingTurn(state[sessionId], firstTurns, hiddenTurns); return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); - (0, import_react19.useEffect)(() => { + (0, import_react17.useEffect)(() => { if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); @@ -10616,10 +9568,10 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe const notice = useMemoryNotice(memoryIds, useNews); const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); const memoryNotice = notice && changed2 !== null && roster.memory !== false; - return (0, import_react19.createElement)( + return (0, import_react17.createElement)( "div", { className: "bt-head" }, - (0, import_react19.createElement)( + (0, import_react17.createElement)( "button", { type: "button", @@ -10629,19 +9581,19 @@ function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSe "aria-label": memoryNotice ? t("Memory updated. Open {name}'s memory", { name: roster.byId[changed2]?.name ?? full2 }) : t("View conversation details"), onClick: () => memoryNotice ? actions.openMemory(changed2) : actions.toggleDetails(sessionId) }, - bot ? (0, import_react19.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react19.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, - (0, import_react19.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), - (0, import_react19.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react19.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react19.createElement)(GoArrow)) + bot ? (0, import_react17.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react17.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, + (0, import_react17.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), + (0, import_react17.createElement)(MemoryNotice, { shown: memoryNotice }), + (0, import_react17.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react17.createElement)(GoArrow)) ), - (0, import_react19.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), - (0, import_react19.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), - (0, import_react19.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) + (0, import_react17.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), + (0, import_react17.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), + (0, import_react17.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ); } function useTranscriptView() { - const [view, setView] = (0, import_react19.useState)(null); - (0, import_react19.useEffect)(() => { + const [view, setView] = (0, import_react17.useState)(null); + (0, import_react17.useEffect)(() => { let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); @@ -10706,22 +9658,22 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl }); const view = useTranscriptView(); const unpinned = view?.unpinned === true; - const [release, setRelease] = (0, import_react19.useState)({ base: null, dismissed: false }); - const [above, setAbove] = (0, import_react19.useState)({ seen: false, dismissed: false }); - (0, import_react19.useEffect)(() => { + const [release, setRelease] = (0, import_react17.useState)({ base: null, dismissed: false }); + const [above, setAbove] = (0, import_react17.useState)({ seen: false, dismissed: false }); + (0, import_react17.useEffect)(() => { setRelease({ base: unpinned ? replies : null, dismissed: false }); }, [unpinned]); - (0, import_react19.useEffect)(() => { + (0, import_react17.useEffect)(() => { if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); }, [view?.divider]); const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); - (0, import_react19.useEffect)(() => { + (0, import_react17.useEffect)(() => { if (above.seen && movedOn) actions?.settleVisit?.(sessionId); }, [above.seen, movedOn, sessionId]); const fresh = release.base === null ? 0 : replies - release.base; const showDown = unpinned && fresh > 0 && !release.dismissed; const showUp = view?.divider === "above" && !above.seen && !above.dismissed; - (0, import_react19.useEffect)(() => { + (0, import_react17.useEffect)(() => { const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); @@ -10729,16 +9681,16 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl }, [showDown]); if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); - const pill = (direction, count, style, onJump, onDismiss) => (0, import_react19.createElement)( + const pill = (direction, count, style, onJump, onDismiss) => (0, import_react17.createElement)( "div", { className: "bt-news", "data-direction": direction, style }, - (0, import_react19.createElement)( + (0, import_react17.createElement)( "button", { type: "button", className: "bt-news-jump", onClick: onJump }, - (0, import_react19.createElement)("span", { className: "bt-news-ico" }, (0, import_react19.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), + (0, import_react17.createElement)("span", { className: "bt-news-ico" }, (0, import_react17.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), label(count) ), - (0, import_react19.createElement)("button", { + (0, import_react17.createElement)("button", { type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), @@ -10746,7 +9698,7 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl event.stopPropagation(); onDismiss(); } - }, (0, import_react19.createElement)(CloseIcon)) + }, (0, import_react17.createElement)(CloseIcon)) ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); @@ -10756,8 +9708,8 @@ function NewMessagePills({ sessionId, hiddenTurns, useSessions, useUi, useTurnCl }; const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); return (0, import_react_dom4.createPortal)( - (0, import_react19.createElement)( - import_react19.Fragment, + (0, import_react17.createElement)( + import_react17.Fragment, null, showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null @@ -10865,18 +9817,18 @@ function installHeaderEffects({ sessions, roster, actions, doc = globalThis.docu } // src/client/composer.js -var import_react20 = require("react"); +var import_react18 = require("react"); var import_react_dom5 = require("react-dom"); var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; var keepComposerFocus = (event) => event.preventDefault(); function useDictation(onText) { - const [listening, setListening] = (0, import_react20.useState)(false); - const [note, setNote] = (0, import_react20.useState)(""); - const recognition = (0, import_react20.useRef)(null); - const latest = (0, import_react20.useRef)(onText); + const [listening, setListening] = (0, import_react18.useState)(false); + const [note, setNote] = (0, import_react18.useState)(""); + const recognition = (0, import_react18.useRef)(null); + const latest = (0, import_react18.useRef)(onText); latest.current = onText; - (0, import_react20.useEffect)(() => () => recognition.current?.abort(), []); - (0, import_react20.useEffect)(() => { + (0, import_react18.useEffect)(() => () => recognition.current?.abort(), []); + (0, import_react18.useEffect)(() => { if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); @@ -10930,14 +9882,14 @@ function ComposerIdentity({ sessionId, useRoster }) { const self = roster.self; const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); if (room2?.viewerRole === "invited-owner") { - return (0, import_react20.createElement)( + return (0, import_react18.createElement)( "span", { className: "bt-composer-as", title: t("You can remove your Bot from this room") }, t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") }) ); } if (!self || !room2 || humans.length < 2) return null; - return (0, import_react20.createElement)( + return (0, import_react18.createElement)( "span", { className: "bt-composer-as", title: t("Messages in this room are attributed to you") }, t("Sending as {name}", { name: self.displayName || self.memberId }) @@ -10948,7 +9900,7 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()); }); const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); - (0, import_react20.useEffect)(() => { + (0, import_react18.useEffect)(() => { if (!quote || !inputActions) return; actions.clearQuote(quote.token); const excerpt = quote.text.replace(/\s+/g, " ").trim(); @@ -10966,10 +9918,10 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { }, [quote?.token]); if (sessionId === void 0) return null; const pluginMic = actions.speech() !== void 0; - return (0, import_react20.createElement)( - import_react20.Fragment, + return (0, import_react18.createElement)( + import_react18.Fragment, null, - pluginMic ? null : (0, import_react20.createElement)("button", { + pluginMic ? null : (0, import_react18.createElement)("button", { type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), @@ -10977,16 +9929,16 @@ function ComposerVoice({ sessionId, inputActions, actions, useUi }) { title: dictation.listening ? t("Stop dictation") : t("Dictate"), onMouseDown: keepComposerFocus, onClick: () => dictation.listening ? dictation.stop() : dictation.start() - }, (0, import_react20.createElement)(MicIcon)), - (0, import_react20.createElement)("button", { + }, (0, import_react18.createElement)(MicIcon)), + (0, import_react18.createElement)("button", { type: "button", className: "bt-voice", "aria-label": t("Start voice chat"), title: t("Voice chat"), onMouseDown: keepComposerFocus, onClick: () => actions.openVoice(sessionId) - }, (0, import_react20.createElement)(WaveIcon, { size: 18 })), - dictation.note ? (0, import_react20.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null + }, (0, import_react18.createElement)(WaveIcon, { size: 18 })), + dictation.note ? (0, import_react18.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null ); } function menuIcons(roster) { @@ -10994,17 +9946,17 @@ function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon; const id = row.bot.id; - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react20.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); + if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react18.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); return marks.get(id); }; } function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { const roster = useRoster((value) => value); - const [open, setOpen] = (0, import_react20.useState)(null); + const [open, setOpen] = (0, import_react18.useState)(null); const [shown, leaving] = useLinger(open, 120); - const button = (0, import_react20.useRef)(null); - const menu = (0, import_react20.useRef)(null); - (0, import_react20.useEffect)(() => { + const button = (0, import_react18.useRef)(null); + const menu = (0, import_react18.useRef)(null); + (0, import_react18.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); @@ -11045,10 +9997,10 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { actions.referenceChat(sessionId, inputActions?.captureInsertion?.()); }; const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); - return (0, import_react20.createElement)( - import_react20.Fragment, + return (0, import_react18.createElement)( + import_react18.Fragment, null, - (0, import_react20.createElement)("button", { + (0, import_react18.createElement)("button", { ref: button, type: "button", className: "bt-plus", @@ -11058,8 +10010,8 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { "aria-expanded": Boolean(open), onMouseDown: keepComposerFocus, onClick: toggle - }, (0, import_react20.createElement)(PlusIcon)), - shown ? (0, import_react_dom5.createPortal)((0, import_react20.createElement)( + }, (0, import_react18.createElement)(PlusIcon)), + shown ? (0, import_react_dom5.createPortal)((0, import_react18.createElement)( "div", { ref: menu, @@ -11069,9 +10021,9 @@ function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { style: { left: shown.left, bottom: shown.bottom }, "data-leaving": leaving || void 0 }, - (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react20.createElement)(PaperclipIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), - canReference ? (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react20.createElement)(ChatsIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, - (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react20.createElement)(ClockIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) + (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react18.createElement)(PaperclipIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), + canReference ? (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react18.createElement)(ChatsIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, + (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react18.createElement)(ClockIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) ), document.body) : null ); } @@ -11427,7 +10379,7 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { } // src/client/model-menu.js -var import_react21 = require("react"); +var import_react19 = require("react"); var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { @@ -11450,8 +10402,8 @@ function groupModels(models) { var servedOf = (roster, ref) => (roster.models ?? []).find((model) => model.ref === ref); var effortCache = /* @__PURE__ */ new Map(); function useEfforts(served, ref, actions) { - const [fetched, setFetched] = (0, import_react21.useState)(void 0); - (0, import_react21.useEffect)(() => { + const [fetched, setFetched] = (0, import_react19.useState)(void 0); + (0, import_react19.useEffect)(() => { setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; @@ -11476,12 +10428,12 @@ function effortRows(reasoning) { ]; } function EffortRows({ reasoning, current, onPick }) { - return (0, import_react21.createElement)( - import_react21.Fragment, + return (0, import_react19.createElement)( + import_react19.Fragment, null, - (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), - effortRows(reasoning).map((row) => (0, import_react21.createElement)( + (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react19.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), + effortRows(reasoning).map((row) => (0, import_react19.createElement)( "button", { key: row.effort ?? "default", @@ -11490,17 +10442,17 @@ function EffortRows({ reasoning, current, onPick }) { "aria-checked": row.effort === current, onClick: () => onPick(row.effort) }, - (0, import_react21.createElement)("span", { className: "bt-menu-label" }, row.label), - (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react21.createElement)(CheckIcon) : null) + (0, import_react19.createElement)("span", { className: "bt-menu-label" }, row.label), + (0, import_react19.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react19.createElement)(CheckIcon) : null) )) ); } function ModelRows({ roster, current, onPick }) { - return groupModels(roster.models).map(([provider, list]) => (0, import_react21.createElement)( - import_react21.Fragment, + return groupModels(roster.models).map(([provider, list]) => (0, import_react19.createElement)( + import_react19.Fragment, { key: list[0].provider ?? provider }, - (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), - list.map((model) => (0, import_react21.createElement)( + (0, import_react19.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), + list.map((model) => (0, import_react19.createElement)( "button", { key: model.ref, @@ -11510,20 +10462,20 @@ function ModelRows({ roster, current, onPick }) { title: model.ref, onClick: () => onPick(model.ref) }, - (0, import_react21.createElement)("span", { className: "bt-menu-label" }, model.name), - model.ref === roster.defaultModel ? (0, import_react21.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, - (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react21.createElement)(CheckIcon) : null) + (0, import_react19.createElement)("span", { className: "bt-menu-label" }, model.name), + model.ref === roster.defaultModel ? (0, import_react19.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, + (0, import_react19.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react19.createElement)(CheckIcon) : null) )) )); } -var Caret = () => (0, import_react21.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); +var Caret = () => (0, import_react19.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); function useAnchoredMenu() { - const [place, setPlace] = (0, import_react21.useState)(null); + const [place, setPlace] = (0, import_react19.useState)(null); const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); - const button = (0, import_react21.useRef)(null); - const menu = (0, import_react21.useRef)(null); + const button = (0, import_react19.useRef)(null); + const menu = (0, import_react19.useRef)(null); const close = () => setPlace(null); - (0, import_react21.useEffect)(() => { + (0, import_react19.useEffect)(() => { if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; @@ -11586,10 +10538,10 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo else if (effort !== bot.effort) void run(actions.updateBot(bot.id, { effort: effort ?? null })); }; const models = roster.models ?? []; - return (0, import_react21.createElement)( - import_react21.Fragment, + return (0, import_react19.createElement)( + import_react19.Fragment, null, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "button", { ref: button, @@ -11602,38 +10554,38 @@ function ModelMenu({ bot, roster, actions, run, wide = false, onPick, onPickEffo "data-missing": shown !== void 0 && !shown.served && models.length > 0 ? "" : void 0, onClick: toggle }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "span", { className: "bt-model-copy" }, - (0, import_react21.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), - shown ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null + (0, import_react19.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), + shown ? (0, import_react19.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null ), - (0, import_react21.createElement)(Caret) + (0, import_react19.createElement)(Caret) ), - shownPlace ? (0, import_react_dom6.createPortal)((0, import_react21.createElement)( + shownPlace ? (0, import_react_dom6.createPortal)((0, import_react19.createElement)( "div", { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, - models.length > 0 ? (0, import_react21.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), - effortRows(reasoning).length > 0 ? (0, import_react21.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, - (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + models.length > 0 ? (0, import_react19.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react19.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), + effortRows(reasoning).length > 0 ? (0, import_react19.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, + (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react19.createElement)("button", { type: "button", role: "menuitem", onClick: () => { close(); openHarnessModels(); - } }, (0, import_react21.createElement)(SlidersIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) + } }, (0, import_react19.createElement)(SlidersIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) ), document.body) : null ); } function ModelChoiceDock({ sessionId, useRoster, actions }) { const roster = useRoster((value) => value); const bot = roster.byId[sessionId]; - const [busy, setBusy] = (0, import_react21.useState)(""); - const [error, setError] = (0, import_react21.useState)(""); + const [busy, setBusy] = (0, import_react19.useState)(""); + const [error, setError] = (0, import_react19.useState)(""); const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; - (0, import_react21.useEffect)(() => { + (0, import_react19.useEffect)(() => { if (shown) void actions.refreshModels?.(); }, [shown, sessionId]); if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; - if (bot.remote) return bot.remote.gone ? (0, import_react21.createElement)(RemoteGoneDock, { bot }) : null; + if (bot.remote) return bot.remote.gone ? (0, import_react19.createElement)(RemoteGoneDock, { bot }) : null; const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; @@ -11646,12 +10598,12 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { }).finally(() => setBusy("")); }; const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); - const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" })] : [ - ...groupModels(models).map(([provider, list]) => (0, import_react21.createElement)( + const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react19.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react19.createElement)(KeyHelpLink, { key: "key-help" })] : [ + ...groupModels(models).map(([provider, list]) => (0, import_react19.createElement)( "div", { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, - (0, import_react21.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), - (0, import_react21.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react21.createElement)("button", { + (0, import_react19.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), + (0, import_react19.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react19.createElement)("button", { key: model.ref, type: "button", className: "bt-model-chip", @@ -11660,46 +10612,46 @@ function ModelChoiceDock({ sessionId, useRoster, actions }) { "aria-label": `${model.name} · ${provider}`, title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) - }, model.name, busy === model.ref ? (0, import_react21.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) + }, model.name, busy === model.ref ? (0, import_react19.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) )), - (0, import_react21.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react21.createElement)(SlidersIcon), t("Manage models")) + (0, import_react19.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react19.createElement)(SlidersIcon), t("Manage models")) ]; - return (0, import_react21.createElement)( + return (0, import_react19.createElement)( "div", { className: "bt-model-dock" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-astack", "data-lead": "" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-msg" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-msg-line" }, - (0, import_react21.createElement)(BotMark, { bot, size: 22 }), - (0, import_react21.createElement)("div", { className: "bt-bubble" }, text) + (0, import_react19.createElement)(BotMark, { bot, size: 22 }), + (0, import_react19.createElement)("div", { className: "bt-bubble" }, text) ) ) ), - (0, import_react21.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react21.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null + (0, import_react19.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), + error ? (0, import_react19.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ); } function RemoteGoneDock({ bot }) { - return (0, import_react21.createElement)( + return (0, import_react19.createElement)( "div", { className: "bt-model-dock", "data-flybot": "remote-gone" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-astack", "data-lead": "" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-msg" }, - (0, import_react21.createElement)( + (0, import_react19.createElement)( "div", { className: "bt-msg-line" }, - (0, import_react21.createElement)(BotMark, { bot, size: 22 }), - (0, import_react21.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) + (0, import_react19.createElement)(BotMark, { bot, size: 22 }), + (0, import_react19.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ) ) ) @@ -11714,7 +10666,7 @@ function setupChoices(actions) { } var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; function KeyHelpLink() { - return (0, import_react21.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); + return (0, import_react19.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); } function missingModelKey(message) { const text = String(message ?? ""); @@ -11738,22 +10690,22 @@ function TurnErrorCell({ node, t: chatT, actions }) { const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; const deepseek = missing ? /^deepseek/i.test(missing.route) : false; - return (0, import_react21.createElement)( + return (0, import_react19.createElement)( "div", { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, - (0, import_react21.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), - (0, import_react21.createElement)( + (0, import_react19.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), + (0, import_react19.createElement)( "div", { className: "bt-turn-error-copy" }, - (0, import_react21.createElement)("span", { className: "bt-turn-error-title" }, title), - (0, import_react21.createElement)("span", { className: "bt-turn-error-message" }, message), - code ? (0, import_react21.createElement)("code", { className: "bt-turn-error-code" }, code) : null + (0, import_react19.createElement)("span", { className: "bt-turn-error-title" }, title), + (0, import_react19.createElement)("span", { className: "bt-turn-error-message" }, message), + code ? (0, import_react19.createElement)("code", { className: "bt-turn-error-code" }, code) : null ), - choices.length > 0 ? (0, import_react21.createElement)( + choices.length > 0 ? (0, import_react19.createElement)( "div", { className: "bt-turn-error-actions" }, - choices.map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), - deepseek ? (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" }) : null + choices.map(([label, run]) => (0, import_react19.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), + deepseek ? (0, import_react19.createElement)(KeyHelpLink, { key: "key-help" }) : null ) : null ); } @@ -11761,14 +10713,14 @@ function DeepSeekSignIn({ useUi, actions }) { const open = useUi((state) => state.signIn); const entry = open ? actions.signInEntry?.() : null; if (!entry) return null; - return (0, import_react21.createElement)(SignInHost, { key: open, entry, actions }); + return (0, import_react19.createElement)(SignInHost, { key: open, entry, actions }); } function SignInHost({ entry, actions }) { - const [ops] = (0, import_react21.useState)(() => entry.inject?.() ?? {}); + const [ops] = (0, import_react19.useState)(() => entry.inject?.() ?? {}); const close = () => actions.closeSignIn(); - const useHook = (source) => (selector) => (0, import_react21.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); - const [hooks] = (0, import_react21.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); - return (0, import_react21.createElement)(entry.component, { + const useHook = (source) => (selector) => (0, import_react19.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); + const [hooks] = (0, import_react19.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); + return (0, import_react19.createElement)(entry.component, { ...ops, ...hooks, t: actions.accountT(), @@ -11827,7 +10779,7 @@ var MODEL_CSS = ` `; // src/client/model-test.js -var import_react22 = require("react"); +var import_react20 = require("react"); var FAILURE_COPY = { NO_MODEL: "Pick a model first.", NO_KEY: "This model has no API key yet. Set one up in the model settings.", @@ -11852,12 +10804,12 @@ function testOutcome(result, modelName) { } var testModel = (id) => call("test-model", { id }); function ModelTestRow({ bot, modelName, test = testModel }) { - const [state, setState] = (0, import_react22.useState)({ busy: false, outcome: void 0 }); - const live = (0, import_react22.useRef)(true); - (0, import_react22.useEffect)(() => () => { + const [state, setState] = (0, import_react20.useState)({ busy: false, outcome: void 0 }); + const live = (0, import_react20.useRef)(true); + (0, import_react20.useEffect)(() => () => { live.current = false; }, []); - (0, import_react22.useEffect)(() => { + (0, import_react20.useEffect)(() => { setState({ busy: false, outcome: void 0 }); }, [bot.id, bot.model]); const start = () => { @@ -11871,21 +10823,21 @@ function ModelTestRow({ bot, modelName, test = testModel }) { }); }; const { busy, outcome } = state; - return (0, import_react22.createElement)( + return (0, import_react20.createElement)( "div", { className: "bt-set-row bt-bs-row bt-model-test" }, - (0, import_react22.createElement)( + (0, import_react20.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), - (0, import_react22.createElement)( + (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), + (0, import_react20.createElement)( "span", { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, - outcome?.code ? (0, import_react22.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null + outcome?.code ? (0, import_react20.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null ) ), - (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) + (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) ); } var MODEL_TEST_CSS = ` @@ -11897,17 +10849,17 @@ var MODEL_TEST_CSS = ` `; // src/client/folder-menu.js -var import_react23 = require("react"); +var import_react21 = require("react"); var import_react_dom7 = require("react-dom"); var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); -var Tick = ({ on }) => (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react23.createElement)(CheckIcon) : null); +var Tick = ({ on }) => (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react21.createElement)(CheckIcon) : null); function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); - const [info, setInfo] = (0, import_react23.useState)(null); - const [view, setView] = (0, import_react23.useState)("list"); - const [draft, setDraft] = (0, import_react23.useState)(""); - const [listing, setListing] = (0, import_react23.useState)(null); - const [problem, setProblem] = (0, import_react23.useState)(""); + const [info, setInfo] = (0, import_react21.useState)(null); + const [view, setView] = (0, import_react21.useState)("list"); + const [draft, setDraft] = (0, import_react21.useState)(""); + const [listing, setListing] = (0, import_react21.useState)(null); + const [problem, setProblem] = (0, import_react21.useState)(""); const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { @@ -11940,35 +10892,35 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } }; const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; - const listView = (0, import_react23.createElement)( + const listView = (0, import_react21.createElement)( "div", { key: "list", className: "bt-folder-view" }, - (0, import_react23.createElement)( + (0, import_react21.createElement)( "button", { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, - (0, import_react23.createElement)("span", { className: "bt-menu-label" }, fallback), - (0, import_react23.createElement)(Tick, { on: !value }) + (0, import_react21.createElement)("span", { className: "bt-menu-label" }, fallback), + (0, import_react21.createElement)(Tick, { on: !value }) ), - folders.length > 0 ? (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, - folders.map((folder) => (0, import_react23.createElement)( + folders.length > 0 ? (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, + folders.map((folder) => (0, import_react21.createElement)( "button", { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, - (0, import_react23.createElement)("span", { className: "bt-folder-copy" }, (0, import_react23.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react23.createElement)("span", { className: "bt-folder-path" }, folder.path)), - (0, import_react23.createElement)(Tick, { on: folder.path === value }) + (0, import_react21.createElement)("span", { className: "bt-folder-copy" }, (0, import_react21.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react21.createElement)("span", { className: "bt-folder-path" }, folder.path)), + (0, import_react21.createElement)(Tick, { on: folder.path === value }) )), - (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - info?.picker === "native" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, - info?.picker === "browse" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, - (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => { + (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + info?.picker === "native" ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, + info?.picker === "browse" ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, + (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => { setDraft(value ?? ""); setView("type"); - } }, (0, import_react23.createElement)(PencilIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) + } }, (0, import_react21.createElement)(PencilIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) ); - const typeView = (0, import_react23.createElement)( + const typeView = (0, import_react21.createElement)( "div", { key: "type", className: "bt-folder-view" }, - (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react23.createElement)("input", { + (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react21.createElement)(ChevronLeftIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Back"))), + (0, import_react21.createElement)("input", { className: "bt-folder-input", autoFocus: true, value: draft, @@ -11980,33 +10932,33 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), - (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) + (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) ); const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0; const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? []; - const browseView = listing ? (0, import_react23.createElement)( + const browseView = listing ? (0, import_react21.createElement)( "div", { key: `browse:${listing.path}`, className: "bt-folder-view" }, - (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), - parent ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, - inside.map((entry) => (0, import_react23.createElement)( + (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react21.createElement)(ChevronLeftIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Back"))), + (0, import_react21.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), + parent ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, + inside.map((entry) => (0, import_react21.createElement)( "button", { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) }, - (0, import_react23.createElement)(FolderIcon), - (0, import_react23.createElement)("span", { className: "bt-menu-label" }, entry.name), - (0, import_react23.createElement)(ChevronRightIcon) + (0, import_react21.createElement)(FolderIcon), + (0, import_react21.createElement)("span", { className: "bt-menu-label" }, entry.name), + (0, import_react21.createElement)(ChevronRightIcon) )), - inside.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, - listing.truncated ? (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, - (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) + inside.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, + listing.truncated ? (0, import_react21.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, + (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), + (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) ) : null; const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView; - return (0, import_react23.createElement)( - import_react23.Fragment, + return (0, import_react21.createElement)( + import_react21.Fragment, null, - (0, import_react23.createElement)( + (0, import_react21.createElement)( "button", { ref: button, @@ -12019,19 +10971,19 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } title: value, onClick: toggle }, - (0, import_react23.createElement)( + (0, import_react21.createElement)( "span", { className: "bt-model-copy" }, - (0, import_react23.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), - value ? (0, import_react23.createElement)("span", { className: "bt-model-provider" }, value) : null + (0, import_react21.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), + value ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, value) : null ), - (0, import_react23.createElement)(Caret) + (0, import_react21.createElement)(Caret) ), - shownPlace ? (0, import_react_dom7.createPortal)((0, import_react23.createElement)( + shownPlace ? (0, import_react_dom7.createPortal)((0, import_react21.createElement)( "div", { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 }, shownView, - problem ? (0, import_react23.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null + problem ? (0, import_react21.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null ), document.body) : null ); } @@ -12051,8 +11003,8 @@ var FOLDER_CSS = ` `; // src/client/schedules.js -var import_react24 = require("react"); -var POLL_MS5 = 3e4; +var import_react22 = require("react"); +var POLL_MS4 = 3e4; var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { @@ -12060,8 +11012,8 @@ function publish(view) { for (const listener of listeners) listener(view); } function useSchedules(actions) { - const [view, setView] = (0, import_react24.useState)(shared); - (0, import_react24.useEffect)(() => { + const [view, setView] = (0, import_react22.useState)(shared); + (0, import_react22.useEffect)(() => { listeners.add(setView); let stopped = false; let timer; @@ -12069,7 +11021,7 @@ function useSchedules(actions) { if (!stopped) publish(next); }).catch(() => { }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS5); + if (!stopped) timer = setTimeout(load, POLL_MS4); }); load(); return () => { @@ -12133,9 +11085,9 @@ function lastRunText(run) { } } function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { - const [confirming, setConfirming] = (0, import_react24.useState)(false); - const [busy, setBusy] = (0, import_react24.useState)(false); - (0, import_react24.useEffect)(() => { + const [confirming, setConfirming] = (0, import_react22.useState)(false); + const [busy, setBusy] = (0, import_react22.useState)(false); + (0, import_react22.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); @@ -12156,7 +11108,7 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { onView(next); }, () => setBusy(false)); }; - const [retryError, setRetryError] = (0, import_react24.useState)(""); + const [retryError, setRetryError] = (0, import_react22.useState)(""); const retry = () => { setBusy(true); setRetryError(""); @@ -12172,25 +11124,25 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { const stoppable = task.status === "active" || task.status === "paused"; const failed = task.lastRun?.result === "failed"; const last = lastRunText(task.lastRun); - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "div", { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, - showBot ? (0, import_react24.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, - (0, import_react24.createElement)( + showBot ? (0, import_react22.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, + (0, import_react22.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react24.createElement)("span", { className: "bt-task-title" }, task.title), - (0, import_react24.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react24.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, - last ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, - retryError ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null + (0, import_react22.createElement)("span", { className: "bt-task-title" }, task.title), + (0, import_react22.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), + task.prompt && task.prompt !== task.title ? (0, import_react22.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, + last ? (0, import_react22.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, + retryError ? (0, import_react22.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null ), - (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)), - failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, - onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null, - onCopy ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react24.createElement)(DuplicateIcon)) : null, - stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, - (0, import_react24.createElement)("button", { + (0, import_react22.createElement)("span", { className: "bt-task-next" }, nextText(task)), + failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, + onEdit ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react22.createElement)(PencilIcon)) : null, + onCopy ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react22.createElement)(DuplicateIcon)) : null, + stoppable ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, + (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, @@ -12198,7 +11150,7 @@ function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { "aria-label": confirming ? t("Click again to delete") : t("Delete task"), title: confirming ? void 0 : t("Delete task"), onClick: remove - }, confirming ? t("Delete") : (0, import_react24.createElement)(TrashIcon)) + }, confirming ? t("Delete") : (0, import_react22.createElement)(TrashIcon)) ); } var pad = (number) => String(number).padStart(2, "0"); @@ -12231,17 +11183,17 @@ function formStart(task) { } function TaskForm({ roster, actions, start, task, onDone, onCancel }) { const first = formStart(task); - const [target, setTarget] = (0, import_react24.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); - const [title, setTitle] = (0, import_react24.useState)(first.title); - const [prompt, setPrompt] = (0, import_react24.useState)(first.prompt); - const [kind, setKind] = (0, import_react24.useState)(first.kind); - const [at, setAt] = (0, import_react24.useState)(first.at); - const [time, setTime] = (0, import_react24.useState)(first.time); - const [weekdays, setWeekdays] = (0, import_react24.useState)(first.weekdays); - const [count, setCount] = (0, import_react24.useState)(first.count); - const [unit, setUnit] = (0, import_react24.useState)(first.unit); - const [busy, setBusy] = (0, import_react24.useState)(false); - const [error, setError] = (0, import_react24.useState)(""); + const [target, setTarget] = (0, import_react22.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); + const [title, setTitle] = (0, import_react22.useState)(first.title); + const [prompt, setPrompt] = (0, import_react22.useState)(first.prompt); + const [kind, setKind] = (0, import_react22.useState)(first.kind); + const [at, setAt] = (0, import_react22.useState)(first.at); + const [time, setTime] = (0, import_react22.useState)(first.time); + const [weekdays, setWeekdays] = (0, import_react22.useState)(first.weekdays); + const [count, setCount] = (0, import_react22.useState)(first.count); + const [unit, setUnit] = (0, import_react22.useState)(first.unit); + const [busy, setBusy] = (0, import_react22.useState)(false); + const [error, setError] = (0, import_react22.useState)(""); const timing = () => { if (kind === "cron") return { kind: "keep" }; if (kind === "at") return { kind, at: new Date(at).toISOString(), timeZone: first.timeZone }; @@ -12268,69 +11220,69 @@ function TaskForm({ roster, actions, start, task, onDone, onCancel }) { }); }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "form", { className: "bt-set-card bt-task-form", onSubmit: submit }, - (0, import_react24.createElement)( + (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), - (0, import_react24.createElement)( + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), + (0, import_react22.createElement)( "select", { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, - (0, import_react24.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react24.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), - roster.rooms.length > 0 ? (0, import_react24.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react24.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null + (0, import_react22.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react22.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), + roster.rooms.length > 0 ? (0, import_react22.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react22.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null ) ), - (0, import_react24.createElement)( + (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react24.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Name"))), + (0, import_react22.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) ), - (0, import_react24.createElement)( + (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), - (0, import_react24.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), + (0, import_react22.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) ), - (0, import_react24.createElement)( + (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("When"))), - (0, import_react24.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), - (0, import_react24.createElement)( + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("When"))), + (0, import_react22.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react22.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), + (0, import_react22.createElement)( "div", { key: kind, className: "bt-task-when bt-veil" }, - kind === "cron" ? (0, import_react24.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, - kind === "at" ? (0, import_react24.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, - kind === "weekly" ? (0, import_react24.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react24.createElement)("button", { + kind === "cron" ? (0, import_react22.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, + kind === "at" ? (0, import_react22.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, + kind === "weekly" ? (0, import_react22.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react22.createElement)("button", { key: day, type: "button", className: "bt-task-day", "aria-pressed": weekdays.includes(day), onClick: () => toggleDay(day) }, when2(Date.UTC(2024, 0, day), { weekday: "narrow", timeZone: "UTC" })))) : null, - kind === "daily" || kind === "weekly" ? (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, - kind === "every" ? (0, import_react24.createElement)( - import_react24.Fragment, + kind === "daily" || kind === "weekly" ? (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, + kind === "every" ? (0, import_react22.createElement)( + import_react22.Fragment, null, - (0, import_react24.createElement)("span", { className: "bt-task-every" }, t("Every")), - (0, import_react24.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), - (0, import_react24.createElement)( + (0, import_react22.createElement)("span", { className: "bt-task-every" }, t("Every")), + (0, import_react22.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), + (0, import_react22.createElement)( "select", { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, - UNITS.map(([id, , label]) => (0, import_react24.createElement)("option", { key: id, value: id }, t(label))) + UNITS.map(([id, , label]) => (0, import_react22.createElement)("option", { key: id, value: id }, t(label))) ) ) : null ) ), - error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, - (0, import_react24.createElement)( + error ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, + (0, import_react22.createElement)( "div", { className: "bt-set-row bt-task-foot" }, - (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react24.createElement)( + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react22.createElement)( "button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") @@ -12343,21 +11295,21 @@ function openPlugins(actions) { actions.selectPanel("plugins"); } function AutomationOff({ actions }) { - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "div", { className: "bt-set-card bt-task-off" }, - (0, import_react24.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react24.createElement)(ClockIcon)), - (0, import_react24.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), - (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), - (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + (0, import_react22.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react22.createElement)(ClockIcon)), + (0, import_react22.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), + (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ); } function QuietHoursCard({ view, actions }) { const quiet = view.quietHours ?? { on: false }; - const [busy, setBusy] = (0, import_react24.useState)(false); - const [error, setError] = (0, import_react24.useState)(""); - const [from, setFrom] = (0, import_react24.useState)(quiet.from ?? "22:00"); - const [to, setTo] = (0, import_react24.useState)(quiet.to ?? "07:00"); + const [busy, setBusy] = (0, import_react22.useState)(false); + const [error, setError] = (0, import_react22.useState)(""); + const [from, setFrom] = (0, import_react22.useState)(quiet.from ?? "22:00"); + const [to, setTo] = (0, import_react22.useState)(quiet.to ?? "07:00"); const unchecked = view.quietUnchecked === true; const refresh = () => { void Promise.resolve(actions.schedules?.()).then((next) => { @@ -12375,23 +11327,23 @@ function QuietHoursCard({ view, actions }) { refresh(); }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); }; - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "section", { className: "bt-set-section" }, - (0, import_react24.createElement)("h3", null, t("Quiet hours")), - (0, import_react24.createElement)( + (0, import_react22.createElement)("h3", null, t("Quiet hours")), + (0, import_react22.createElement)( "div", { className: "bt-set-card" }, - (0, import_react24.createElement)( + (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react24.createElement)( + (0, import_react22.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react24.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), - (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) + (0, import_react22.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), + (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) ), - (0, import_react24.createElement)("button", { + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, @@ -12399,30 +11351,30 @@ function QuietHoursCard({ view, actions }) { onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) }, quiet.on ? t("Turn off") : t("Turn on")) ), - quiet.on ? (0, import_react24.createElement)( + quiet.on ? (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Window"))), - (0, import_react24.createElement)( + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Window"))), + (0, import_react22.createElement)( "div", { className: "bt-task-when" }, - (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), - (0, import_react24.createElement)("span", { className: "bt-task-every" }, "–"), - (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), - (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) + (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), + (0, import_react22.createElement)("span", { className: "bt-task-every" }, "–"), + (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) ) ) : null, - error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, - quiet.on && unchecked ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + error ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, + quiet.on && unchecked ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null ) ); } function SchedulesPage({ roster, actions, start }) { const view = useSchedules(actions); - const [adding, setAdding] = (0, import_react24.useState)(Boolean(start)); - const [editing, setEditing] = (0, import_react24.useState)(null); - if (view === null) return (0, import_react24.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react24.createElement)(AutomationOff, { actions }); + const [adding, setAdding] = (0, import_react22.useState)(Boolean(start)); + const [editing, setEditing] = (0, import_react22.useState)(null); + if (view === null) return (0, import_react22.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); + if (!view.available) return (0, import_react22.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); @@ -12433,37 +11385,37 @@ function SchedulesPage({ roster, actions, start }) { setEditing(task); }; const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react24.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react24.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) + ...roster.bots.map((bot) => [bot.id, (0, import_react22.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), + ...roster.rooms.map((room2) => [room2.id, (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ].filter(([, , , tasks]) => tasks.length > 0); return [ - (0, import_react24.createElement)(QuietHoursCard, { key: "quiet", view, actions }), - adding ? (0, import_react24.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react24.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + (0, import_react22.createElement)(QuietHoursCard, { key: "quiet", view, actions }), + adding ? (0, import_react22.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react22.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { setEditing(null); setAdding(true); - } }, (0, import_react24.createElement)(PlusIcon), t("New task"))), - byChat.length === 0 && !adding ? (0, import_react24.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react24.createElement)( + } }, (0, import_react22.createElement)(PlusIcon), t("New task"))), + byChat.length === 0 && !adding ? (0, import_react22.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react22.createElement)( "section", { key: id, className: "bt-set-section" }, - (0, import_react24.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react24.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) + (0, import_react22.createElement)("h3", { className: "bt-task-head" }, avatar, name), + (0, import_react22.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; } function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; - const [copying, setCopying] = (0, import_react24.useState)(null); + const [copying, setCopying] = (0, import_react22.useState)(null); const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; - const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); - return (0, import_react24.createElement)( - import_react24.Fragment, + const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react22.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); + return (0, import_react22.createElement)( + import_react22.Fragment, null, - open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, - source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { + open ? (0, import_react22.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, + source ? (0, import_react22.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { setCopying(null); onDone(next); }, onCancel: () => setCopying(null) }) : null, - rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + rows.length + extra.length > 0 ? (0, import_react22.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ); } function CopyRow({ task, roster, actions, onView, onCancel }) { @@ -12471,9 +11423,9 @@ function CopyRow({ task, roster, actions, onView, onCancel }) { ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) ]; - const [target, setTarget] = (0, import_react24.useState)(""); - const [busy, setBusy] = (0, import_react24.useState)(false); - const [error, setError] = (0, import_react24.useState)(""); + const [target, setTarget] = (0, import_react22.useState)(""); + const [busy, setBusy] = (0, import_react22.useState)(false); + const [error, setError] = (0, import_react22.useState)(""); const submit = (event) => { event.preventDefault(); if (busy || target === "") return; @@ -12484,40 +11436,40 @@ function CopyRow({ task, roster, actions, onView, onCancel }) { setError(hostText(failure?.message ?? String(failure))); }); }; - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "form", { className: "bt-set-row bt-task-copy", onSubmit: submit }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), - (0, import_react24.createElement)( + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), + (0, import_react22.createElement)( "select", { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, - (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), - others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name)) + (0, import_react22.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), + others.map(([id, name]) => (0, import_react22.createElement)("option", { key: id, value: id }, name)) ), - (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), - (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + (0, import_react22.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + error ? (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null ); } function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); - const [editing, setEditing] = (0, import_react24.useState)(null); + const [editing, setEditing] = (0, import_react22.useState)(null); if (view === null) return null; const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "section", { className: "bt-set-section" }, - (0, import_react24.createElement)("h3", null, t("Scheduled tasks")), - !view.available ? (0, import_react24.createElement)( + (0, import_react22.createElement)("h3", null, t("Scheduled tasks")), + !view.available ? (0, import_react22.createElement)( "div", { className: "bt-set-card" }, - (0, import_react24.createElement)( + (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), - (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), + (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ) - ) : (0, import_react24.createElement)(TaskList, { + ) : (0, import_react22.createElement)(TaskList, { tasks, editing, roster, @@ -12528,23 +11480,23 @@ function BotSchedules({ bot, roster, actions }) { setEditing(null); }, onCancel: () => setEditing(null), - extra: [(0, import_react24.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react24.createElement)(PlusIcon), t("New task"))] + extra: [(0, import_react22.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react22.createElement)(PlusIcon), t("New task"))] }) ); } function ScheduleSummary({ bot, actions }) { const view = useSchedules(actions); const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : []; - return (0, import_react24.createElement)( + return (0, import_react22.createElement)( "div", null, - (0, import_react24.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), - tasks.length === 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react24.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react24.createElement)( + (0, import_react22.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), + tasks.length === 0 ? (0, import_react22.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react22.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react22.createElement)( "button", { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react24.createElement)(ClockIcon), - (0, import_react24.createElement)("span", { className: "bt-task-line-title" }, task.title), - (0, import_react24.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) + (0, import_react22.createElement)(ClockIcon), + (0, import_react22.createElement)("span", { className: "bt-task-line-title" }, task.title), + (0, import_react22.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) ))) ); } @@ -12596,7 +11548,7 @@ var SCHEDULE_CSS = ` `; // src/client/capability-settings.js -var import_react25 = require("react"); +var import_react23 = require("react"); var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; @@ -12610,11 +11562,11 @@ var parseMap = (text, label) => { }; var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { - return (0, import_react25.createElement)( + return (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t(label))), - (0, import_react25.createElement)(multiline ? "textarea" : "input", { + (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t(label))), + (0, import_react23.createElement)(multiline ? "textarea" : "input", { className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, @@ -12626,7 +11578,7 @@ function Field({ label, value, onChange, multiline = false, disabled = false, pl ); } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { - const [error, setError] = (0, import_react25.useState)(""); + const [error, setError] = (0, import_react23.useState)(""); const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); @@ -12657,95 +11609,95 @@ function CapabilityForm({ draft, setDraft, items, actions, onClose }) { } }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); - return (0, import_react25.createElement)( + return (0, import_react23.createElement)( "form", { className: "bt-connector-form", onSubmit: submit }, - (0, import_react25.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), - (0, import_react25.createElement)( + (0, import_react23.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), + (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Type"))), - (0, import_react25.createElement)( + (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Type"))), + (0, import_react23.createElement)( "select", { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) }, - ["mcp", "skill", "pack"].map((kind) => (0, import_react25.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) + ["mcp", "skill", "pack"].map((kind) => (0, import_react23.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) ) ), - (0, import_react25.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), - draft.kind === "mcp" ? (0, import_react25.createElement)( + (0, import_react23.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), + draft.kind === "mcp" ? (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), - (0, import_react25.createElement)( + (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), + (0, import_react23.createElement)( "select", { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) }, - (0, import_react25.createElement)("option", { value: "stdio" }, t("Local command")), - (0, import_react25.createElement)("option", { value: "streamable-http" }, t("HTTP server")) + (0, import_react23.createElement)("option", { value: "stdio" }, t("Local command")), + (0, import_react23.createElement)("option", { value: "streamable-http" }, t("HTTP server")) ) ) : null, - draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react25.createElement)( + draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react23.createElement)( "div", null, - (0, import_react25.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), - (0, import_react25.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), - (0, import_react25.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) + (0, import_react23.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), + (0, import_react23.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), + (0, import_react23.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ) : null, - draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react25.createElement)( + draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react23.createElement)( "div", null, - (0, import_react25.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), - (0, import_react25.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) + (0, import_react23.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), + (0, import_react23.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ) : null, - draft.kind === "skill" ? (0, import_react25.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, - draft.kind === "pack" ? (0, import_react25.createElement)( + draft.kind === "skill" ? (0, import_react23.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, + draft.kind === "pack" ? (0, import_react23.createElement)( "div", { className: "bt-set-card" }, - available.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react25.createElement)( + available.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react23.createElement)( "label", { key: item.name, className: "bt-set-row bt-bs-row" }, - (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, item.name)), - (0, import_react25.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) + (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, item.name)), + (0, import_react23.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) )) ) : null, - error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, - (0, import_react25.createElement)( + error ? (0, import_react23.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, + (0, import_react23.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), - (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), + (0, import_react23.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) ) ); } function CapabilityRow({ item, onEdit, onDelete, onApprove, confirming }) { const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill"); const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail; - return (0, import_react25.createElement)( + return (0, import_react23.createElement)( "div", { className: "bt-connector" }, - (0, import_react25.createElement)( + (0, import_react23.createElement)( "div", { className: "bt-connector-head" }, - (0, import_react25.createElement)( + (0, import_react23.createElement)( "span", { className: "bt-connector-text" }, - (0, import_react25.createElement)("span", { className: "bt-connector-name" }, item.name), - (0, import_react25.createElement)("span", { className: "bt-connector-status" }, secondary) + (0, import_react23.createElement)("span", { className: "bt-connector-name" }, item.name), + (0, import_react23.createElement)("span", { className: "bt-connector-status" }, secondary) ), - (0, import_react25.createElement)( + (0, import_react23.createElement)( "span", { className: "bt-connector-actions" }, - item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) + item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) ) ) ); } function CapabilitiesPage({ roster, actions, run }) { const items = [...roster.capabilities ?? []].sort((a, b) => a.name.localeCompare(b.name)); - const [draft, setDraft] = (0, import_react25.useState)(null); - const [confirming, setConfirming] = (0, import_react25.useState)(""); - (0, import_react25.useEffect)(() => { + const [draft, setDraft] = (0, import_react23.useState)(null); + const [confirming, setConfirming] = (0, import_react23.useState)(""); + (0, import_react23.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); @@ -12768,20 +11720,20 @@ function CapabilitiesPage({ roster, actions, run }) { setConfirming(""); void run(actions.deleteCapability(item.name)); }; - return (0, import_react25.createElement)( + return (0, import_react23.createElement)( "section", { className: "bt-set-section" }, - (0, import_react25.createElement)("h3", null, t("Shared capabilities")), - (0, import_react25.createElement)( + (0, import_react23.createElement)("h3", null, t("Shared capabilities")), + (0, import_react23.createElement)( "div", { className: "bt-actions" }, - (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) + (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) ), - draft ? (0, import_react25.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, - (0, import_react25.createElement)( + draft ? (0, import_react23.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, + (0, import_react23.createElement)( "div", { className: "bt-set-card bt-connectors-card" }, - items.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react25.createElement)(CapabilityRow, { + items.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react23.createElement)(CapabilityRow, { key: item.name, item, confirming: confirming === item.name, @@ -12797,10 +11749,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; - return (0, import_react25.createElement)( + return (0, import_react23.createElement)( Card2, { title: t("Capabilities") }, - items.map((item) => (0, import_react25.createElement)(SwitchRow2, { + items.map((item) => (0, import_react23.createElement)(SwitchRow2, { key: item.name, label: item.name, hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, @@ -12811,7 +11763,7 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow } // src/client/connector-bots.js -var import_react26 = require("react"); +var import_react24 = require("react"); function ownBots(roster) { const me = roster?.self?.memberId; return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me)); @@ -12827,38 +11779,38 @@ function usageText(roster, id) { return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length }); } function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) { - return (0, import_react26.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) }); + return (0, import_react24.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) }); } function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) { const bots = ownBots(roster); - return (0, import_react26.createElement)( + return (0, import_react24.createElement)( "div", { className: "bt-connector-bots" }, - bots.length === 0 ? (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react26.createElement)( + bots.length === 0 ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react24.createElement)( "div", { key: bot.id, className: "bt-connector-bot" }, - (0, import_react26.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 }) + (0, import_react24.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 }) )), - (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.")) + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.")) ); } function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { const connected = connectedConnectors(roster); if (connected.length === 0) return null; - return (0, import_react26.createElement)( + return (0, import_react24.createElement)( Card2, { title: t("Connectors") }, - connected.map((connector) => (0, import_react26.createElement)( + connected.map((connector) => (0, import_react24.createElement)( "div", { key: connector.id, className: "bt-connector-bot" }, - (0, import_react26.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 }) + (0, import_react24.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 }) )) ); } // src/client/memory-panel.js -var import_react27 = require("react"); -var POLL_MS6 = 1500; +var import_react25 = require("react"); +var POLL_MS5 = 1500; var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); @@ -12868,8 +11820,8 @@ function noteCount(botId, count) { for (const listener of countListeners) listener(); } function useMemoryCount(botId, actions) { - const [count, setCount] = (0, import_react27.useState)(counts.get(botId) ?? null); - (0, import_react27.useEffect)(() => { + const [count, setCount] = (0, import_react25.useState)(counts.get(botId) ?? null); + (0, import_react25.useEffect)(() => { const listener = () => setCount(counts.get(botId) ?? null); countListeners.add(listener); listener(); @@ -12888,16 +11840,16 @@ function useMemoryCount(botId, actions) { function MemoryRow({ bot, actions }) { const count = useMemoryCount(bot.id, actions); const label = count === null ? t("Loading…") : count === 0 ? t("Nothing saved yet") : count === 1 ? t("1 entry") : t("{count} entries", { count }); - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "div", null, - (0, import_react27.createElement)("div", { className: "bt-section-title" }, t("Memory")), - (0, import_react27.createElement)( + (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Memory")), + (0, import_react25.createElement)( "button", { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, - (0, import_react27.createElement)(MemoryIcon), - (0, import_react27.createElement)("span", { className: "bt-mem-row-copy" }, label), - (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon)) + (0, import_react25.createElement)(MemoryIcon), + (0, import_react25.createElement)("span", { className: "bt-mem-row-copy" }, label), + (0, import_react25.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react25.createElement)(ChevronRightIcon)) ) ); } @@ -12912,45 +11864,45 @@ function ago(at, now) { return format.format(-Math.round(hours / 24), "day"); } function Skeleton({ label }) { - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "div", { className: "bt-mem-skel", "aria-busy": true }, - [0, 1].map((block) => (0, import_react27.createElement)( + [0, 1].map((block) => (0, import_react25.createElement)( "div", { key: block, className: "bt-mem-skel-block" }, - (0, import_react27.createElement)("span", { className: "bt-mem-skel-head" }), - (0, import_react27.createElement)("span", null), - (0, import_react27.createElement)("span", null), - (0, import_react27.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) + (0, import_react25.createElement)("span", { className: "bt-mem-skel-head" }), + (0, import_react25.createElement)("span", null), + (0, import_react25.createElement)("span", null), + (0, import_react25.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) )), - label ? (0, import_react27.createElement)("div", { className: "bt-mem-skel-label" }, label) : null + label ? (0, import_react25.createElement)("div", { className: "bt-mem-skel-label" }, label) : null ); } function Summary({ bot, view, regenerate }) { if (view.entries.length === 0) { - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "div", { className: "bt-mem-empty" }, - (0, import_react27.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react27.createElement)(MemoryIcon)), - (0, import_react27.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), - (0, import_react27.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name })) + (0, import_react25.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react25.createElement)(MemoryIcon)), + (0, import_react25.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), + (0, import_react25.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name })) ); } - const failed = view.error && !view.summarizing ? (0, import_react27.createElement)( + const failed = view.error && !view.summarizing ? (0, import_react25.createElement)( "div", { className: "bt-mem-failed", role: "alert" }, - (0, import_react27.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })), - (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) + (0, import_react25.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })), + (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) ) : null; - if (!view.summary) return (0, import_react27.createElement)(import_react27.Fragment, null, failed, failed ? null : (0, import_react27.createElement)(Skeleton, { label: t("Writing the summary…") })); - return (0, import_react27.createElement)( - import_react27.Fragment, + if (!view.summary) return (0, import_react25.createElement)(import_react25.Fragment, null, failed, failed ? null : (0, import_react25.createElement)(Skeleton, { label: t("Writing the summary…") })); + return (0, import_react25.createElement)( + import_react25.Fragment, null, failed, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 }, - summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react27.createElement)("h3", { key: index }, block.text) : (0, import_react27.createElement)("p", { key: index }, block.text)) + summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react25.createElement)("h3", { key: index }, block.text) : (0, import_react25.createElement)("p", { key: index }, block.text)) ) ); } @@ -12962,9 +11914,9 @@ var groupEntries = (entries2) => entries2.reduce((groups, entry) => { return groups; }, []); function EntryRow({ entry, remove }) { - const [confirming, setConfirming] = (0, import_react27.useState)(false); - const [busy, setBusy] = (0, import_react27.useState)(false); - (0, import_react27.useEffect)(() => { + const [confirming, setConfirming] = (0, import_react25.useState)(false); + const [busy, setBusy] = (0, import_react25.useState)(false); + (0, import_react25.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); @@ -12979,16 +11931,16 @@ function EntryRow({ entry, remove }) { setBusy(true); if (!await remove(entry)) setBusy(false); }; - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "li", { className: "bt-mem-entry", "data-busy": busy || void 0 }, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-entry-copy" }, - (0, import_react27.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), - meta ? (0, import_react27.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null + (0, import_react25.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), + meta ? (0, import_react25.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null ), - (0, import_react27.createElement)("button", { + (0, import_react25.createElement)("button", { type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, @@ -12996,7 +11948,7 @@ function EntryRow({ entry, remove }) { "aria-label": confirming ? t("Click again to remove") : t("Remove from memory"), title: confirming ? void 0 : t("Remove from memory"), onClick: click - }, confirming ? t("Remove") : (0, import_react27.createElement)(TrashIcon)) + }, confirming ? t("Remove") : (0, import_react25.createElement)(TrashIcon)) ); } function Entries({ bot, view, remove }) { @@ -13005,25 +11957,25 @@ function Entries({ bot, view, remove }) { { scope: "own", title: t("{name}'s memory", { name: bot.name }), size: view.sizes.own }, { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "div", { className: "bt-mem-entries" }, - sections.map((section) => (0, import_react27.createElement)( + sections.map((section) => (0, import_react25.createElement)( "section", { key: section.scope, className: "bt-mem-section" }, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-section-head" }, - (0, import_react27.createElement)("h3", null, section.title), - section.hint ? (0, import_react27.createElement)("span", null, section.hint) : null, - (0, import_react27.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) })) + (0, import_react25.createElement)("h3", null, section.title), + section.hint ? (0, import_react25.createElement)("span", null, section.hint) : null, + (0, import_react25.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) })) ), - groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react27.createElement)( - import_react27.Fragment, + groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react25.createElement)( + import_react25.Fragment, { key: group.key }, - group.topic ? (0, import_react27.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null, - (0, import_react27.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react27.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove }))) - )) : (0, import_react27.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet")) + group.topic ? (0, import_react25.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null, + (0, import_react25.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react25.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove }))) + )) : (0, import_react25.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet")) )) ); } @@ -13035,52 +11987,52 @@ function changeLine(change) { return change.scope === "team" ? t("Added to team memory: {text}", { text }) : t("Added: {text}", { text }); } function Reply({ answer, dismiss }) { - return (0, import_react27.createElement)( + return (0, import_react25.createElement)( "div", { className: "bt-mem-reply", role: "status" }, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-reply-head" }, - (0, import_react27.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked), - answer.pending ? null : (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react27.createElement)(CloseIcon)) + (0, import_react25.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked), + answer.pending ? null : (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react25.createElement)(CloseIcon)) ), - answer.pending ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react27.createElement)( - import_react27.Fragment, + answer.pending ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react25.createElement)( + import_react25.Fragment, null, - answer.reply ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null, - answer.changes.length > 0 ? (0, import_react27.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react27.createElement)( + answer.reply ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null, + answer.changes.length > 0 ? (0, import_react25.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react25.createElement)( "li", { key: index, "data-ok": change.ok || void 0 }, - change.ok ? (0, import_react27.createElement)(CheckIcon) : (0, import_react27.createElement)(CloseIcon), - (0, import_react27.createElement)("span", null, changeLine(change)) + change.ok ? (0, import_react25.createElement)(CheckIcon) : (0, import_react25.createElement)(CloseIcon), + (0, import_react25.createElement)("span", null, changeLine(change)) ))) : null ) ); } function useMemoryView(botId, actions) { - const [view, setView] = (0, import_react27.useState)(null); - const [error, setError] = (0, import_react27.useState)(""); - const timer = (0, import_react27.useRef)(0); - const latest = (0, import_react27.useRef)(0); - const alive = (0, import_react27.useRef)(true); - const show = (0, import_react27.useCallback)((next) => { + const [view, setView] = (0, import_react25.useState)(null); + const [error, setError] = (0, import_react25.useState)(""); + const timer = (0, import_react25.useRef)(0); + const latest = (0, import_react25.useRef)(0); + const alive = (0, import_react25.useRef)(true); + const show = (0, import_react25.useCallback)((next) => { setView(next); setError(""); noteCount(botId, next.entries.length); }, [botId]); - const load = (0, import_react27.useCallback)(async (flags = {}) => { + const load = (0, import_react25.useCallback)(async (flags = {}) => { clearTimeout(timer.current); const ticket = ++latest.current; try { const next = await actions.memoryView(botId, flags); if (!alive.current || ticket !== latest.current) return; show(next); - if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS6); + if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5); } catch (failure) { if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure)); } }, [botId]); - (0, import_react27.useEffect)(() => { + (0, import_react25.useEffect)(() => { alive.current = true; void load({ summarize: true }); return () => { @@ -13091,8 +12043,8 @@ function useMemoryView(botId, actions) { return { view, error, load, show }; } function useNow3(every) { - const [now, setNow] = (0, import_react27.useState)(Date.now()); - (0, import_react27.useEffect)(() => { + const [now, setNow] = (0, import_react25.useState)(Date.now()); + (0, import_react25.useEffect)(() => { const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); @@ -13100,18 +12052,18 @@ function useNow3(every) { } 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 [page, setPage] = (0, import_react25.useState)("summary"); + const [menu, setMenu] = (0, import_react25.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); - const dotsRef = (0, import_react27.useRef)(null); - const inputRef = (0, import_react27.useRef)(null); + const [text, setText] = (0, import_react25.useState)(""); + const [answer, setAnswer] = (0, import_react25.useState)(null); + const menuRef = (0, import_react25.useRef)(null); + const dotsRef = (0, import_react25.useRef)(null); + const inputRef = (0, import_react25.useRef)(null); const now = useNow3(3e4); - const close = (0, import_react27.useCallback)(() => actions.closeMemory(), []); + const close = (0, import_react25.useCallback)(() => actions.closeMemory(), []); useEscape(close); - (0, import_react27.useEffect)(() => { + (0, import_react25.useEffect)(() => { if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; @@ -13173,52 +12125,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_react27.createElement)( + return (0, import_react25.createElement)( "div", { className: "bt-mem-layer", "data-leaving": leaving || void 0 }, - (0, import_react27.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), - (0, import_react27.createElement)( + (0, import_react25.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), + (0, import_react25.createElement)( "div", { className: "bt-mem", role: "dialog", "aria-modal": true, "aria-label": t("{name}'s memory", { name: bot.name }) }, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-head" }, - page === "entries" ? (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react27.createElement)(ChevronLeftIcon)) : null, - (0, import_react27.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), - (0, import_react27.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), - (0, import_react27.createElement)("span", { className: "bt-mem-head-gap" }), - (0, import_react27.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react27.createElement)(DotsIcon)), - (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react27.createElement)(CloseIcon)), - menuShown ? (0, import_react27.createElement)( + page === "entries" ? (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react25.createElement)(ChevronLeftIcon)) : null, + (0, import_react25.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), + (0, import_react25.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), + (0, import_react25.createElement)("span", { className: "bt-mem-head-gap" }), + (0, import_react25.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_react25.createElement)(DotsIcon)), + (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react25.createElement)(CloseIcon)), + menuShown ? (0, import_react25.createElement)( "div", { ref: menuRef, className: "bt-menu bt-mem-menu", role: "menu", "aria-label": t("Memory options"), "data-leaving": menuLeaving || void 0 }, - (0, import_react27.createElement)( + (0, import_react25.createElement)( "button", { type: "button", role: "menuitem", onClick: openEntries }, - (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("All entries")), - (0, import_react27.createElement)("span", { className: "bt-menu-hint" }, String(count)) + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("All entries")), + (0, import_react25.createElement)("span", { className: "bt-menu-hint" }, String(count)) ), - (0, import_react27.createElement)( + (0, import_react25.createElement)( "button", { type: "button", role: "menuitem", disabled: count === 0 || view?.summarizing, onClick: regenerate }, - (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) + (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) ) ) : null ), - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-body" }, - error ? (0, import_react27.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, - view === null ? error ? null : (0, import_react27.createElement)(Skeleton) : page === "entries" ? (0, import_react27.createElement)(Entries, { bot, view, remove }) : (0, import_react27.createElement)(Summary, { bot, view, regenerate }) + error ? (0, import_react25.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, + view === null ? error ? null : (0, import_react25.createElement)(Skeleton) : page === "entries" ? (0, import_react25.createElement)(Entries, { bot, view, remove }) : (0, import_react25.createElement)(Summary, { bot, view, regenerate }) ), - (0, import_react27.createElement)( + (0, import_react25.createElement)( "div", { className: "bt-mem-foot" }, - answer ? (0, import_react27.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, - (0, import_react27.createElement)( + answer ? (0, import_react25.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, + (0, import_react25.createElement)( "form", { className: "bt-mem-ask", onSubmit: ask }, - (0, import_react27.createElement)("input", { + (0, import_react25.createElement)("input", { ref: inputRef, className: "bt-mem-input", value: text, @@ -13228,10 +12180,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_react27.createElement)( + (0, import_react25.createElement)( "button", { type: "submit", className: "bt-mem-send", disabled: busy || text.trim() === "", "aria-label": t("Send") }, - busy ? (0, import_react27.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react27.createElement)(ArrowUpIcon) + busy ? (0, import_react25.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react25.createElement)(ArrowUpIcon) ) ) ) @@ -13240,7 +12192,7 @@ function MemoryDialog({ bot, actions, leaving = false }) { } // src/client/standing-approvals.js -var import_react28 = require("react"); +var import_react26 = require("react"); var standingOf = (target, roster) => { const me = roster?.self?.memberId; return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me); @@ -13249,19 +12201,19 @@ function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? []; if (grants.length === 0) return null; const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking."); - return (0, import_react28.createElement)( + return (0, import_react26.createElement)( Card2, { title: t("Always allowed") }, - grants.map((grant) => (0, import_react28.createElement)( + grants.map((grant) => (0, import_react26.createElement)( "div", { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind }, - (0, import_react28.createElement)( + (0, import_react26.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react28.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind), - (0, import_react28.createElement)("span", { className: "bt-set-hint" }, hint) + (0, import_react26.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind), + (0, import_react26.createElement)("span", { className: "bt-set-hint" }, hint) ), - (0, import_react28.createElement)("button", { + (0, import_react26.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }), @@ -13272,7 +12224,7 @@ function StandingApprovalsCard({ scope, target, roster, actions, run, Card: Card } // src/client/bot-settings.js -var import_react29 = require("react"); +var import_react27 = 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")); @@ -13296,8 +12248,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_react29.useState)(null); - const reverting = (0, import_react29.useRef)(false); + const [draft, setDraft] = (0, import_react27.useState)(null); + const reverting = (0, import_react27.useRef)(false); const commit = () => { const next = draft?.trim(); setDraft(null); @@ -13308,7 +12260,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_react29.createElement)(multiline ? "textarea" : "input", { + return (0, import_react27.createElement)(multiline ? "textarea" : "input", { className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, @@ -13329,29 +12281,29 @@ function InlineText({ label, value, placeholder, required = false, maxLength, mu }); } function Row({ label, hint, stack = false, children }) { - return (0, import_react29.createElement)( + return (0, import_react27.createElement)( "label", { className: stack ? "bt-set-row bt-bs-row bt-bs-stack" : "bt-set-row bt-bs-row" }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, hint) : null), children ); } function Card({ title, children }) { - return (0, import_react29.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react29.createElement)("h3", null, title) : null, (0, import_react29.createElement)("div", { className: "bt-set-card" }, children)); + return (0, import_react27.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react27.createElement)("h3", null, title) : null, (0, import_react27.createElement)("div", { className: "bt-set-card" }, children)); } function Switch({ on, label, disabled, onChange }) { - return (0, import_react29.createElement)( + return (0, import_react27.createElement)( "button", { type: "button", role: "switch", className: "bt-switch", "aria-checked": on, "aria-label": label, disabled, onClick: () => onChange(!on) }, - (0, import_react29.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) + (0, import_react27.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) ); } function SwitchRow({ label, hint, on, disabled, onChange, showLabel = true }) { - return (0, import_react29.createElement)( + return (0, import_react27.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react29.createElement)("span", { className: "bt-set-hint" }, hint) : null), - (0, import_react29.createElement)(Switch, { on, label, disabled, onChange }) + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, hint) : null), + (0, import_react27.createElement)(Switch, { on, label, disabled, onChange }) ); } function modelHint(bot, roster) { @@ -13362,22 +12314,22 @@ function modelHint(bot, roster) { return t("Applies from the next message."); } function ShapeRing({ shape, size }) { - const maskId = (0, import_react29.useRef)(""); + const maskId = (0, import_react27.useRef)(""); if (maskId.current === "") maskId.current = `bt-ring-${nextMarkSerial()}`; const def = shapeOf(shape); const unit = def.size / size; const markup = `${holes(def, `${bodyOf(def, "#000", true)}`)}${bodyOf(def, "currentColor", true)}`; - return (0, import_react29.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); + return (0, import_react27.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); } function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { const shapes = [...PICKER_SHAPES, ...Object.keys(SHAPES).filter((id) => !Object.hasOwn(CHARACTERS, id))]; if (!shapes.includes(look.shape)) shapes.push(look.shape); const rows = []; for (let index = 0; index < shapes.length; index += PICKER_COLUMNS) rows.push(shapes.slice(index, index + PICKER_COLUMNS)); - return (0, import_react29.createElement)( - import_react29.Fragment, + return (0, import_react27.createElement)( + import_react27.Fragment, null, - (0, import_react29.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react29.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react29.createElement)("button", { + (0, import_react27.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react27.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react27.createElement)("button", { key: id, type: "button", className: "bt-shape", @@ -13385,8 +12337,8 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(shapeOf(id).label), "aria-pressed": !hasPhoto && look.shape === id, onClick: () => onShape(id) - }, (0, import_react29.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react29.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), - (0, import_react29.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react29.createElement)("button", { + }, (0, import_react27.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react27.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), + (0, import_react27.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react27.createElement)("button", { key: color, type: "button", className: "bt-color", @@ -13394,19 +12346,19 @@ function CharacterGrid({ look, hasPhoto = false, onShape, onColor }) { title: t(capitalize(color)), "aria-pressed": look.color === color, onClick: () => onColor(color) - }, (0, import_react29.createElement)("span", { style: { background: paintCss(color) } })))) + }, (0, import_react27.createElement)("span", { style: { background: paintCss(color) } })))) ); } function AvatarEditor({ bot, actions, run, onClose }) { const look = lookOf(bot); - const [tab, setTab] = (0, import_react29.useState)(look.image ? "upload" : "bot"); - const [over, setOver] = (0, import_react29.useState)(false); - const ref = (0, import_react29.useRef)(null); - const fileRef = (0, import_react29.useRef)(null); + const [tab, setTab] = (0, import_react27.useState)(look.image ? "upload" : "bot"); + const [over, setOver] = (0, import_react27.useState)(false); + const ref = (0, import_react27.useRef)(null); + const fileRef = (0, import_react27.useRef)(null); const upload = (file) => { if (file) void run(avatarImage(file).then((image) => actions.updateBot(bot.id, { avatar: { ...bot.avatar ?? {}, image } }))); }; - (0, import_react29.useEffect)(() => { + (0, import_react27.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); @@ -13424,7 +12376,7 @@ function AvatarEditor({ bot, actions, run, onClose }) { window.removeEventListener("keydown", onKey, true); }; }, [onClose]); - (0, import_react29.useEffect)(() => { + (0, import_react27.useEffect)(() => { if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); @@ -13438,32 +12390,32 @@ function AvatarEditor({ bot, actions, run, onClose }) { }, [tab, bot]); const hasPhoto = Boolean(look.image); const glide = useGlide(tab); - return (0, import_react29.createElement)( + return (0, import_react27.createElement)( "div", { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-avedit-top" }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { ref: glide.box, role: "tablist", "aria-label": t("Avatar source"), className: "bt-avedit-tabs" }, - (0, import_react29.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react29.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) + (0, import_react27.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react27.createElement)("button", { key: id, type: "button", role: "tab", className: "bt-avedit-tab", "aria-selected": tab === id, onClick: () => setTab(id) }, t(label))) ), - (0, import_react29.createElement)("button", { + (0, import_react27.createElement)("button", { type: "button", className: "bt-avedit-tab bt-avedit-reset", title: t("Reset character to default"), onClick: () => run(actions.updateBot(bot.id, { avatar: null, color: null })) }, t("Reset")) ), - tab === "bot" ? (0, import_react29.createElement)(CharacterGrid, { + tab === "bot" ? (0, import_react27.createElement)(CharacterGrid, { look, hasPhoto, onShape: (shape) => run(actions.updateBot(bot.id, { avatar: { shape } })), onColor: (color) => run(actions.updateBot(bot.id, { color })) }) : null, - tab === "upload" ? (0, import_react29.createElement)( + tab === "upload" ? (0, import_react27.createElement)( "div", { className: "bt-dropzone", @@ -13479,15 +12431,15 @@ function AvatarEditor({ bot, actions, run, onClose }) { upload(event.dataTransfer.files?.[0]); } }, - hasPhoto ? (0, import_react29.createElement)("img", { src: look.image, alt: "" }) : null, - (0, import_react29.createElement)("div", null, t("Drag, drop, or paste an image")), - (0, import_react29.createElement)( + hasPhoto ? (0, import_react27.createElement)("img", { src: look.image, alt: "" }) : null, + (0, import_react27.createElement)("div", null, t("Drag, drop, or paste an image")), + (0, import_react27.createElement)( "div", { className: "bt-actions", style: { justifyContent: "center" } }, - (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), - hasPhoto ? (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), + hasPhoto ? (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.updateBot(bot.id, { avatar: hasShape(bot.avatar?.shape) ? { shape: bot.avatar.shape } : null })) }, t("Remove photo")) : null ), - (0, import_react29.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { + (0, import_react27.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); @@ -13498,22 +12450,22 @@ 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_react29.createElement)( + return (0, import_react27.createElement)( Card, { title: t("Memory") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, label)), - (0, import_react29.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react29.createElement)(ChevronRightIcon)) + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label)), + (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon)) ) ); } function BotSettingsForm({ bot, roster, actions, run }) { - const [editing, setEditing] = (0, import_react29.useState)(false); - const [confirming, setConfirming] = (0, import_react29.useState)(false); - const closeEditor = (0, import_react29.useCallback)(() => setEditing(false), []); - (0, import_react29.useEffect)(() => { + const [editing, setEditing] = (0, import_react27.useState)(false); + const [confirming, setConfirming] = (0, import_react27.useState)(false); + const closeEditor = (0, import_react27.useCallback)(() => setEditing(false), []); + (0, import_react27.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); @@ -13528,116 +12480,116 @@ function BotSettingsForm({ bot, roster, actions, run }) { if (confirming) void run(actions.deleteBot(bot.id)); else setConfirming(true); }; - (0, import_react29.useEffect)(() => { + (0, import_react27.useEffect)(() => { void actions.refreshModels?.(); }, [bot.id]); - return (0, import_react29.createElement)( + return (0, import_react27.createElement)( "div", { className: "bt-bs" }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-bs-hero" }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-avatar-anchor" }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "button", { type: "button", className: "bt-avatar-trigger", "aria-label": t("Edit Bot avatar"), "aria-expanded": editing, onClick: () => setEditing((value) => !value) }, - (0, import_react29.createElement)(BotMark, { bot, size: 64, live: true }), - (0, import_react29.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react29.createElement)(PencilIcon)) + (0, import_react27.createElement)(BotMark, { bot, size: 64, live: true }), + (0, import_react27.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react27.createElement)(PencilIcon)) ), - editing ? (0, import_react29.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null + editing ? (0, import_react27.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-bs-who" }, - (0, import_react29.createElement)("span", { className: "bt-bs-name" }, bot.name), - (0, import_react29.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) + (0, import_react27.createElement)("span", { className: "bt-bs-name" }, bot.name), + (0, import_react27.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) ) ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( Card, { title: t("Profile") }, - (0, import_react29.createElement)(Row, { label: t("Name") }, (0, import_react29.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), - (0, import_react29.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react29.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) + (0, import_react27.createElement)(Row, { label: t("Name") }, (0, import_react27.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), + (0, import_react27.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react27.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( Card, { title: t("Model") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), - (0, import_react29.createElement)(ModelMenu, { bot, roster, actions, run }) + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), + (0, import_react27.createElement)(ModelMenu, { bot, roster, actions, run }) ), - bot.model ? (0, import_react29.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null + bot.model ? (0, import_react27.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( Card, { title: t("Working folder") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), - (0, import_react29.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react27.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) ) ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( Card, { title: t("Instructions") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( Row, { label: t("What this Bot does"), hint: t("Its job on the team. Part of the Bot's system prompt."), stack: true }, - (0, import_react29.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) + (0, import_react27.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) ) ), // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406). - (0, import_react29.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react29.createElement)(Card, { title: t("Sharing") }, children) }), - (0, import_react29.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), - (0, import_react29.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }), - roster.memory === false ? null : (0, import_react29.createElement)(MemoryCard, { bot, actions }), - (0, import_react29.createElement)(BotSchedules, { bot, roster, actions }), - (0, import_react29.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), - (0, import_react29.createElement)( + (0, import_react27.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react27.createElement)(Card, { title: t("Sharing") }, children) }), + (0, import_react27.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), + (0, import_react27.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }), + roster.memory === false ? null : (0, import_react27.createElement)(MemoryCard, { bot, actions }), + (0, import_react27.createElement)(BotSchedules, { bot, roster, actions }), + (0, import_react27.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), + (0, import_react27.createElement)( Card, { title: t("Main Bot") }, - onlyMain ? (0, import_react29.createElement)( + onlyMain ? (0, import_react27.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) + (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) ), - (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) - ) : (0, import_react29.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) + (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) + ) : (0, import_react27.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ), // The Main Bot tile is always first and always shown, like in the context menu. - isTile ? null : (0, import_react29.createElement)( + isTile ? null : (0, import_react27.createElement)( Card, { title: t("Sidebar") }, - (0, import_react29.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), - isMainBot ? null : (0, import_react29.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) + (0, import_react27.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_react27.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) ), - (0, import_react29.createElement)( + (0, import_react27.createElement)( Card, { title: t("More") }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, - (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), - (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon)) + (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), + (0, import_react27.createElement)("span", { className: "bt-chevron" }, (0, import_react27.createElement)(ChevronRightIcon)) ), - isMainBot ? null : (0, import_react29.createElement)( + isMainBot ? null : (0, import_react27.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, - (0, import_react29.createElement)( + (0, import_react27.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react29.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), - (0, import_react29.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) + (0, import_react27.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), + (0, import_react27.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) ), - (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon)) + (0, import_react27.createElement)("span", { className: "bt-chevron" }, (0, import_react27.createElement)(ChevronRightIcon)) ) ) ); @@ -13678,7 +12630,7 @@ ${MODEL_TEST_CSS} `; // src/client/group-settings.js -var import_react30 = require("react"); +var import_react28 = require("react"); var ROOM_MODES = [ ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."], ["everyone", "Everyone", "Every member answers in turn, admin first."] @@ -13710,9 +12662,9 @@ function faceMarks(person, roster, size) { for (const seat of person?.seats ?? []) { const bot = faceSeatBot(seat, roster); if (!bot) continue; - marks.push((0, import_react30.createElement)(BotMark, { key: seat.key, bot, size, live: false })); + marks.push((0, import_react28.createElement)(BotMark, { key: seat.key, bot, size, live: false })); } - if (marks.length === 0) return [(0, import_react30.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + if (marks.length === 0) return [(0, import_react28.createElement)(PersonMark, { key: "person", size, name: person?.name })]; return marks; } function faceRoomCount(room2, roster) { @@ -13726,31 +12678,31 @@ var handleKey = (handle) => String(handle ?? "").trim().replace(/^@+/, "").toLow var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead)); function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { const bots = person.seats.flatMap((seat, index) => { - const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); - const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; - return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; + const name = seat.local && onOpenBot ? (0, import_react28.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_react28.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_react28.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; + return index === 0 ? marked : [(0, import_react28.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; }); - return (0, import_react30.createElement)( + return (0, import_react28.createElement)( "div", { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-gs-who bt-face-person" }, - (0, import_react30.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), - (0, import_react30.createElement)( + (0, import_react28.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), + (0, import_react28.createElement)( "span", { className: "bt-face-person-text" }, - (0, import_react30.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)), - (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots) + (0, import_react28.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)), + (0, import_react28.createElement)("span", { className: "bt-face-bots" }, bots) ) ) ); } function FaceLead({ room: room2, roster, pick: pick2 }) { const lead = room2.face?.lead; - const [open, setOpen] = (0, import_react30.useState)(false); - const [busy, setBusy] = (0, import_react30.useState)(false); - const [error, setError] = (0, import_react30.useState)(""); + const [open, setOpen] = (0, import_react28.useState)(false); + const [busy, setBusy] = (0, import_react28.useState)(false); + const [error, setError] = (0, import_react28.useState)(""); const seats = faceSeats(room2, roster); const current = seats.find((seat) => isLeadSeat(seat, lead?.handle)); if (!current) return null; @@ -13767,30 +12719,30 @@ function FaceLead({ room: room2, roster, pick: pick2 }) { setBusy(false); } }; - return (0, import_react30.createElement)( + return (0, import_react28.createElement)( "div", { className: "bt-face-lead", "data-flybot": "face-lead" }, - (0, import_react30.createElement)("div", { className: "bt-section-title" }, t("Room lead")), - (0, import_react30.createElement)( + (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Room lead")), + (0, import_react28.createElement)( "div", { className: "bt-set-row" }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react30.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)), - (0, import_react30.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined.")) + (0, import_react28.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)), + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined.")) ), - canPick ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null + canPick ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null ), - open && canPick ? seats.map((seat) => (0, import_react30.createElement)( + open && canPick ? seats.map((seat) => (0, import_react28.createElement)( "div", { key: seat.key, className: "bt-set-row" }, - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)), - seat === current ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)), + seat === current ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) )) : null, - open && canPick && lead.picked ? (0, import_react30.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null, - canPick || !lead.supported ? null : (0, import_react30.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), - error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null + open && canPick && lead.picked ? (0, import_react28.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_react28.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + error ? (0, import_react28.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null ); } function seatedCount(room2) { @@ -13806,9 +12758,9 @@ function roomSubtitle(room2, roster) { return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); } function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { - const [adding, setAdding] = (0, import_react30.useState)(false); - const [confirming, setConfirming] = (0, import_react30.useState)(false); - (0, import_react30.useEffect)(() => { + const [adding, setAdding] = (0, import_react28.useState)(false); + const [confirming, setConfirming] = (0, import_react28.useState)(false); + (0, import_react28.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); @@ -13846,41 +12798,41 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (done) onDeleted?.(); }); }; - return (0, import_react30.createElement)( + return (0, import_react28.createElement)( "div", { className: "bt-bs" }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "div", { className: "bt-bs-hero" }, - (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - (0, import_react30.createElement)( + (0, import_react28.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), + (0, import_react28.createElement)( "div", { className: "bt-bs-who" }, - (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name), - (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) + (0, import_react28.createElement)("span", { className: "bt-bs-name" }, room2.name), + (0, import_react28.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) ) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( Card, { title: t("Profile") }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( Row, { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, - (0, import_react30.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) + (0, import_react28.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( Row, { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, - (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) + (0, import_react28.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) ) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( Card, { title: t("Who answers") }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "div", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", { + (0, import_react28.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react28.createElement)("button", { key: id, type: "button", "aria-pressed": mode === id, @@ -13890,108 +12842,108 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), - (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) + (0, import_react28.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) ) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( Card, { title: t("Working folder") }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), - (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), + (0, import_react28.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) ) ), - face ? (0, import_react30.createElement)( + face ? (0, import_react28.createElement)( Card, { title: t("Members · {count}", { count: people2.length }) }, - people2.map((person) => (0, import_react30.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) + people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) ) : null, - face ? null : (0, import_react30.createElement)( + face ? null : (0, import_react28.createElement)( Card, { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, - humans.map((person) => (0, import_react30.createElement)( + humans.map((person) => (0, import_react28.createElement)( "div", { key: person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-gs-who" }, - (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), - (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")), - person.role === "admin" ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null + (0, import_react28.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), + (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Human")), + person.role === "admin" ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Admin")) : null ), - humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null + humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null )), members.map((member) => { const leads = room2.admin === member.id; const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share"; const taskLead = room2.taskLead === member.id; - return (0, import_react30.createElement)( + return (0, import_react28.createElement)( "div", { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "button", { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, - (0, import_react30.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), - (0, import_react30.createElement)("span", { className: "bt-row-title" }, member.name), - leads ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null, - taskLead ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null, - (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Chief")) : null + (0, import_react28.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), + (0, import_react28.createElement)("span", { className: "bt-row-title" }, member.name), + leads ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Admin")) : null, + taskLead ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null, + (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Chief")) : null ), - leads ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), - boardHere ? taskLead ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null, - members.length > 2 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null + leads ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), + boardHere ? taskLead ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null, + members.length > 2 ? (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null ); }), - remotes.map((person) => (0, import_react30.createElement)( + remotes.map((person) => (0, import_react28.createElement)( "div", { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-gs-who" }, - (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), + (0, import_react28.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react28.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is // labelled as such; a Bot with no Bot Social identity can never be written to at all. - room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) + room2.origin === "face-to-face" ? null : (0, import_react28.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) ), // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. - room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") )), room2.origin === "face-to-face" ? null : adding ? [ - ...outsidePeople.map((person) => (0, import_react30.createElement)( + ...outsidePeople.map((person) => (0, import_react28.createElement)( "button", { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] }); } }, - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)), - (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add person")) + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)), + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add person")) )), - ...outside.map((bot) => (0, import_react30.createElement)( + ...outside.map((bot) => (0, import_react28.createElement)( "button", { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { setAdding(false); void update({ add: [bot.id] }); } }, - (0, import_react30.createElement)(BotMark, { bot, size: 22 }), - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, bot.name)), - (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add")) + (0, import_react28.createElement)(BotMark, { bot, size: 22 }), + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, bot.name)), + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add")) )), - ...outsideRemotes.map((bot) => (0, import_react30.createElement)( + ...outsideRemotes.map((bot) => (0, import_react28.createElement)( "button", { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] }); } }, - (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), - (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) + (0, import_react28.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) )), - (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) - ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null, - (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)( + (0, import_react28.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) + ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react28.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react28.createElement)(PlusIcon), t("Add member")) : null, + (0, import_react28.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react28.createElement)( "span", { className: "bt-set-hint" }, // The host checks for two members on every change, even a rename. @@ -13999,37 +12951,37 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { )), // #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 }) + room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react28.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }) ), - (0, import_react30.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), - (0, import_react30.createElement)( + (0, import_react28.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), + (0, import_react28.createElement)( Card, { title: t("Sidebar") }, - (0, import_react30.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: room2.pinned === true, onChange: (pinned) => run(actions.setFlags(room2.id, { pinned })) }), - (0, import_react30.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) + (0, import_react28.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_react28.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: room2.hidden === true, onChange: (hidden) => run(actions.setFlags(room2.id, { hidden })) }) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( Card, { title: t("More") }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "button", { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, - (0, import_react30.createElement)( + (0, import_react28.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react30.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), - (0, import_react30.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its history is archived.") : t("Removes the group chat. Its members stay on the team.")) + (0, import_react28.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), + (0, import_react28.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_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon)) + (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) ) ) ); } function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { - const [admin, setAdmin] = (0, import_react30.useState)(null); - const [members, setMembers] = (0, import_react30.useState)([]); - const [name, setName] = (0, import_react30.useState)(""); - const [busy, setBusy] = (0, import_react30.useState)(false); + const [admin, setAdmin] = (0, import_react28.useState)(null); + const [members, setMembers] = (0, import_react28.useState)([]); + const [name, setName] = (0, import_react28.useState)(""); + const [busy, setBusy] = (0, import_react28.useState)(false); const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); const pickAdmin = (id) => { setAdmin(id); @@ -14045,48 +12997,48 @@ function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { if (done && room2) onCreated(room2); else setBusy(false); }; - const chip = (bot, on, onClick, role) => (0, import_react30.createElement)("button", { + const chip = (bot, on, onClick, role) => (0, import_react28.createElement)("button", { key: bot.id, type: "button", role, className: "bt-ng-chip", "aria-checked": on, onClick - }, (0, import_react30.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react30.createElement)(AdminStar) : null); - return (0, import_react30.createElement)( + }, (0, import_react28.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react28.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react28.createElement)(AdminStar) : null); + return (0, import_react28.createElement)( "div", { className: "bt-set-card bt-ng" }, - (0, import_react30.createElement)( + (0, import_react28.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)( + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react28.createElement)(AdminStar), t("Choose the group admin"))), + (0, import_react28.createElement)( "div", { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) ) ), - admin === null ? null : (0, import_react30.createElement)( + admin === null ? null : (0, import_react28.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)( + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), + (0, import_react28.createElement)( "div", { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) ) ), - admin === null || members.length === 0 ? null : (0, import_react30.createElement)( + admin === null || members.length === 0 ? null : (0, import_react28.createElement)( "label", { className: "bt-set-row bt-bs-row bt-ng-step" }, - (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react30.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) + (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Name"))), + (0, import_react28.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) ), - (0, import_react30.createElement)( + (0, import_react28.createElement)( "div", { className: "bt-set-row bt-task-foot" }, - (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) + (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), + (0, import_react28.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) ) ); } @@ -14122,13 +13074,13 @@ button.bt-face-bot:hover{color:var(--bt-ink);text-decoration:underline;text-unde `; // src/client/palette.js -var import_react31 = require("react"); +var import_react29 = require("react"); var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); var PALETTE_EXIT_MS = 160; function CommandPalette({ start, token, roster, actions, useSessions }) { - const [query, setQuery] = (0, import_react31.useState)(start ?? ""); - const [cursor, setCursor] = (0, import_react31.useState)(0); - const [leaving, setLeaving] = (0, import_react31.useState)(false); + const [query, setQuery] = (0, import_react29.useState)(start ?? ""); + const [cursor, setCursor] = (0, import_react29.useState)(0); + const [leaving, setLeaving] = (0, import_react29.useState)(false); const wanted = query.trim().toLowerCase(); const close = () => { if (leaving) return; @@ -14164,7 +13116,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { const people2 = [ ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ key: bot.id, - avatar: (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), + avatar: (0, import_react29.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), title: pickerBotLabel(bot), subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), badge: bot.hidden ? t("Hidden") : void 0, @@ -14175,7 +13127,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { })), ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ key: room2.id, - avatar: (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), + avatar: (0, import_react29.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), title: room2.name, subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }), badge: room2.hidden ? t("Hidden") : void 0, @@ -14188,7 +13140,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { if (people2.length) sections.push([t("Bots"), people2]); const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({ key: agent.id, - avatar: (0, import_react31.createElement)("span", { className: "bt-palette-agent" }, (0, import_react31.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), + avatar: (0, import_react29.createElement)("span", { className: "bt-palette-agent" }, (0, import_react29.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), title: "DSH Agent", subtitle: t("Plain dsh session"), run: () => actions.openAgent(agent.id) @@ -14201,16 +13153,16 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { key: `${id}:${turn}`, title: bot?.name ?? room2?.name ?? t("Bot"), subtitle: snippet, - avatar: bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), + avatar: bot ? (0, import_react29.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react29.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), run: () => actions.openSession(id) }; }); if (messages.length) sections.push([t("Messages"), messages]); const commands = [ - { key: "cmd:new", icon: (0, import_react31.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, - { key: "cmd:bot", icon: (0, import_react31.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, - { key: "cmd:group", icon: (0, import_react31.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, - { key: "cmd:settings", icon: (0, import_react31.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") } + { key: "cmd:new", icon: (0, import_react29.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, + { key: "cmd:bot", icon: (0, import_react29.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, + { key: "cmd:group", icon: (0, import_react29.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, + { key: "cmd:settings", icon: (0, import_react29.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") } ].filter((command) => matchesName(command.title)); if (commands.length) sections.push([t("Commands"), commands]); const rows = sections.flatMap(([, items]) => items); @@ -14220,7 +13172,7 @@ function CommandPalette({ start, token, roster, actions, useSessions }) { ${shown}`, { axis: "y", ...QUICK_EDGES }); const listRef = glide.box; useFlip(listRef, shown, "[data-flip]"); - (0, import_react31.useEffect)(() => { + (0, import_react29.useEffect)(() => { listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); }, [active, query]); const choose = (row) => { @@ -14229,18 +13181,18 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); close(); }; let index = -1; - return (0, import_react31.createElement)( + return (0, import_react29.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)( + (0, import_react29.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), + (0, import_react29.createElement)( "div", { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, - (0, import_react31.createElement)( + (0, import_react29.createElement)( "div", { className: "bt-palette-head" }, - (0, import_react31.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)), - (0, import_react31.createElement)("input", { + (0, import_react29.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react29.createElement)(SearchIcon)), + (0, import_react29.createElement)("input", { autoFocus: true, value: query, placeholder: t("Search Bots, chats and messages"), @@ -14266,29 +13218,29 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); } } }), - query ? (0, import_react31.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { + query ? (0, import_react29.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_react29.createElement)(CloseIcon)) : null ), - (0, import_react31.createElement)( + (0, import_react29.createElement)( "div", { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, - (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - rows.length === 0 ? (0, import_react31.createElement)( + (0, import_react29.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + rows.length === 0 ? (0, import_react29.createElement)( "div", { className: "bt-palette-empty" }, - (0, import_react31.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)), - (0, import_react31.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), - (0, import_react31.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) - ) : sections.map(([title, items]) => (0, import_react31.createElement)( - import_react31.Fragment, + (0, import_react29.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react29.createElement)(SearchIcon)), + (0, import_react29.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), + (0, import_react29.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) + ) : sections.map(([title, items]) => (0, import_react29.createElement)( + import_react29.Fragment, { key: title }, - (0, import_react31.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), + (0, import_react29.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), items.map((row) => { index += 1; const at = index; - return (0, import_react31.createElement)( + return (0, import_react29.createElement)( "button", { key: row.key, @@ -14302,14 +13254,14 @@ ${shown}`, { axis: "y", ...QUICK_EDGES }); }, onClick: () => choose(row) }, - (0, import_react31.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react31.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), - (0, import_react31.createElement)( + (0, import_react29.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react29.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), + (0, import_react29.createElement)( "span", { className: "bt-palette-text" }, - (0, import_react31.createElement)("span", { className: "bt-palette-title" }, row.title), - row.subtitle ? (0, import_react31.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null + (0, import_react29.createElement)("span", { className: "bt-palette-title" }, row.title), + row.subtitle ? (0, import_react29.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null ), - row.badge ? (0, import_react31.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null + row.badge ? (0, import_react29.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null ); }) )) @@ -14351,7 +13303,7 @@ async function afterCreate({ id, text, actions }) { } // src/client/create-bot.js -var import_react32 = require("react"); +var import_react30 = require("react"); var pick = (list) => list[Math.floor(Math.random() * list.length)]; var randomLook = (avoid = {}) => { const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape); @@ -14359,17 +13311,17 @@ var randomLook = (avoid = {}) => { return { shape: pick(shapes), color: pick(colors) }; }; function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) { - const [look, setLook] = (0, import_react32.useState)(() => randomLook()); - const [image, setImage] = (0, import_react32.useState)(null); - const [name, setName] = (0, import_react32.useState)(start?.name ?? ""); - const [model, setModel] = (0, import_react32.useState)(() => startModel(roster)); - const [effort, setEffort] = (0, import_react32.useState)(void 0); - const [busy, setBusy] = (0, import_react32.useState)(false); - const [error, setError] = (0, import_react32.useState)(""); - const fileRef = (0, import_react32.useRef)(null); - const frame = (0, import_react32.useRef)(null); + const [look, setLook] = (0, import_react30.useState)(() => randomLook()); + const [image, setImage] = (0, import_react30.useState)(null); + const [name, setName] = (0, import_react30.useState)(start?.name ?? ""); + const [model, setModel] = (0, import_react30.useState)(() => startModel(roster)); + const [effort, setEffort] = (0, import_react30.useState)(void 0); + const [busy, setBusy] = (0, import_react30.useState)(false); + const [error, setError] = (0, import_react30.useState)(""); + const fileRef = (0, import_react30.useRef)(null); + const frame = (0, import_react30.useRef)(null); useEscape(onBack); - (0, import_react32.useEffect)(() => { + (0, import_react30.useEffect)(() => { if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); @@ -14394,25 +13346,25 @@ function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inlin avatarImage(file).then(setImage, (failure) => setError(failure?.message ?? String(failure))); }; const previewLook = image ? { ...look, image } : look; - const card = (0, import_react32.createElement)( + const card = (0, import_react30.createElement)( "div", { className: "bt-create-card" }, - (0, import_react32.createElement)( + (0, import_react30.createElement)( "div", { className: "bt-create-preview" }, // The key remounts the mark, so every new pick plays the pop. - (0, import_react32.createElement)( + (0, import_react30.createElement)( "span", { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" }, - (0, import_react32.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true }) + (0, import_react30.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true }) ), - (0, import_react32.createElement)("span", { className: "bt-create-name" }, finalName) + (0, import_react30.createElement)("span", { className: "bt-create-name" }, finalName) ), - (0, import_react32.createElement)("div", { className: "bt-create-step" }, t("Look")), - (0, import_react32.createElement)( + (0, import_react30.createElement)("div", { className: "bt-create-step" }, t("Look")), + (0, import_react30.createElement)( "div", { className: "bt-create-looks" }, - (0, import_react32.createElement)(CharacterGrid, { + (0, import_react30.createElement)(CharacterGrid, { look, hasPhoto: Boolean(image), onShape: (shape) => { @@ -14424,27 +13376,27 @@ function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inlin setLook((current) => ({ ...current, color })); } }), - (0, import_react32.createElement)( + (0, import_react30.createElement)( "div", { className: "bt-actions bt-create-lookbar" }, - (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { setImage(null); setLook((current) => randomLook(current)); } }, t("Shuffle")), - (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")), - image ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null, - (0, import_react32.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { + (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")), + image ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null, + (0, import_react30.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { const file = event.target.files?.[0]; event.target.value = ""; upload(file); } }) ) ), - (0, import_react32.createElement)( + (0, import_react30.createElement)( "label", { className: "bt-field" }, - (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Name")), - (0, import_react32.createElement)("input", { + (0, import_react30.createElement)("span", { className: "bt-field-label" }, t("Name")), + (0, import_react30.createElement)("input", { className: "bt-input", autoFocus: !inline, value: name, @@ -14459,59 +13411,59 @@ function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inlin } } }), - problem ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null + problem ? (0, import_react30.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react30.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null ), - (0, import_react32.createElement)( + (0, import_react30.createElement)( "div", { className: "bt-field" }, - (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Model")), - (0, import_react32.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => { + (0, import_react30.createElement)("span", { className: "bt-field-label" }, t("Model")), + (0, import_react30.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => { setModel(ref); setEffort(void 0); }, onPickEffort: setEffort }), - (0, import_react32.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later.")) + (0, import_react30.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later.")) ), - error ? (0, import_react32.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, - (0, import_react32.createElement)( + error ? (0, import_react30.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, + (0, import_react30.createElement)( "div", { className: "bt-create-foot" }, // The dialog's header already has Back and Close (#230); only the inline form needs its own. - inline ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null, - (0, import_react32.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create")) + inline ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null, + (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create")) ) ); - if (inline) return (0, import_react32.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card); - return (0, import_react32.createElement)( + if (inline) return (0, import_react30.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card); + return (0, import_react30.createElement)( "div", { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") }, - (0, import_react32.createElement)( + (0, import_react30.createElement)( "div", { className: "bt-create-head" }, - canGoBack ? (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react32.createElement)(ChevronLeftIcon)) : (0, import_react32.createElement)("span", { className: "bt-create-gap" }), - (0, import_react32.createElement)("span", { className: "bt-create-title" }, t("Create Bot")), - (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react32.createElement)(CloseIcon)) + canGoBack ? (0, import_react30.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react30.createElement)(ChevronLeftIcon)) : (0, import_react30.createElement)("span", { className: "bt-create-gap" }), + (0, import_react30.createElement)("span", { className: "bt-create-title" }, t("Create Bot")), + (0, import_react30.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react30.createElement)(CloseIcon)) ), - (0, import_react32.createElement)("div", { className: "bt-create-body" }, card) + (0, import_react30.createElement)("div", { className: "bt-create-body" }, card) ); } // src/client/new-chat.js -var import_react33 = require("react"); +var import_react31 = require("react"); function NewChat({ mode, roster, actions }) { - const [query, setQuery] = (0, import_react33.useState)(""); - const [picked, setPicked] = (0, import_react33.useState)([]); - const [group, setGroup] = (0, import_react33.useState)(mode === "group"); - const [compose, setCompose] = (0, import_react33.useState)(mode !== "new"); - const [admin, setAdmin] = (0, import_react33.useState)(null); - const [message, setMessage] = (0, import_react33.useState)(""); - const [busy, setBusy] = (0, import_react33.useState)(false); - const [error, setError] = (0, import_react33.useState)(""); - const [fix, setFix] = (0, import_react33.useState)(null); - const [fixNote, setFixNote] = (0, import_react33.useState)(""); - const [inviteLink, setInviteLink] = (0, import_react33.useState)(""); - const [cursor, setCursor] = (0, import_react33.useState)(0); - const [draft, setDraft] = (0, import_react33.useState)(mode === "create" ? { name: "", fromList: false } : null); - const inputRef = (0, import_react33.useRef)(null); + const [query, setQuery] = (0, import_react31.useState)(""); + const [picked, setPicked] = (0, import_react31.useState)([]); + const [group, setGroup] = (0, import_react31.useState)(mode === "group"); + const [compose, setCompose] = (0, import_react31.useState)(mode !== "new"); + const [admin, setAdmin] = (0, import_react31.useState)(null); + const [message, setMessage] = (0, import_react31.useState)(""); + const [busy, setBusy] = (0, import_react31.useState)(false); + const [error, setError] = (0, import_react31.useState)(""); + const [fix, setFix] = (0, import_react31.useState)(null); + const [fixNote, setFixNote] = (0, import_react31.useState)(""); + const [inviteLink, setInviteLink] = (0, import_react31.useState)(""); + const [cursor, setCursor] = (0, import_react31.useState)(0); + const [draft, setDraft] = (0, import_react31.useState)(mode === "create" ? { name: "", fromList: false } : null); + const inputRef = (0, import_react31.useRef)(null); const close = () => actions.closeOverlay(); useEscape(close); const paneLeft = usePaneLeft(); @@ -14642,14 +13594,14 @@ function NewChat({ mode, roster, actions }) { const options = []; if (!group && picked.length === 0 && mode !== "search" && wanted === "") { if (menuOnly) { - options.push({ key: "chat", icon: (0, import_react33.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => { + options.push({ key: "chat", icon: (0, import_react31.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => { setCompose(true); setCursor(0); setTimeout(() => inputRef.current?.focus()); } }); } - options.push({ key: "new", icon: (0, import_react33.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot }); - options.push({ key: "group", icon: (0, import_react33.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => { + options.push({ key: "new", icon: (0, import_react31.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot }); + options.push({ key: "group", icon: (0, import_react31.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => { setGroup(true); setCompose(true); setCursor(0); @@ -14658,7 +13610,7 @@ function NewChat({ mode, roster, actions }) { for (const action of newChatActionRows({ mode, group, picked, query, roster })) { options.push({ key: action.key, - icon: action.icon ? (0, import_react33.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react33.createElement)(PeopleIcon) }) : (0, import_react33.createElement)(PeopleIcon), + icon: action.icon ? (0, import_react31.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react31.createElement)(PeopleIcon) }) : (0, import_react31.createElement)(PeopleIcon), label: t(action.label), hint: action.hint ? t(action.hint) : void 0, run: () => { @@ -14670,7 +13622,7 @@ function NewChat({ mode, roster, actions }) { }); } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react33.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { + options.push({ key: "agent", icon: (0, import_react31.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -14681,19 +13633,19 @@ function NewChat({ mode, roster, actions }) { options.push({ key: bot.id, bot, label: pickerBotLabel(bot), hint: choosingAdmin ? t("Make admin") : group || picked.length > 0 ? t("Add to group chat") : void 0, run: () => openBot(bot) }); } } - if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react33.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot }); + if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react31.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot }); const active = Math.min(cursor, Math.max(options.length - 1, 0)); const listed = options.map((option) => option.key).join("\n"); const glide = useGlide(`${active} ${listed}`, { axis: "y", ...QUICK_EDGES }); useFlip(glide.box, listed, "[data-flip]"); - const drop = (0, import_react33.useRef)(null); + const drop = (0, import_react31.useRef)(null); useMorph(drop, listed); if (draft) { - return (0, import_react33.createElement)( + return (0, import_react31.createElement)( "div", { className: "bt-pane", style: { left: paneLeft } }, - (0, import_react33.createElement)(CreateBot, { + (0, import_react31.createElement)(CreateBot, { roster, actions, start: draft, @@ -14713,7 +13665,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (picked.length > 0) setPicked((current) => current.slice(0, -1)); else if (admin !== null) setAdmin(null); }; - return (0, import_react33.createElement)( + return (0, import_react31.createElement)( "div", { className: "bt-pane", @@ -14723,31 +13675,31 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); if (event.target === event.currentTarget) close(); } : void 0 }, - (0, import_react33.createElement)( + (0, import_react31.createElement)( "div", { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 }, // The popover needs no title row: Escape or a press outside closes it. - menuOnly ? null : (0, import_react33.createElement)( + menuOnly ? null : (0, import_react31.createElement)( "div", { className: "bt-to" }, - (0, import_react33.createElement)("span", { className: "bt-to-label" }, t("To:")), - adminBot ? (0, import_react33.createElement)( + (0, import_react31.createElement)("span", { className: "bt-to-label" }, t("To:")), + adminBot ? (0, import_react31.createElement)( "span", { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") }, - (0, import_react33.createElement)(BotMark, { bot: adminBot, size: 16 }), + (0, import_react31.createElement)(BotMark, { bot: adminBot, size: 16 }), adminBot.name, - (0, import_react33.createElement)(AdminStar), - (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×") + (0, import_react31.createElement)(AdminStar), + (0, import_react31.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×") ) : null, - choosingAdmin ? (0, import_react33.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react33.createElement)(AdminStar)) : null, - pickedBots.map((bot) => (0, import_react33.createElement)( + choosingAdmin ? (0, import_react31.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react31.createElement)(AdminStar)) : null, + pickedBots.map((bot) => (0, import_react31.createElement)( "span", { key: bot.id, className: "bt-token" }, - (0, import_react33.createElement)(BotMark, { bot, size: 16 }), + (0, import_react31.createElement)(BotMark, { bot, size: 16 }), bot.name, - (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×") + (0, import_react31.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×") )), - (0, import_react33.createElement)("input", { + (0, import_react31.createElement)("input", { ref: inputRef, autoFocus: true, value: query, @@ -14771,20 +13723,20 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), - group && admin !== null ? (0, import_react33.createElement)("button", { + group && admin !== null ? (0, import_react31.createElement)("button", { type: "button", className: "bt-send bt-to-create", disabled: busy || picked.length === 0, title: picked.length === 0 ? t("Add at least one more Bot") : void 0, onClick: createGroup }, t("Create group chat")) : null, - (0, import_react33.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") + (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") ), - (0, import_react33.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react33.createElement)( + (0, import_react31.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react31.createElement)( "div", { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" }, - (0, import_react33.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.length === 0 ? (0, import_react33.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react33.createElement)( + (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + options.length === 0 ? (0, import_react31.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react31.createElement)( "button", { key: option.key, @@ -14796,36 +13748,36 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, - option.bot ? (0, import_react33.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react33.createElement)("span", { className: "bt-option-icon" }, option.icon), + option.bot ? (0, import_react31.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react31.createElement)("span", { className: "bt-option-icon" }, option.icon), // An action row's description sits under its title and is always there (#283): a long line // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows. // A Bot row's short hint stays beside the name and shows on the active row only. - option.bot || !option.hint ? (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react33.createElement)("span", { className: "bt-option-copy" }, (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react33.createElement)("span", { className: "bt-option-sub" }, option.hint)), - option.bot && option.hint && index === active ? (0, import_react33.createElement)("span", { className: "bt-option-hint" }, option.hint) : null + option.bot || !option.hint ? (0, import_react31.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react31.createElement)("span", { className: "bt-option-copy" }, (0, import_react31.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react31.createElement)("span", { className: "bt-option-sub" }, option.hint)), + option.bot && option.hint && index === active ? (0, import_react31.createElement)("span", { className: "bt-option-hint" }, option.hint) : null )) )), - menuOnly ? null : (0, import_react33.createElement)("div", { className: "bt-nc-spacer" }), - error && fix ? (0, import_react33.createElement)( + menuOnly ? null : (0, import_react31.createElement)("div", { className: "bt-nc-spacer" }), + error && fix ? (0, import_react31.createElement)( "div", { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, - (0, import_react33.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), - (0, import_react33.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ - typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react33.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { + (0, import_react31.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), + (0, import_react31.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ + typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react31.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { void copyInvite(bot); } }, t("Copy invite link for {name}", { name: bot.name })) : null, - (0, import_react33.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) + (0, import_react31.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) ])), - fixNote ? (0, import_react33.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null, - inviteLink ? (0, import_react33.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null - ) : error ? (0, import_react33.createElement)("div", { className: "bt-error" }, error) : null, - menuOnly ? null : (0, import_react33.createElement)( + fixNote ? (0, import_react31.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null, + inviteLink ? (0, import_react31.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null + ) : error ? (0, import_react31.createElement)("div", { className: "bt-error" }, error) : null, + menuOnly ? null : (0, import_react31.createElement)( "div", { className: "bt-nc-compose" }, - (0, import_react33.createElement)( + (0, import_react31.createElement)( "div", { className: "bt-nc-card" }, - (0, import_react33.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react33.createElement)(PlusIcon)), - (0, import_react33.createElement)("textarea", { + (0, import_react31.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react31.createElement)(PlusIcon)), + (0, import_react31.createElement)("textarea", { rows: 1, value: message, placeholder, @@ -14838,7 +13790,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), - (0, import_react33.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react33.createElement)(ArrowUpIcon)) + (0, import_react31.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react31.createElement)(ArrowUpIcon)) ) ) ) @@ -14846,18 +13798,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js -var import_react34 = require("react"); +var import_react32 = require("react"); var SHEET_EXIT_MS = 220; function ExchangeDialog({ pair, roster, actions }) { - const [entries2, setEntries] = (0, import_react34.useState)(null); - const [leaving, setLeaving] = (0, import_react34.useState)(false); + const [entries2, setEntries] = (0, import_react32.useState)(null); + const [leaving, setLeaving] = (0, import_react32.useState)(false); const close = () => { if (leaving) return; setLeaving(true); setTimeout(() => actions.closeOverlay(), SHEET_EXIT_MS); }; useEscape(close); - (0, import_react34.useEffect)(() => { + (0, import_react32.useEffect)(() => { let stop = false; let timer; let seen = null; @@ -14881,48 +13833,48 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); - return (0, import_react34.createElement)( + return (0, import_react32.createElement)( "div", { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 }, - (0, import_react34.createElement)( + (0, import_react32.createElement)( "div", { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") }, - (0, import_react34.createElement)("div", { className: "bt-head" }, (0, import_react34.createElement)( + (0, import_react32.createElement)("div", { className: "bt-head" }, (0, import_react32.createElement)( "span", { className: "bt-pill", style: { cursor: "default" } }, - left ? (0, import_react34.createElement)(BotMark, { bot: left, size: 22 }) : null, + left ? (0, import_react32.createElement)(BotMark, { bot: left, size: 22 }) : null, left?.name ?? "?", - (0, import_react34.createElement)("span", { className: "bt-swap" }, "⇄"), - right ? (0, import_react34.createElement)(BotMark, { bot: right, size: 22 }) : null, + (0, import_react32.createElement)("span", { className: "bt-swap" }, "⇄"), + right ? (0, import_react32.createElement)(BotMark, { bot: right, size: 22 }) : null, right?.name ?? "?" )), - (0, import_react34.createElement)( + (0, import_react32.createElement)( "div", { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") }, - entries2 === null ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { + entries2 === null ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; const showDay = entry.time - lastDay > 30 * 60 * 1e3; lastDay = entry.time; - return (0, import_react34.createElement)( - import_react34.Fragment, + return (0, import_react32.createElement)( + import_react32.Fragment, { key: entry.id }, - showDay ? (0, import_react34.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, - (0, import_react34.createElement)( + showDay ? (0, import_react32.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, + (0, import_react32.createElement)( "div", { className: "bt-group-msg" }, - (0, import_react34.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), - (0, import_react34.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react34.createElement)("span", { className: "bt-lead" }, (0, import_react34.createElement)(BotMark, { bot, size: 22 })) }) + (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), + (0, import_react32.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) ) ); }) ), - (0, import_react34.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react34.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat"))) + (0, import_react32.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat"))) ) ); } // src/client/voice.js -var import_react35 = require("react"); +var import_react33 = require("react"); var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", @@ -14934,8 +13886,8 @@ var VOICE_LABELS = { }; var CAPTURE_NOTES = { blocked: "Microphone access is blocked", unavailable: "Voice chat is not available in this browser", "too-long": "That was too long to recognize" }; function useMicLevel(enabled) { - const [level, setLevel] = (0, import_react35.useState)(0); - (0, import_react35.useEffect)(() => { + const [level, setLevel] = (0, import_react33.useState)(0); + (0, import_react33.useEffect)(() => { if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0; let stream = null; let audio = null; @@ -14985,18 +13937,18 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } const entry = Array.isArray(outline) ? outline[outline.length - 1] : void 0; return entry ? `${outline.length}|${entry.response ?? ""}` : "0|"; }); - const [speech] = (0, import_react35.useState)(() => actions.speech()); - const [phase, setPhase] = (0, import_react35.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported"); - const [catalog, setCatalog] = (0, import_react35.useState)(null); - const [heard, setHeard] = (0, import_react35.useState)(""); - const [said, setSaid] = (0, import_react35.useState)(""); - const [note, setNote] = (0, import_react35.useState)(""); - const [serviceLevel, setServiceLevel] = (0, import_react35.useState)(0); - const [cycle, setCycle] = (0, import_react35.useState)(0); - const waiting = (0, import_react35.useRef)(null); - const reply = (0, import_react35.useRef)(lastReply); + const [speech] = (0, import_react33.useState)(() => actions.speech()); + const [phase, setPhase] = (0, import_react33.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported"); + const [catalog, setCatalog] = (0, import_react33.useState)(null); + const [heard, setHeard] = (0, import_react33.useState)(""); + const [said, setSaid] = (0, import_react33.useState)(""); + const [note, setNote] = (0, import_react33.useState)(""); + const [serviceLevel, setServiceLevel] = (0, import_react33.useState)(0); + const [cycle, setCycle] = (0, import_react33.useState)(0); + const waiting = (0, import_react33.useRef)(null); + const reply = (0, import_react33.useRef)(lastReply); reply.current = lastReply; - const turn = (0, import_react35.useRef)(0); + const turn = (0, import_react33.useRef)(0); const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; @@ -15016,12 +13968,12 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } dictation.stop(); submit(text); }); - (0, import_react35.useEffect)(() => { + (0, import_react33.useEffect)(() => { if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); }, [note]); - (0, import_react35.useEffect)(() => { + (0, import_react33.useEffect)(() => { if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { @@ -15036,16 +13988,16 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } }; }, [speech]); const listen = phase === "listening"; - (0, import_react35.useEffect)(() => { + (0, import_react33.useEffect)(() => { if (speech || !listen || dictation.listening) return void 0; const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => { } }), 250); return () => clearTimeout(timer); }, [listen, dictation.listening]); - (0, import_react35.useEffect)(() => { + (0, import_react33.useEffect)(() => { if (phase !== "listening") dictation.stop(); }, [phase]); - (0, import_react35.useEffect)(() => { + (0, import_react33.useEffect)(() => { if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; @@ -15107,7 +14059,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } setServiceLevel(0); }; }, [speech, catalog, listen, cycle]); - (0, import_react35.useEffect)(() => { + (0, import_react33.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; @@ -15127,7 +14079,7 @@ function VoiceMode({ sessionId, roster, actions, useSessions, useSessionStatus } window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); - (0, import_react35.useEffect)(() => () => window.speechSynthesis?.cancel(), []); + (0, import_react33.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"; @@ -15144,50 +14096,50 @@ 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_react35.createElement)( + return (0, import_react33.createElement)( "div", { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, - (0, import_react35.createElement)( + (0, import_react33.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)( + (0, import_react33.createElement)( "div", { className: "bt-voice-orb", style: { "--bt-level": level } }, - (0, import_react35.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), - (0, import_react35.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), - bot ? (0, import_react35.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null + (0, import_react33.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), + (0, import_react33.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), + bot ? (0, import_react33.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react33.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null ), - (0, import_react35.createElement)("div", { className: "bt-voice-name" }, name), - (0, import_react35.createElement)( + (0, import_react33.createElement)("div", { className: "bt-voice-name" }, name), + (0, import_react33.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]) : "" + phase === "thinking" ? (0, import_react33.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react33.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: () => { + (0, import_react33.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), + openSetup ? (0, import_react33.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 + shownNote ? (0, import_react33.createElement)("div", { className: "bt-voice-note" }, shownNote) : null ), - (0, import_react35.createElement)( + (0, import_react33.createElement)( "div", { className: "bt-voice-bar" }, - (0, import_react35.createElement)("button", { + (0, import_react33.createElement)("button", { type: "button", className: "bt-voice-btn", "aria-pressed": phase === "paused", disabled: blocked, "aria-label": phase === "paused" ? t("Resume listening") : t("Pause"), onClick: togglePause - }, (0, import_react35.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react35.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), - (0, import_react35.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react35.createElement)(CloseIcon)) + }, (0, import_react33.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react33.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), + (0, import_react33.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react33.createElement)(CloseIcon)) ) ); } // src/client/workflows.js -var import_react36 = require("react"); +var import_react34 = require("react"); function parseList(text) { return String(text ?? "").split(",").map((item) => item.trim()).filter(Boolean); } @@ -15222,11 +14174,11 @@ function applyNodeField(definition, nodeId, field2, value) { }) }; } -var field = (label, value, onChange, options = {}) => (0, import_react36.createElement)( +var field = (label, value, onChange, options = {}) => (0, import_react34.createElement)( "label", { className: "bt-wf-field", key: label }, - (0, import_react36.createElement)("span", { className: "bt-wf-label" }, label), - (0, import_react36.createElement)("input", { + (0, import_react34.createElement)("span", { className: "bt-wf-label" }, label), + (0, import_react34.createElement)("input", { className: "bt-wf-input", value: value ?? "", disabled: options.disabled === true, @@ -15237,7 +14189,7 @@ var field = (label, value, onChange, options = {}) => (0, import_react36.createE 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)( + return (0, import_react34.createElement)( "svg", { className: "bt-wf-flow", @@ -15247,7 +14199,7 @@ function Flow({ layout }) { "data-boxes": String(layout.boxes.length), "data-edges": String(layout.edges.length) }, - layout.edges.map((edge, index) => (0, import_react36.createElement)("line", { + layout.edges.map((edge, index) => (0, import_react34.createElement)("line", { key: `e${index}`, className: "bt-wf-edge", x1: edge.points?.[0]?.x ?? 0, @@ -15255,39 +14207,39 @@ function Flow({ layout }) { x2: edge.points?.[1]?.x ?? 0, y2: edge.points?.[1]?.y ?? 0 })), - layout.boxes.map((box) => (0, import_react36.createElement)( + layout.boxes.map((box) => (0, import_react34.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(", ") || "—"}`) + (0, import_react34.createElement)("rect", { width: box.width, height: box.height, rx: 12 }), + (0, import_react34.createElement)("text", { x: 14, y: 26, className: "bt-wf-box-name" }, box.name), + (0, import_react34.createElement)("text", { x: 14, y: 48, className: "bt-wf-box-role" }, box.role), + (0, import_react34.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)( + return (0, import_react34.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)( + preview.facts === "unavailable" ? (0, import_react34.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_react34.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 + (0, import_react34.createElement)("span", { className: "bt-wf-badge", "data-state": row.state }, guardState(row)), + (0, import_react34.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_react34.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 [list, setList] = (0, import_react34.useState)(null); + const [opened, setOpened] = (0, import_react34.useState)(null); + const [draft, setDraft] = (0, import_react34.useState)(null); + const [node, setNode] = (0, import_react34.useState)(null); + const [preview, setPreview] = (0, import_react34.useState)(null); + const [error, setError] = (0, import_react34.useState)(""); + const [saved, setSaved] = (0, import_react34.useState)(""); const load = () => Promise.resolve(actions.workflows({ op: "list" })).then((value) => { setList(value); return value; @@ -15295,7 +14247,7 @@ function WorkflowsPage({ actions }) { setError(failure?.message ?? String(failure)); return null; }); - (0, import_react36.useEffect)(() => { + (0, import_react34.useEffect)(() => { load(); }, []); const open = (id, target) => Promise.resolve(actions.workflows({ op: "open", id, node: target ?? null })).then((value) => { @@ -15358,25 +14310,25 @@ function WorkflowsPage({ actions }) { }; const editable = opened?.workflow.editable === true; const current = draft?.nodes?.find((entry) => entry.id === node) ?? null; - return (0, import_react36.createElement)( + return (0, import_react34.createElement)( "div", { className: "bt-wf-page" }, - (0, import_react36.createElement)( + (0, import_react34.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.")) + (0, import_react34.createElement)("h3", null, (0, import_react34.createElement)(WorkflowIcon), t("Workflows")), + (0, import_react34.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)( + error ? (0, import_react34.createElement)("div", { className: "bt-error", role: "alert" }, error) : null, + saved ? (0, import_react34.createElement)("div", { className: "bt-wf-saved", role: "status" }, saved) : null, + (0, import_react34.createElement)( "div", { className: "bt-wf-list" }, - ["presets", "own"].map((group) => (0, import_react36.createElement)( + ["presets", "own"].map((group) => (0, import_react34.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", { + (0, import_react34.createElement)("span", { className: "bt-wf-group-name" }, group === "presets" ? t("Factory presets") : t("Yours")), + (list?.[group] ?? []).map((entry) => (0, import_react34.createElement)("button", { key: `${group}:${entry.id}`, type: "button", className: "bt-wf-pick", @@ -15384,32 +14336,32 @@ function WorkflowsPage({ actions }) { "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)) + }, entry.name, list?.default === entry.id ? (0, import_react34.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 + list === null ? (0, import_react34.createElement)("span", { className: "bt-wf-hint" }, t("Reading workflows…")) : null ), - opened === null ? null : (0, import_react36.createElement)( + opened === null ? null : (0, import_react34.createElement)( "div", { className: "bt-wf-doc" }, - (0, import_react36.createElement)( + (0, import_react34.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_react34.createElement)("strong", null, opened.workflow.name), + (0, import_react34.createElement)("span", { className: "bt-wf-path" }, `workflows/${opened.workflow.id}.json`), + (0, import_react34.createElement)("button", { type: "button", className: "bt-wf-action", onClick: customize }, t("Customize")), + editable ? (0, import_react34.createElement)("button", { type: "button", className: "bt-wf-action", onClick: save }, t("Save")) : null, + (0, import_react34.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)( + (0, import_react34.createElement)(Flow, { layout: opened.layout }), + (0, import_react34.createElement)( "div", { className: "bt-wf-table" }, - (0, import_react36.createElement)( + (0, import_react34.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)) + ["id", "name", "role", "inputs", "outputs", "guards", "refusal"].map((key) => (0, import_react34.createElement)("span", { key }, key)) ), - (draft?.nodes ?? []).map((entry) => (0, import_react36.createElement)( + (draft?.nodes ?? []).map((entry) => (0, import_react34.createElement)( "div", { key: entry.id, @@ -15418,26 +14370,26 @@ function WorkflowsPage({ actions }) { "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_react34.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_react34.createElement)("span", null, entry.name), + editable ? field("role", entry.role, (value) => setDraft(applyNodeField(draft, entry.id, "role", value))) : (0, import_react34.createElement)("span", null, entry.role), + editable ? field("inputs", formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, "inputs", value))) : (0, import_react34.createElement)("span", null, formatList(entry.inputs)), + editable ? field("outputs", formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, "outputs", value))) : (0, import_react34.createElement)("span", null, formatList(entry.outputs)), + editable ? (0, import_react34.createElement)("textarea", { className: "bt-wf-guards-input", value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, "guards", event.target.value)) }) : (0, import_react34.createElement)("span", null, guardLines(entry.guards)), + editable ? field("refusal", entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, "refusal", value))) : (0, import_react34.createElement)("span", null, entry.refusal) )) ), - (0, import_react36.createElement)( + (0, import_react34.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_react34.createElement)("span", { className: "bt-wf-group-name" }, t("Edges")), + (draft?.edges ?? []).map((edge) => (0, import_react34.createElement)("span", { key: `${edge.from}->${edge.to}`, className: "bt-wf-edge-row" }, `${edge.from} → ${edge.to}`)) ), - (0, import_react36.createElement)( + (0, import_react34.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 }) + (0, import_react34.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_react34.createElement)("span", { className: "bt-wf-hint" }, t("This node has no guards, so nothing refuses it but its inputs.")) : (0, import_react34.createElement)(Preview, { preview }) ) ) ); @@ -15576,7 +14528,7 @@ function percent(part, whole) { } // src/client/usage-page.js -var import_react37 = require("react"); +var import_react35 = require("react"); var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; @@ -15623,9 +14575,9 @@ function titleOf(range, bucket) { return dates({ year: "numeric", month: "long" }).format(bucket.start); } function useTween(value) { - const [shown, setShown] = (0, import_react37.useState)(0); - const from = (0, import_react37.useRef)(0); - (0, import_react37.useEffect)(() => { + const [shown, setShown] = (0, import_react35.useState)(0); + const from = (0, import_react35.useRef)(0); + (0, import_react35.useEffect)(() => { if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); @@ -15645,9 +14597,9 @@ function useTween(value) { }, [value]); return shown; } -var glyphBox = (size, icon3) => (0, import_react37.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react37.createElement)(icon3)); +var glyphBox = (size, icon3) => (0, import_react35.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react35.createElement)(icon3)); function ModelGlyph({ name, paint, size }) { - return (0, import_react37.createElement)("span", { + return (0, import_react35.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))` } @@ -15664,7 +14616,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_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false }) + glyph: (size) => (0, import_react35.createElement)(BotAvatar, { bot, size, badge: false, live: false }) }; } if (owner.kind === "room") { @@ -15674,7 +14626,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_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) + glyph: (size) => room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) }; } if (owner.kind === "agent") { @@ -15696,30 +14648,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_react37.createElement)( + return (0, import_react35.createElement)( "div", { ref: glide.box, className: "bt-us-tabs", role: "group", "aria-label": label }, - (0, import_react37.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.map(([id, 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)) + (0, import_react35.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + options.map(([id, text, icon3]) => (0, import_react35.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react35.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_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)( + const cell = (label, value) => (0, import_react35.createElement)("div", { className: "bt-us-stat" }, (0, import_react35.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react35.createElement)("span", { className: "bt-us-stat-value" }, value)); + return (0, import_react35.createElement)( "section", { className: "bt-us-stats", style: { "--i": 0 } }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-stat bt-us-stat-main" }, - (0, import_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), - (0, import_react37.createElement)( + (0, import_react35.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), + (0, import_react35.createElement)( "span", { className: "bt-us-stat-value" }, compact(shown), - (0, import_react37.createElement)("small", null, t("tokens")), - change === null ? null : (0, import_react37.createElement)( + (0, import_react35.createElement)("small", null, t("tokens")), + change === null ? null : (0, import_react35.createElement)( "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` @@ -15737,83 +14689,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_react37.useState)(null); + const [hover, setHover] = (0, import_react35.useState)(null); const top = niceTop(Math.max(0, ...columns.map((column) => column.total))); const count = columns.length; const fillOf = new Map(series.map((entry) => [entry.key, entry.fill])); const tipOf = new Map(tipSeries.map((entry) => [entry.key, entry])); const tip = hover === null ? null : columns[hover]; const tipSide = hover !== null && hover >= count / 2 ? { right: `calc(${(count - hover) / count * 100}% + 8px)` } : { left: `calc(${(hover + 1) / count * 100}% + 8px)` }; - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "section", { className: "bt-us-card bt-us-chart", style: { "--i": 1 } }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-card-head" }, - (0, import_react37.createElement)("h3", null, t("Tokens over time"), (0, import_react37.createElement)("span", null, unitLabel(range.unit))), - onBy ? (0, import_react37.createElement)(Tabs, { label: t("Group usage by"), value: by, onChange: onBy, options: [["bot", t("By Bot")], ["model", t("By model")]] }) : null + (0, import_react35.createElement)("h3", null, t("Tokens over time"), (0, import_react35.createElement)("span", null, unitLabel(range.unit))), + onBy ? (0, import_react35.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)( + (0, import_react35.createElement)( "div", { className: "bt-us-chart-body" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-plot", "data-hover": hover === null ? void 0 : "", onMouseLeave: () => setHover(null) }, - [1, 0.5].map((level) => (0, import_react37.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react37.createElement)("span", null, compact(top * level)))), - (0, import_react37.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react37.createElement)("span", null, "0")), - (0, import_react37.createElement)( + [1, 0.5].map((level) => (0, import_react35.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react35.createElement)("span", null, compact(top * level)))), + (0, import_react35.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react35.createElement)("span", null, "0")), + (0, import_react35.createElement)( "div", { className: "bt-us-cols" }, - columns.map((column, at) => (0, import_react37.createElement)("div", { + columns.map((column, at) => (0, import_react35.createElement)("div", { key: `${range.from ? `${range.from}~${range.to}` : range.id}:${column.key}`, className: "bt-us-col", "data-on": hover === at ? "" : void 0, onMouseEnter: () => setHover(at) - }, column.total > 0 ? (0, import_react37.createElement)( + }, column.total > 0 ? (0, import_react35.createElement)( "div", { className: "bt-us-stack", style: { height: `${column.total / top * 100}%`, "--i": at } }, - column.parts.map(([key, value]) => value > 0 ? (0, import_react37.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) - ) : (0, import_react37.createElement)("div", { className: "bt-us-stub" }))) + column.parts.map(([key, value]) => value > 0 ? (0, import_react35.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) + ) : (0, import_react35.createElement)("div", { className: "bt-us-stub" }))) ), - tip ? (0, import_react37.createElement)( + tip ? (0, import_react35.createElement)( "div", { className: "bt-us-tip", style: tipSide }, - (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, tip.title), - (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(tip.total) })), - tip.tip.filter(([, value]) => value > 0).sort((a, b) => b[1] - a[1]).map(([key, value]) => (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-tip-title" }, tip.title), + (0, import_react35.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_react35.createElement)( "div", { key, className: "bt-us-tip-row" }, - (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), - (0, import_react37.createElement)("span", null, tipOf.get(key)?.name), - (0, import_react37.createElement)("b", null, compact(value)) + (0, import_react35.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), + (0, import_react35.createElement)("span", null, tipOf.get(key)?.name), + (0, import_react35.createElement)("b", null, compact(value)) )) ) : null ), - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-ticks", "aria-hidden": true }, // A preset counts its labels back from now; a picked span forward from its first day. - columns.map((column, at) => (0, import_react37.createElement)( + columns.map((column, at) => (0, import_react35.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_react37.createElement)( + series.length > 1 || series[0]?.pick ? (0, import_react35.createElement)( "div", { className: "bt-us-legend" }, - series.map((entry) => (0, import_react37.createElement)(entry.pick ? "button" : "span", { + series.map((entry) => (0, import_react35.createElement)(entry.pick ? "button" : "span", { key: entry.key, className: "bt-us-key", ...entry.pick ? { type: "button", onClick: () => onPick(entry.pick) } : {} - }, (0, import_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) + }, (0, import_react35.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_react37.createElement)( + return (0, import_react35.createElement)( onClick ? "button" : "div", { className: "bt-us-row", @@ -15822,69 +14774,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_react37.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, - (0, import_react37.createElement)( + glyph ? (0, import_react35.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, + (0, import_react35.createElement)( "span", { className: "bt-us-row-body" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "span", { className: "bt-us-row-line" }, - (0, import_react37.createElement)("span", { className: "bt-us-row-name" }, name), - sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null + (0, import_react35.createElement)("span", { className: "bt-us-row-name" }, name), + sub ? (0, import_react35.createElement)("span", { className: "bt-us-row-sub" }, sub) : null ), - (0, import_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) + (0, import_react35.createElement)("span", { className: "bt-us-track" }, (0, import_react35.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ), - (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)}%`)) + (0, import_react35.createElement)("span", { className: "bt-us-row-num" }, (0, import_react35.createElement)("b", null, compact(sum.total)), (0, import_react35.createElement)("small", null, `${percent(sum.total, whole)}%`)) ); } function ListCard({ title, count, index, children }) { - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "section", { className: "bt-us-card", style: { "--i": index } }, - (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, title, count === void 0 ? null : (0, import_react37.createElement)("span", null, count))), - (0, import_react37.createElement)("div", { className: "bt-us-rows" }, children) + (0, import_react35.createElement)("div", { className: "bt-us-card-head" }, (0, import_react35.createElement)("h3", null, title, count === void 0 ? null : (0, import_react35.createElement)("span", null, count))), + (0, import_react35.createElement)("div", { className: "bt-us-rows" }, children) ); } function Profile({ face, share, range }) { const arc = Math.max(0, Math.min(1, share)) * RING2; - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "section", { className: "bt-us-profile" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "span", { className: "bt-us-ring" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "svg", { viewBox: "0 0 96 96", "aria-hidden": true }, - face.stops ? (0, import_react37.createElement)("defs", null, (0, import_react37.createElement)( + face.stops ? (0, import_react35.createElement)("defs", null, (0, import_react35.createElement)( "linearGradient", { id: "bt-us-ring-paint", x1: 0, y1: 1, x2: 1, y2: 0 }, - face.stops.map(([offset, color]) => (0, import_react37.createElement)("stop", { key: offset, offset, stopColor: color })) + face.stops.map(([offset, color]) => (0, import_react35.createElement)("stop", { key: offset, offset, stopColor: color })) )) : null, - (0, import_react37.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), - arc > 0 ? (0, import_react37.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null + (0, import_react35.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), + arc > 0 ? (0, import_react35.createElement)("circle", { className: "bt-us-ring-arc", cx: 48, cy: 48, r: RING_R, style: { stroke: face.stops ? "url(#bt-us-ring-paint)" : face.paint, strokeDasharray: `${arc} ${RING2}` } }) : null ), - (0, import_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) + (0, import_react35.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ), - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-profile-copy" }, - (0, import_react37.createElement)("h3", null, face.name), - face.sub ? (0, import_react37.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, - (0, import_react37.createElement)("span", { className: "bt-us-profile-share" }, range.from ? t("{share}% of all usage from {span}", { share: percent(share, 1), span: spanText(range.from, range.to) }) : t("{share}% of all usage in the last {range}", { share: percent(share, 1), range: rangeLabel(range.id) })) + (0, import_react35.createElement)("h3", null, face.name), + face.sub ? (0, import_react35.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, + (0, import_react35.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_react37.useRef)(null); + const box = (0, import_react35.useRef)(null); const todayMs = hourMs(today); const thisMonth = monthOf(todayMs); - const [right, setRight] = (0, import_react37.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); - const [anchor, setAnchor] = (0, import_react37.useState)(null); - const [hover, setHover] = (0, import_react37.useState)(null); - (0, import_react37.useEffect)(() => { + const [right, setRight] = (0, import_react35.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); + const [anchor, setAnchor] = (0, import_react35.useState)(null); + const [hover, setHover] = (0, import_react35.useState)(null); + (0, import_react35.useEffect)(() => { const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; @@ -15910,17 +14862,17 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { const [from, to] = [anchor, key].sort(); onPick(from, to); }; - const weekdays = (0, import_react37.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); + const weekdays = (0, import_react35.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); const month = (start, side) => { const length = new Date(addMonths(start, 1) - DAY_MS).getUTCDate(); const lead = (new Date(start).getUTCDay() + 6) % 7; - const cells = Array.from({ length: lead }, (_, at) => (0, import_react37.createElement)("span", { key: `b${at}` })); + const cells = Array.from({ length: lead }, (_, at) => (0, import_react35.createElement)("span", { key: `b${at}` })); for (let day = 1; day <= length; day += 1) { const ms = start + (day - 1) * DAY_MS; const key = dayKey(ms); const weekday = (lead + day - 1) % 7; const flag = (on) => on ? "" : void 0; - cells.push((0, import_react37.createElement)("button", { + cells.push((0, import_react35.createElement)("button", { key, type: "button", className: "bt-us-day", @@ -15936,15 +14888,15 @@ function SpanPicker({ span, today, oldest, active, near, onPick, onClose }) { "data-has": flag(active.has(key)), onMouseEnter: () => setHover(key), onClick: () => press(key) - }, (0, import_react37.createElement)("b", null, day))); + }, (0, import_react35.createElement)("b", null, day))); } - const nav = side === "left" ? (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "left", "aria-label": t("Previous month"), disabled: start <= oldest, onClick: () => setRight(addMonths(right, -1)) }, (0, import_react37.createElement)(ChevronLeftIcon)) : (0, import_react37.createElement)("button", { type: "button", className: "bt-us-month-nav", "data-side": "right", "aria-label": t("Next month"), disabled: start >= thisMonth, onClick: () => setRight(addMonths(right, 1)) }, (0, import_react37.createElement)(ChevronRightIcon)); - return (0, import_react37.createElement)( + const nav = side === "left" ? (0, import_react35.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_react35.createElement)(ChevronLeftIcon)) : (0, import_react35.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_react35.createElement)(ChevronRightIcon)); + return (0, import_react35.createElement)( "div", { key: start, className: "bt-us-month" }, - (0, import_react37.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react37.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), - (0, import_react37.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react37.createElement)("span", { key: at }, name))), - (0, import_react37.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) + (0, import_react35.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react35.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), + (0, import_react35.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react35.createElement)("span", { key: at }, name))), + (0, import_react35.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) ); }; const shortcuts = [ @@ -15953,30 +14905,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_react37.createElement)( + return (0, import_react35.createElement)( "div", { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-pick-quick" }, - shortcuts.map(([id, label, from, to]) => (0, import_react37.createElement)("button", { + shortcuts.map(([id, label, from, to]) => (0, import_react35.createElement)("button", { key: id, type: "button", "aria-pressed": span?.from === from && span?.to === to, onClick: () => onPick(from, to) }, label)) ), - (0, import_react37.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), - (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), + (0, import_react35.createElement)( "div", { className: "bt-us-pick-foot", role: "status" }, - (0, import_react37.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), - low === null ? null : (0, import_react37.createElement)("span", null, (0, import_react37.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) + (0, import_react35.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), + low === null ? null : (0, import_react35.createElement)("span", null, (0, import_react35.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) ) ); } function Heat({ view, keep, paint, index }) { - const [hover, setHover] = (0, import_react37.useState)(null); + const [hover, setHover] = (0, import_react35.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); @@ -15992,13 +14944,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_react37.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); + months.push((0, import_react35.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); } for (let day = 0; day < 7; day += 1) { const ms = monday + day * DAY_MS; const value = daily.get(dayKey(ms)) ?? 0; const level = value > 0 ? Math.max(1, Math.ceil(4 * Math.sqrt(value / most))) : 0; - cells.push((0, import_react37.createElement)("span", { + cells.push((0, import_react35.createElement)("span", { key: ms, className: "bt-us-cell", style: { "--w": week }, @@ -16006,7 +14958,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_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); + }, level > 0 ? (0, import_react35.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); } } const tipSide = hover ? { @@ -16015,61 +14967,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_react37.createElement)( + return (0, import_react35.createElement)( "section", { className: "bt-us-card bt-us-heat", style: { "--i": index } }, - (0, import_react37.createElement)("div", { className: "bt-us-card-head" }, (0, import_react37.createElement)("h3", null, t("Activity")), (0, import_react37.createElement)("span", { className: "bt-us-head-note" }, summary)), - (0, import_react37.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), - (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-card-head" }, (0, import_react35.createElement)("h3", null, t("Activity")), (0, import_react35.createElement)("span", { className: "bt-us-head-note" }, summary)), + (0, import_react35.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), + (0, import_react35.createElement)( "div", { className: "bt-us-heat-plot", onMouseLeave: () => setHover(null) }, - (0, import_react37.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), - hover ? (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), + hover ? (0, import_react35.createElement)( "div", { className: "bt-us-tip bt-us-heat-tip", style: tipSide }, - (0, import_react37.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), - (0, import_react37.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) })) + (0, import_react35.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), + (0, import_react35.createElement)("div", { className: "bt-us-tip-total" }, t("{count} tokens", { count: full(daily.get(dayKey(hover.ms)) ?? 0) })) ) : null ), - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-heat-scale", "aria-hidden": true }, t("Light"), - levels.map((level) => (0, import_react37.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react37.createElement)("i", { style: { background: soften(paint, level) } }))), + levels.map((level) => (0, import_react35.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react35.createElement)("i", { style: { background: soften(paint, level) } }))), t("Heavy") ) ); } function Skeleton2() { - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "div", { className: "bt-us", "aria-busy": true }, - (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), - (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), + (0, import_react35.createElement)( "div", { className: "bt-us-sheet bt-us-skel-sheet" }, - (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), - (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), - (0, import_react37.createElement)( + (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), + (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), + (0, import_react35.createElement)( "div", { className: "bt-us-pair" }, - (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), - (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) + (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), + (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) ) ) ); } var focusOf = (id) => typeof id !== "string" ? null : id.startsWith("model:") ? { kind: "model", key: id.slice("model:".length) } : id.startsWith("owner:") ? { kind: "owner", key: id.slice("owner:".length) } : null; function UsagePage({ roster, actions, focusId }) { - const [view, setView] = (0, import_react37.useState)(null); - const [error, setError] = (0, import_react37.useState)(""); - const [rangeId, setRangeId] = (0, import_react37.useState)("7d"); - const [span, setSpan] = (0, import_react37.useState)(null); - const [picking, setPicking] = (0, import_react37.useState)(false); - const [by, setBy] = (0, import_react37.useState)("bot"); - const tabs = (0, import_react37.useRef)(null); + const [view, setView] = (0, import_react35.useState)(null); + const [error, setError] = (0, import_react35.useState)(""); + const [rangeId, setRangeId] = (0, import_react35.useState)("7d"); + const [span, setSpan] = (0, import_react35.useState)(null); + const [picking, setPicking] = (0, import_react35.useState)(false); + const [by, setBy] = (0, import_react35.useState)("bot"); + const tabs = (0, import_react35.useRef)(null); const focus = focusOf(focusId); - (0, import_react37.useEffect)(() => { + (0, import_react35.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(() => { @@ -16079,7 +15031,7 @@ function UsagePage({ roster, actions, focusId }) { const value = await actions.usage(); await resized; if (stopped) return; - (0, import_react37.startTransition)(() => setView(value)); + (0, import_react35.startTransition)(() => setView(value)); setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { @@ -16096,7 +15048,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_react37.useMemo)(() => { + const shaped = (0, import_react35.useMemo)(() => { if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); @@ -16105,7 +15057,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_react37.createElement)(ModelGlyph, { name, paint, size }) }; + return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react35.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; @@ -16153,23 +15105,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_react37.useRef)(null); - (0, import_react37.useEffect)(() => { + const root = (0, import_react35.useRef)(null); + (0, import_react35.useEffect)(() => { root.current?.closest(".bt-settings-scroll")?.scrollTo?.({ top: 0 }); }, [focusId]); if (!view) { - return error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react37.createElement)(Skeleton2); + return error ? (0, import_react35.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react35.createElement)(Skeleton2); } if (view.rows.length === 0) { - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "div", { className: "bt-us" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-empty" }, - (0, import_react37.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), - (0, import_react37.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), - (0, import_react37.createElement)("span", null, t("Token usage shows up here after a model is called.")) + (0, import_react35.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), + (0, import_react35.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), + (0, import_react35.createElement)("span", null, t("Token usage shows up here after a model is called.")) ) ); } @@ -16194,7 +15146,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_react37.createElement)(Row2, { + const ownerRow = ([at, sum], index, total = whole) => (0, import_react35.createElement)(Row2, { key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), @@ -16206,7 +15158,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_react37.createElement)(Row2, { + const modelRow = ([at, sum], index) => (0, import_react35.createElement)(Row2, { key: `m${at}`, kind: "model", glyph: models[at].glyph(32), @@ -16218,30 +15170,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_react37.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); + const nothing = (0, import_react35.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); let body; if (face) { const kinds = ranked(part.kinds); const usedModels = ranked(part.models); const owners = ranked(part.owners); body = [ - (0, import_react37.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), - (0, import_react37.createElement)( + (0, import_react35.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), + (0, import_react35.createElement)( "div", { key: "sheet", className: "bt-us-sheet" }, - (0, import_react37.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), - (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), - (0, import_react37.createElement)( + (0, import_react35.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), + (0, import_react35.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), + (0, import_react35.createElement)( "div", { key: "pair", className: "bt-us-pair" }, - ownerAt >= 0 ? (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react37.createElement)(ListCard, { title: t("Who used it"), count: owners.length, index: 2 }, owners.length > 0 ? owners.map((entry, index) => ownerRow(entry, index)) : nothing), - (0, import_react37.createElement)( + ownerAt >= 0 ? (0, import_react35.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react35.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_react35.createElement)( ListCard, { title: t("What for"), index: 3 }, - 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 + kinds.length > 0 ? kinds.map(([at, sum], index) => (0, import_react35.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_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) + (0, import_react35.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ) ]; } else { @@ -16250,39 +15202,39 @@ function UsagePage({ roster, actions, focusId }) { const rest = owners.filter(([at]) => !isBot(at)); const usedModels = ranked(all.models); body = [ - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { key: "sheet", className: "bt-us-sheet" }, - (0, import_react37.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), - (0, import_react37.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), - (0, import_react37.createElement)( + (0, import_react35.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), + (0, import_react35.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), + (0, import_react35.createElement)( "div", { key: "pair", className: "bt-us-pair" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( ListCard, { title: t("Bots"), count: bots.length, index: 2 }, owners.length > 0 ? null : nothing, bots.map((entry, index) => ownerRow(entry, index)), - bots.length > 0 && rest.length > 0 ? (0, import_react37.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, + bots.length > 0 && rest.length > 0 ? (0, import_react35.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, rest.map((entry, index) => ownerRow(entry, bots.length + index)) ), - (0, import_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) + (0, import_react35.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) ), - (0, import_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) + (0, import_react35.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ) ]; } - return (0, import_react37.createElement)( + return (0, import_react35.createElement)( "div", { ref: root, className: "bt-us", key: focusId ?? "all" }, - (0, import_react37.createElement)( + (0, import_react35.createElement)( "div", { className: "bt-us-top" }, - (0, import_react37.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), - view.scanning ? (0, import_react37.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, - picking ? (0, import_react37.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null + (0, import_react35.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), + view.scanning ? (0, import_react35.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, + picking ? (0, import_react35.createElement)(SpanPicker, { span, today, oldest, active: activeDays, near: tabs, onPick: pickSpan, onClose: () => setPicking(false) }) : null ), - error ? (0, import_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, + error ? (0, import_react35.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, body ); } @@ -16439,7 +15391,7 @@ button.bt-us-row:hover{background:var(--bt-hover)} `; // src/client/connectors.js -var import_react38 = require("react"); +var import_react36 = 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"; @@ -16473,17 +15425,17 @@ var APPS = [ ["youtube", "YouTube"] ]; var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug; -var GitHubMark = () => (0, import_react38.createElement)( +var GitHubMark = () => (0, import_react36.createElement)( "svg", { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react38.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) + (0, import_react36.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) ); -var FeishuMark = () => (0, import_react38.createElement)( +var FeishuMark = () => (0, import_react36.createElement)( "svg", { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, - (0, import_react38.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), - (0, import_react38.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), - (0, import_react38.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) + (0, import_react36.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_react36.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_react36.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 = { @@ -16503,11 +15455,11 @@ function statusText2(connector) { } function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { const label = t(credential.label); - if (credential.masked) return (0, import_react38.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); - return (0, import_react38.createElement)( + if (credential.masked) return (0, import_react36.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); + return (0, import_react36.createElement)( "label", { className: "bt-q-field" }, - (0, import_react38.createElement)("input", { + (0, import_react36.createElement)("input", { className: "bt-input", type: "text", value, @@ -16523,31 +15475,31 @@ function CredentialField({ credential, value, onChange, onEnter, autoFocus }) { ); } function AllowedSenders({ connector, actions, run, busy }) { - const [openId, setOpenId] = (0, import_react38.useState)(""); + const [openId, setOpenId] = (0, import_react36.useState)(""); const allowed = connector.allowed ?? []; const refused = connector.inbound?.refusedSenders ?? []; const allow = (id) => void run(actions.connectorAllow(connector.id, id)); - return (0, import_react38.createElement)( + return (0, import_react36.createElement)( "div", { className: "bt-connector-allowed" }, - (0, import_react38.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), - allowed.length === 0 && refused.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, - allowed.map((entry) => (0, import_react38.createElement)( + (0, import_react36.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), + allowed.length === 0 && refused.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, + allowed.map((entry) => (0, import_react36.createElement)( "div", { key: entry.openId, className: "bt-connector-person" }, - (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) + (0, import_react36.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) )), - refused.map((entry) => (0, import_react38.createElement)( + refused.map((entry) => (0, import_react36.createElement)( "div", { key: entry.openId, className: "bt-connector-person" }, - (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) + (0, import_react36.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) )), - (0, import_react38.createElement)( + (0, import_react36.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react38.createElement)("input", { + (0, import_react36.createElement)("input", { className: "bt-input", type: "text", value: openId, @@ -16562,7 +15514,7 @@ function AllowedSenders({ connector, actions, run, busy }) { } } }), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { allow(openId.trim()); setOpenId(""); } }, t("Allow")) @@ -16572,20 +15524,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_react38.createElement)( + return (0, import_react36.createElement)( "button", { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove }, name, - label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null + label ? (0, import_react36.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null ); } function AppPicker({ apps, onChange, search, statuses = {} }) { - const [query, setQuery] = (0, import_react38.useState)(""); - const [found, setFound] = (0, import_react38.useState)(null); - const [failed, setFailed] = (0, import_react38.useState)(null); - const names = (0, import_react38.useRef)(new Map(APPS)); - const asked = (0, import_react38.useRef)(0); - (0, import_react38.useEffect)(() => { + const [query, setQuery] = (0, import_react36.useState)(""); + const [found, setFound] = (0, import_react36.useState)(null); + const [failed, setFailed] = (0, import_react36.useState)(null); + const names = (0, import_react36.useRef)(new Map(APPS)); + const asked = (0, import_react36.useRef)(0); + (0, import_react36.useEffect)(() => { const text = query.trim(); if (text === "" || search === void 0) { setFound(null); @@ -16612,13 +15564,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_react38.createElement)( + return (0, import_react36.createElement)( "div", { className: "bt-connector-apps" }, - apps.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react38.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, - (0, import_react38.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }), - failed ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, - (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react38.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => { + apps.length > 0 ? (0, import_react36.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react36.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, + (0, import_react36.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_react36.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, + (0, import_react36.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react36.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))) @@ -16626,22 +15578,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_react38.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react38.createElement)( + return (0, import_react36.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react36.createElement)( "li", { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" }, - (0, import_react38.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), - (0, import_react38.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") + (0, import_react36.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), + (0, import_react36.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") ))); } function ConnectorRow({ connector, roster, actions }) { - const [form, setForm] = (0, import_react38.useState)(false); - const [bots, setBots] = (0, import_react38.useState)(false); - const [token, setToken] = (0, import_react38.useState)(""); - const [apps, setApps] = (0, import_react38.useState)(() => connector.apps ?? []); - const [picking, setPicking] = (0, import_react38.useState)(false); - const [busy, setBusy] = (0, import_react38.useState)(false); - const [error, setError] = (0, import_react38.useState)(null); - const [message, setMessage] = (0, import_react38.useState)(""); + const [form, setForm] = (0, import_react36.useState)(false); + const [bots, setBots] = (0, import_react36.useState)(false); + const [token, setToken] = (0, import_react36.useState)(""); + const [apps, setApps] = (0, import_react36.useState)(() => connector.apps ?? []); + const [picking, setPicking] = (0, import_react36.useState)(false); + const [busy, setBusy] = (0, import_react36.useState)(false); + const [error, setError] = (0, import_react36.useState)(null); + const [message, setMessage] = (0, import_react36.useState)(""); const run = (task, after) => { setBusy(true); setError(null); @@ -16653,15 +15605,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_react38.useState)(() => connector.mode ?? modes[0]?.id); - const [values, setValues] = (0, import_react38.useState)({}); + const [mode, setMode] = (0, import_react36.useState)(() => connector.mode ?? modes[0]?.id); + const [values, setValues] = (0, import_react36.useState)({}); const chosen = modes.find((entry) => entry.id === mode) ?? modes[0]; const field2 = (name) => values[name] ?? ""; const setField = (name, value) => setValues((current) => ({ ...current, [name]: value })); const filled = chosen !== void 0 && chosen.credentials.every((credential) => field2(credential.name).trim() !== ""); - const [statuses, setStatuses] = (0, import_react38.useState)({}); + const [statuses, setStatuses] = (0, import_react36.useState)({}); const appsKey = (connector.apps ?? []).join(","); - (0, import_react38.useEffect)(() => { + (0, import_react36.useEffect)(() => { if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) { setStatuses({}); return void 0; @@ -16676,7 +15628,7 @@ function ConnectorRow({ connector, roster, actions }) { live = false; }; }, [gateway, connector.state, appsKey, picking]); - const search = (0, import_react38.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); + const search = (0, import_react36.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) { @@ -16710,52 +15662,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_react38.useRef)(null); + const row = (0, import_react36.useRef)(null); useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); - return (0, import_react38.createElement)( + return (0, import_react36.createElement)( "div", { ref: row, className: "bt-connector", "data-state": connector.state }, - (0, import_react38.createElement)( + (0, import_react36.createElement)( "div", { className: "bt-connector-head" }, - (0, import_react38.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react38.createElement)(Mark) : null), - (0, import_react38.createElement)( + (0, import_react36.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react36.createElement)(Mark) : null), + (0, import_react36.createElement)( "span", { className: "bt-connector-text" }, - (0, import_react38.createElement)("span", { className: "bt-connector-name" }, connector.name), - (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) + (0, import_react36.createElement)("span", { className: "bt-connector-name" }, connector.name), + (0, import_react36.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ), - (0, import_react38.createElement)( + (0, import_react36.createElement)( "span", { className: "bt-connector-actions" }, - connector.state === "error" ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null, - on && gateway && !picking ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + connector.state === "error" ? (0, import_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setApps(connector.apps ?? []); setPicking(true); } }, t("Choose apps")) : null, - on && perBot ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, - on ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) + on && perBot ? (0, import_react36.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_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) ) ), - on && gateway && !picking ? (0, import_react38.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, - on && perBot ? (0, import_react38.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, - on && bots ? (0, import_react38.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, - on && channel && connector.mode === "app" ? (0, import_react38.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, - on && canPost ? (0, import_react38.createElement)( + on && gateway && !picking ? (0, import_react36.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, + on && perBot ? (0, import_react36.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, + on && bots ? (0, import_react36.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, + on && channel && connector.mode === "app" ? (0, import_react36.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, + on && canPost ? (0, import_react36.createElement)( "div", { className: "bt-connector-form" }, - (0, import_react38.createElement)("label", { className: "bt-q-field" }, (0, import_react38.createElement)("input", { className: "bt-input", type: "text", value: message, "aria-label": t("Message to send to Feishu"), placeholder: t("Type a message for the Feishu group"), onChange: (event) => setMessage(event.target.value) })), - (0, import_react38.createElement)( + (0, import_react36.createElement)("label", { className: "bt-q-field" }, (0, import_react36.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_react36.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react38.createElement)("span", { className: "bt-connector-link" }), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + (0, import_react36.createElement)("span", { className: "bt-connector-link" }), + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) ) ) : null, - !on && form ? (0, import_react38.createElement)( + !on && form ? (0, import_react36.createElement)( "div", { className: "bt-connector-form" }, - channel ? (0, import_react38.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react38.createElement)("button", { + channel ? (0, import_react36.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react36.createElement)("button", { key: entry.id, type: "button", className: "bt-state", @@ -16766,67 +15718,67 @@ function ConnectorRow({ connector, roster, actions }) { setError(null); } }, t(entry.label)))) : null, - channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, { + channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react36.createElement)(CredentialField, { key: credential.name, credential, value: field2(credential.name), onChange: (value) => setField(credential.name, value), onEnter: connect, autoFocus: index === 0 - })) : (0, import_react38.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react38.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), - gateway ? (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, - (0, import_react38.createElement)( + })) : (0, import_react36.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react36.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), + gateway ? (0, import_react36.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, + (0, import_react36.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react38.createElement)( + (0, import_react36.createElement)( "a", { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub") ), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setForm(false); setToken(""); setValues({}); setError(null); } }, t("Cancel")), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) ) ) : null, - on && gateway && picking ? (0, import_react38.createElement)( + on && gateway && picking ? (0, import_react36.createElement)( "div", { className: "bt-connector-form" }, - (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), - (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")), - (0, import_react38.createElement)( + (0, import_react36.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), + (0, import_react36.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_react36.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react38.createElement)("span", { className: "bt-connector-link" }), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + (0, import_react36.createElement)("span", { className: "bt-connector-link" }), + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setPicking(false); setError(null); } }, t("Cancel")), - (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) + (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) ) ) : null, - error ? (0, import_react38.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null + error ? (0, import_react36.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ); } function ConnectorsPage({ roster, actions }) { const list = roster?.connectors ?? []; - return (0, import_react38.createElement)( + return (0, import_react36.createElement)( "section", { className: "bt-set-section" }, - (0, import_react38.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react38.createElement)("div", { className: "bt-set-row" }, (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react38.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions }))) + (0, import_react36.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react36.createElement)(ConnectorRow, { key: connector.id, connector, roster, actions }))) ); } // src/client/feedback.js -var import_react39 = require("react"); -var FeedbackIcon = () => (0, import_react39.createElement)( +var import_react37 = require("react"); +var FeedbackIcon = () => (0, import_react37.createElement)( "svg", { width: 15, height: 15, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - (0, import_react39.createElement)("path", { d: "M4 4.5h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H9l-3.5 3v-3H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" }), - (0, import_react39.createElement)("path", { d: "M10 7v2.6M10 11.4h.01" }) + (0, import_react37.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_react37.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" })); @@ -16849,16 +15801,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_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 [what, setWhat] = (0, import_react37.useState)(""); + const [report, setReport] = (0, import_react37.useState)(null); + const [preview, setPreview] = (0, import_react37.useState)(false); + const [note, setNote] = (0, import_react37.useState)(""); const fetchReport = () => actions.diagnostics({ client: clientFacts(), clientErrors: clientErrors() }).then((next) => { setReport(next); return next; }); const load = () => fetchReport().then(() => void 0); - (0, import_react39.useEffect)(() => { + (0, import_react37.useEffect)(() => { void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { @@ -16866,7 +15818,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_react39.useState)(null); + const [issueLink, setIssueLink] = (0, import_react37.useState)(null); const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; @@ -16894,59 +15846,59 @@ function FeedbackPage({ actions, run, Section, Row: Row3 }) { const total = (report?.total ?? 0) + browserErrors.length; const shown = entries2.slice(0, 8); return [ - (0, import_react39.createElement)( + (0, import_react37.createElement)( Section, { key: "report", title: t("Report a problem") }, - (0, import_react39.createElement)( + (0, import_react37.createElement)( "div", { className: "bt-set-row bt-set-paste bt-fb-what" }, - (0, import_react39.createElement)( + (0, import_react37.createElement)( "label", { className: "bt-set-copy", htmlFor: "bt-fb-what" }, - (0, import_react39.createElement)("span", null, t("What happened?")), - (0, import_react39.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue.")) + (0, import_react37.createElement)("span", null, t("What happened?")), + (0, import_react37.createElement)("span", { className: "bt-set-hint" }, t("What you did, what you expected, and what happened instead. It goes into the issue.")) ), - (0, import_react39.createElement)("textarea", { id: "bt-fb-what", className: "bt-input bt-input-area", value: what, maxLength: WHAT_MAX, placeholder: t("For example: I asked Writer in the group chat and it showed an error."), onChange: (event) => setWhat(event.target.value) }) + (0, import_react37.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)( + (0, import_react37.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_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")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyReport }, t("Copy")) ), - preview ? (0, import_react39.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react39.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, - (0, import_react39.createElement)( + preview ? (0, import_react37.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react37.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, + (0, import_react37.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_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ) ), - note ? (0, import_react39.createElement)( + note ? (0, import_react37.createElement)( "div", { key: "note", className: "bt-note bt-fb-note", role: "status" }, note, - issueLink ? [" ", (0, import_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null + issueLink ? [" ", (0, import_react37.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ) : null, - (0, import_react39.createElement)( + (0, import_react37.createElement)( Section, { key: "errors", title: report ? t("Recent errors · {count}", { count: total }) : t("Recent errors") }, - 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)( + shown.length === 0 ? (0, import_react37.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_react37.createElement)( "div", { key: `${entry.time}-${index}`, className: "bt-set-row bt-fb-entry", title: entry.text }, - (0, import_react39.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), - (0, import_react39.createElement)( + (0, import_react37.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), + (0, import_react37.createElement)( "div", { className: "bt-set-copy" }, - (0, import_react39.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), - (0, import_react39.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · ")) + (0, import_react37.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), + (0, import_react37.createElement)("span", { className: "bt-set-hint" }, [whenText2(entry.time), entry.bot, entry.where, t(SOURCES[entry.source] ?? "FlyBot")].filter(Boolean).join(" · ")) ), - entry.code ? (0, import_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null + entry.code ? (0, import_react37.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null )), - (0, import_react39.createElement)( + (0, import_react37.createElement)( Row3, { label: t("Error log"), hint: t("Kept on this computer across restarts, up to about 1 MB.") }, - (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), - (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), + (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) ) ) ]; @@ -16954,8 +15906,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_react39.useState)(false); - (0, import_react39.useEffect)(() => { + const [leaving, setLeaving] = (0, import_react37.useState)(false); + (0, import_react37.useEffect)(() => { if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); @@ -16967,15 +15919,15 @@ function ErrorToast({ useUi, actions }) { }, [toast?.token]); if (!toast) return null; const info = toast.tone === "info"; - return (0, import_react39.createElement)( + return (0, import_react37.createElement)( "div", { className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 }, - (0, import_react39.createElement)("span", { className: "bt-toast-text" }, toast.text), - info ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { + (0, import_react37.createElement)("span", { className: "bt-toast-text" }, toast.text), + info ? null : (0, import_react37.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { actions.dismissToast(toast.token); actions.openSettings("feedback"); } }, t("Details")), - (0, import_react39.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react39.createElement)(CloseIcon)) + (0, import_react37.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react37.createElement)(CloseIcon)) ); } var FEEDBACK_CSS = ` @@ -17006,7 +15958,7 @@ var FEEDBACK_CSS = ` `; // src/client/about.js -var import_react40 = require("react"); +var import_react38 = 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…"); @@ -17041,9 +15993,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_react40.useState)(false); - const [stars, setStars] = (0, import_react40.useState)(null); - (0, import_react40.useEffect)(() => { + const [checking, setChecking] = (0, import_react38.useState)(false); + const [stars, setStars] = (0, import_react38.useState)(null); + (0, import_react38.useEffect)(() => { let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); @@ -17064,29 +16016,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_react40.createElement)( + const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react38.createElement)( "button", { type: "button", className: "bt-fb-primary", disabled: phase === "installing", onClick: () => run(actions.installUpdate()) }, phase === "installing" ? t("Updating…") : phase === "failed" ? t("Try again") : t("Update") - ) : (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: checking || update.enabled === false, onClick: check }, checking ? t("Checking…") : t("Check for updates")); - const dshControl = !dshNew || dshPhase === "installed" ? null : dsh.kind === "app" ? dsh.available ? (0, import_react40.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.openAppUpdates(dsh.verified)) }, t("Open updates")) : null : dsh.available || dshPhase !== "idle" ? (0, import_react40.createElement)( + ) : (0, import_react38.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_react38.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_react38.createElement)( "button", { type: "button", className: "bt-fb-primary", disabled: dshPhase === "installing", onClick: () => run(actions.installDshUpdate()) }, dshPhase === "installing" ? t("Updating…") : dshPhase === "failed" ? t("Try again") : t("Update") ) : null; const repo = update.repoUrl; return [ - (0, import_react40.createElement)( + (0, import_react38.createElement)( "div", { key: "hero", className: "bt-about-hero" }, - (0, import_react40.createElement)("span", { className: "bt-about-mark" }, (0, import_react40.createElement)(FlyBotMark, { size: 52 })), - (0, import_react40.createElement)( + (0, import_react38.createElement)("span", { className: "bt-about-mark" }, (0, import_react38.createElement)(FlyBotMark, { size: 52 })), + (0, import_react38.createElement)( "div", { className: "bt-about-copy" }, - (0, import_react40.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), - update.current ? (0, import_react40.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null + (0, import_react38.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), + update.current ? (0, import_react38.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null ), - repo ? (0, import_react40.createElement)( + repo ? (0, import_react38.createElement)( "a", { className: "bt-star", @@ -17096,23 +16048,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_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)) + (0, import_react38.createElement)("span", { className: "bt-star-main" }, (0, import_react38.createElement)("span", { className: "bt-star-icon" }, (0, import_react38.createElement)(StarLineIcon)), "Star"), + stars === null ? null : (0, import_react38.createElement)("span", { className: "bt-star-count" }, starCount(stars)) ) : null ), - repo ? (0, import_react40.createElement)("p", { key: "ask", className: "bt-about-ask" }, t("If FlyBot helps you, a star on GitHub helps more people find it.")) : null, - (0, import_react40.createElement)( + repo ? (0, import_react38.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_react38.createElement)( Section, { key: "updates", title: t("Updates") }, - (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_react38.createElement)(Row3, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react38.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null), + dsh?.current ? (0, import_react38.createElement)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null ), - (0, import_react40.createElement)( + (0, import_react38.createElement)( Section, { key: "links", title: t("Links") }, - 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"))) + repo ? (0, import_react38.createElement)(Row3, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react38.createElement)("a", linkProps(repo), (0, import_react38.createElement)(GitHubMark), t("Open"))) : null, + update.notesUrl ? (0, import_react38.createElement)(Row3, { label: t("Release notes") }, (0, import_react38.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, + (0, import_react38.createElement)(Row3, { label: t("Report a problem") }, (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) ) ]; } @@ -17141,19 +16093,19 @@ var ABOUT_CSS = ` `; // src/client/settings.js -var import_react41 = require("react"); +var import_react39 = 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"], ["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_react41.createElement)("section", { className: "bt-set-section" }, (0, import_react41.createElement)("h3", null, title), (0, import_react41.createElement)("div", { className: "bt-set-card" }, children)); + return (0, import_react39.createElement)("section", { className: "bt-set-section" }, (0, import_react39.createElement)("h3", null, title), (0, import_react39.createElement)("div", { className: "bt-set-card" }, children)); } function SetRow({ label, hint, children }) { - return (0, import_react41.createElement)( + return (0, import_react39.createElement)( "div", { className: "bt-set-row" }, - (0, import_react41.createElement)("div", { className: "bt-set-copy" }, (0, import_react41.createElement)("span", null, label), hint ? (0, import_react41.createElement)("span", { className: "bt-set-hint" }, hint) : null), - children ? (0, import_react41.createElement)("div", { className: "bt-set-control" }, children) : null + (0, import_react39.createElement)("div", { className: "bt-set-copy" }, (0, import_react39.createElement)("span", null, label), hint ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, hint) : null), + children ? (0, import_react39.createElement)("div", { className: "bt-set-control" }, children) : null ); } var NOTIFY_KINDS = [ @@ -17165,35 +16117,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_react41.useState)(prefs.accent ?? "#4d6bfe"); - const timer = (0, import_react41.useRef)(0); - (0, import_react41.useEffect)(() => () => clearTimeout(timer.current), []); + const [custom, setCustom] = (0, import_react39.useState)(prefs.accent ?? "#4d6bfe"); + const timer = (0, import_react39.useRef)(0); + (0, import_react39.useEffect)(() => () => clearTimeout(timer.current), []); const pickCustom = (value) => { setCustom(value); clearTimeout(timer.current); timer.current = setTimeout(() => run(actions.setPrefs({ accent: value })), 350); }; return [ - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetSection, { key: "look", title: t("Appearance") }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetRow, { label: t("Theme"), hint: t("Colors for the whole team, on every device.") }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "select", { className: "bt-select", "aria-label": t("Theme"), value: current, onChange: (event) => run(actions.setPrefs({ theme: event.target.value })) }, - Object.entries(themes).map(([id, theme]) => (0, import_react41.createElement)("option", { key: id, value: id }, t(theme.label))) + Object.entries(themes).map(([id, theme]) => (0, import_react39.createElement)("option", { key: id, value: id }, t(theme.label))) ) ), - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetRow, { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "div", { className: "bt-swatches" }, - (0, import_react41.createElement)("button", { type: "button", className: "bt-swatch bt-swatch-default", title: t("Theme accent"), "aria-label": t("Theme accent"), "aria-pressed": !prefs.accent, onClick: () => run(actions.setPrefs({ accent: null })) }), - ACCENT_INKS.map((ink) => (0, import_react41.createElement)("button", { + (0, import_react39.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_react39.createElement)("button", { key: ink, type: "button", className: "bt-swatch", @@ -17203,26 +16155,26 @@ function GeneralPage({ roster, actions, run }) { style: { background: INKS[ink][0] }, onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), - (0, import_react41.createElement)( + (0, import_react39.createElement)( "label", { className: "bt-swatch bt-swatch-custom", title: t("Custom accent"), "data-on": Boolean(prefs.accent) && !ACCENT_INKS.some((ink) => INKS[ink][0] === prefs.accent) }, - (0, import_react41.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) + (0, import_react39.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) ) ) ) ), // One switch for every system notification (#353), then one per kind (#469). - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetSection, { key: "notify", title: t("Notifications") }, - (0, import_react41.createElement)(SwitchRow, { + (0, import_react39.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_react41.createElement)(SwitchRow, { + NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react39.createElement)(SwitchRow, { key: kind, label: t(label), hint: hint ? t(hint) : void 0, @@ -17238,52 +16190,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_react41.createElement)( + return (0, import_react39.createElement)( SetSection, { title: t("Main Bot") }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetRow, { label: t("Main Bot"), hint: first ? t("First in the sidebar. Pick another Bot to hand it the role; {name} then stops being a Main Bot.", { name: first.name }) : void 0 }, - (0, import_react41.createElement)("select", { + (0, import_react39.createElement)("select", { className: "bt-select", "aria-label": t("Main Bot"), value: first?.id ?? "", onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})) - }, first ? null : (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) + }, first ? null : (0, import_react39.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react39.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ), - others.map((bot) => (0, import_react41.createElement)( + others.map((bot) => (0, import_react39.createElement)( SetRow, { key: bot.id, label: bot.name, hint: t("Also a Main Bot.") }, - (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t("Move to first")), - (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role")) + (0, import_react39.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_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => run(actions.setMain(bot.id, false)) }, t("Remove Main Bot role")) )), - candidates.length > 0 ? (0, import_react41.createElement)( + candidates.length > 0 ? (0, import_react39.createElement)( SetRow, { label: t("Add a Main Bot"), hint: t("Main Bots can create and change Bots, and run every group chat.") }, - (0, import_react41.createElement)("select", { + (0, import_react39.createElement)("select", { className: "bt-select", "aria-label": t("Add a Main Bot"), value: "", onChange: (event) => { if (event.target.value) void run(actions.setMain(event.target.value, true)); } - }, (0, import_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) + }, (0, import_react39.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react39.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_react41.createElement)( + return (0, import_react39.createElement)( SetSection, { title: t("Animation") }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetRow, { label: t("Bot animation") }, - (0, import_react41.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react41.createElement)("button", { + (0, import_react39.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react39.createElement)("button", { key: id, type: "button", "aria-pressed": motion === id, @@ -17295,69 +16247,69 @@ function MotionSection({ roster, actions, run }) { ); } function BotsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react41.useState)(false); + const [creating, setCreating] = (0, import_react39.useState)(false); return [ - (0, import_react41.createElement)(MainBotsSection, { key: "main", roster, actions, run }), - (0, import_react41.createElement)(MotionSection, { key: "motion", roster, actions, run }), - creating ? (0, import_react41.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react41.createElement)( + (0, import_react39.createElement)(MainBotsSection, { key: "main", roster, actions, run }), + (0, import_react39.createElement)(MotionSection, { key: "motion", roster, actions, run }), + creating ? (0, import_react39.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react39.createElement)( "div", { key: "new", className: "bt-actions" }, - (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("New Bot")) + (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react39.createElement)(PlusIcon), t("New Bot")) ), - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetSection, { key: "bots", title: t("Bots · {count}", { count: roster.bots.filter((bot) => !bot.remote).length }) }, - roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react41.createElement)( + roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react39.createElement)( "button", { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react41.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), - (0, import_react41.createElement)("span", { className: "bt-row-title" }, bot.name), - bot.role ? (0, import_react41.createElement)("span", { className: "bt-tag" }, bot.role) : null, - isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, - bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null + (0, import_react39.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), + (0, import_react39.createElement)("span", { className: "bt-row-title" }, bot.name), + bot.role ? (0, import_react39.createElement)("span", { className: "bt-tag" }, bot.role) : null, + isMainOf(roster, bot.id) ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, + bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ), - (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) + (0, import_react39.createElement)("span", { className: "bt-chevron" }, (0, import_react39.createElement)(ChevronRightIcon)) )) ) ]; } function GroupsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react41.useState)(false); + const [creating, setCreating] = (0, import_react39.useState)(false); return [ - creating ? (0, import_react41.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react41.createElement)( + creating ? (0, import_react39.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react39.createElement)( "div", { key: "new", className: "bt-actions" }, - (0, import_react41.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", disabled: roster.bots.length < 2, title: roster.bots.length < 2 ? t("A group chat needs at least two Bots") : void 0, onClick: () => setCreating(true) }, (0, import_react41.createElement)(PlusIcon), t("Create group chat")) + (0, import_react39.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_react39.createElement)(PlusIcon), t("Create group chat")) ), - (0, import_react41.createElement)( + (0, import_react39.createElement)( SetSection, { key: "groups", title: t("Group chats · {count}", { count: roster.rooms.length }) }, - roster.rooms.length === 0 ? (0, import_react41.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => { + roster.rooms.length === 0 ? (0, import_react39.createElement)(SetRow, { label: t("No group chats yet."), hint: t("A group chat lets several Bots work on one thing with you.") }) : roster.rooms.map((room2) => { const admin = roster.byId[room2.admin]; - return (0, import_react41.createElement)( + return (0, import_react39.createElement)( "button", { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react41.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), - (0, import_react41.createElement)("span", { className: "bt-row-title" }, room2.name), - (0, import_react41.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })), - admin ? (0, import_react41.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react41.createElement)(AdminStar), admin.name) : null, - room2.hidden ? (0, import_react41.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null + (0, import_react39.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react39.createElement)("span", { className: "bt-row-title" }, room2.name), + (0, import_react39.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })), + admin ? (0, import_react39.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react39.createElement)(AdminStar), admin.name) : null, + room2.hidden ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ), - (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) + (0, import_react39.createElement)("span", { className: "bt-chevron" }, (0, import_react39.createElement)(ChevronRightIcon)) ); }) ) ]; } function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }) { - const [error, setError] = (0, import_react41.useState)(""); - const close = (0, import_react41.useCallback)(() => actions.closeSettings(), []); + const [error, setError] = (0, import_react39.useState)(""); + const close = (0, import_react39.useCallback)(() => actions.closeSettings(), []); useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); @@ -17378,34 +16330,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_react41.useRef)({ key: pageKey, depth, move: "up" }); + const last = (0, import_react39.useRef)({ key: pageKey, depth, move: "up" }); if (last.current.key !== pageKey) { last.current = { key: pageKey, depth, move: depth > last.current.depth ? "in" : depth < last.current.depth ? "out" : "up" }; } - const [usageOpened, setUsageOpened] = (0, import_react41.useState)(shown === "usage"); + const [usageOpened, setUsageOpened] = (0, import_react39.useState)(shown === "usage"); if (shown === "usage" && !usageOpened) setUsageOpened(true); - const errorLine = error ? (0, import_react41.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; - return (0, import_react41.createElement)( + const errorLine = error ? (0, import_react39.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; + return (0, import_react39.createElement)( "div", { className: "bt-settings-layer", "data-leaving": leaving || void 0 }, - (0, import_react41.createElement)("div", { className: "bt-scrim", onMouseDown: close }), - (0, import_react41.createElement)( + (0, import_react39.createElement)("div", { className: "bt-scrim", onMouseDown: close }), + (0, import_react39.createElement)( "div", { className: "bt-settings", role: "dialog", "aria-modal": true, "aria-label": t("FlyBot settings"), "data-wide": shown === "usage" ? "" : void 0 }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "nav", { ref: nav.box, className: "bt-settings-nav", "aria-label": t("Settings pages") }, - (0, import_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), - pages.map(([key, text]) => (0, import_react41.createElement)("button", { + (0, import_react39.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), + pages.map(([key, text]) => (0, import_react39.createElement)("button", { key, type: "button", className: "bt-settings-item", "aria-current": shown === key ? "page" : void 0, onClick: () => actions.openSettings(key) - }, (0, import_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), + }, (0, import_react39.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_react41.createElement)("button", { + (0, import_react39.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"), @@ -17414,52 +16366,52 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false close(); openHarnessSettings(); } - }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) + }, (0, import_react39.createElement)(SlidersIcon), t("Harness settings"), (0, import_react39.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ), - (0, import_react41.createElement)( + (0, import_react39.createElement)( "div", { className: "bt-settings-main" }, - (0, import_react41.createElement)( + (0, import_react39.createElement)( "div", { className: "bt-settings-head" }, - back ? (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react41.createElement)(ChevronLeftIcon)) : null, - (0, import_react41.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) + back ? (0, import_react39.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react39.createElement)(ChevronLeftIcon)) : null, + (0, import_react39.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label) ), - shown === "usage" ? null : (0, import_react41.createElement)( + shown === "usage" ? null : (0, import_react39.createElement)( "div", { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, errorLine, - shown === "general" ? (0, import_react41.createElement)(GeneralPage, { roster, actions, run }) : null, - bot ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, - shown === "bots" && !bot ? (0, import_react41.createElement)(BotsPage, { roster, actions, run }) : null, - room2 ? (0, import_react41.createElement)("div", { className: "bt-settings-form" }, (0, import_react41.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, - shown === "groups" && !room2 ? (0, import_react41.createElement)(GroupsPage, { roster, actions, run }) : null, - shown === "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 + shown === "general" ? (0, import_react39.createElement)(GeneralPage, { roster, actions, run }) : null, + bot ? (0, import_react39.createElement)("div", { className: "bt-settings-form" }, (0, import_react39.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, + shown === "bots" && !bot ? (0, import_react39.createElement)(BotsPage, { roster, actions, run }) : null, + room2 ? (0, import_react39.createElement)("div", { className: "bt-settings-form" }, (0, import_react39.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, + shown === "groups" && !room2 ? (0, import_react39.createElement)(GroupsPage, { roster, actions, run }) : null, + shown === "connectors" ? (0, import_react39.createElement)(ConnectorsPage, { roster, actions }) : null, + shown === "workflows" ? (0, import_react39.createElement)(WorkflowsPage, { actions }) : null, + pluginPage ? (0, import_react39.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, + shown === "capabilities" ? (0, import_react39.createElement)(CapabilitiesPage, { roster, actions, run }) : null, + shown === "schedules" ? (0, import_react39.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, + shown === "secrets" ? (0, import_react39.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, + shown === "feedback" ? (0, import_react39.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, + shown === "about" ? (0, import_react39.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null ), - usageOpened ? (0, import_react41.createElement)( + usageOpened ? (0, import_react39.createElement)( "div", { key: "usage", className: "bt-settings-scroll bt-settings-usage", "data-off": shown === "usage" ? void 0 : "", "aria-hidden": shown === "usage" ? void 0 : true }, shown === "usage" ? errorLine : null, - (0, import_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) + (0, import_react39.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ) : null ), - (0, import_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) + (0, import_react39.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react39.createElement)(CloseIcon)) ) ); } // src/client/context-menu.js -var import_react42 = require("react"); +var import_react40 = require("react"); function ContextMenu({ menu, roster, actions, leaving = false }) { useEscape(() => actions.closeMenu()); - (0, import_react42.useEffect)(() => { + (0, import_react40.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu(); }; @@ -17467,14 +16419,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { return () => window.removeEventListener("mousedown", onDown); }, []); const structure = useSidebarStructure(); - const ref = (0, import_react42.useRef)(null); - const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y }); - (0, import_react42.useLayoutEffect)(() => { + const ref = (0, import_react40.useRef)(null); + const [place, setPlace] = (0, import_react40.useState)({ left: menu.x, top: menu.y }); + (0, import_react40.useLayoutEffect)(() => { const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); }, [menu.x, menu.y]); - if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react42.createElement)(AgentMenu, { menu, roster, actions, leaving }); + if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react40.createElement)(AgentMenu, { menu, roster, actions, leaving }); const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; @@ -17516,14 +16468,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { ] ]; const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); - return (0, import_react42.createElement)( + return (0, import_react40.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": t("{name} actions", { name: bot?.name ?? room2.name }), style: place, "data-leaving": leaving || void 0 }, - shown.map((group, index) => (0, import_react42.createElement)( - import_react42.Fragment, + shown.map((group, index) => (0, import_react40.createElement)( + import_react40.Fragment, { key: index }, - index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", { + index > 0 ? (0, import_react40.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, + group.map(([label, Icon, run, danger]) => (0, import_react40.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -17532,14 +16484,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } - }, (0, import_react42.createElement)(Icon), label)) + }, (0, import_react40.createElement)(Icon), label)) )) ); } function AgentMenu({ menu, roster, actions, leaving }) { - const ref = (0, import_react42.useRef)(null); - const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y }); - (0, import_react42.useLayoutEffect)(() => { + const ref = (0, import_react40.useRef)(null); + const [place, setPlace] = (0, import_react40.useState)({ left: menu.x, top: menu.y }); + (0, import_react40.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)) }); @@ -17561,14 +16513,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; - return (0, import_react42.createElement)( + return (0, import_react40.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 }, - groups.map((group, index) => (0, import_react42.createElement)( - import_react42.Fragment, + groups.map((group, index) => (0, import_react40.createElement)( + import_react40.Fragment, { key: index }, - index > 0 ? (0, import_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", { + index > 0 ? (0, import_react40.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, + group.map(([label, Icon, run, danger]) => (0, import_react40.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -17577,7 +16529,7 @@ function AgentMenu({ menu, roster, actions, leaving }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => console.warn("[ds-bot]", error)); } - }, (0, import_react42.createElement)(Icon), label)) + }, (0, import_react40.createElement)(Icon), label)) )) ); } @@ -17663,7 +16615,7 @@ function migrateStorage(storage = globalThis.localStorage) { } // src/client/details-drawer.js -var import_react43 = require("react"); +var import_react41 = 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 ""; @@ -17688,10 +16640,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_react43.useState)(null); + const [pending, setPending] = (0, import_react41.useState)(null); const catalog = roster.permissions; const saved = bot.permission ?? catalog?.defaultPreset; - (0, import_react43.useEffect)(() => { + (0, import_react41.useEffect)(() => { if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; @@ -17701,14 +16653,14 @@ function PermissionRow({ bot, roster, actions, run }) { setPending(value); void run(actions.updateBot(bot.id, { permission: value })).catch(() => setPending(null)); }; - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { className: "bt-drawer-permission" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Permissions")), - (0, import_react43.createElement)( + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Permissions")), + (0, import_react41.createElement)( Segmented, { label: t("Permissions"), value: current }, - catalog.options.map((option) => (0, import_react43.createElement)("button", { + catalog.options.map((option) => (0, import_react41.createElement)("button", { key: option.value, type: "button", "aria-pressed": option.value === current, @@ -17722,42 +16674,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_react43.useEffect)(() => { + (0, import_react41.useEffect)(() => { actions.browserEnsure?.(bot.id); }, [bot.id]); if (roster.browser !== true || !actions.browserShow) { - return (0, import_react43.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); + return (0, import_react41.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); } if (state.ids.length === 0) { - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { className: "bt-browser-empty-mark" }, - (0, import_react43.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), - (0, import_react43.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), - (0, import_react43.createElement)("div", { className: "bt-muted" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })), - (0, import_react43.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) + (0, import_react41.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), + (0, import_react41.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), + (0, import_react41.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_react41.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) ); } - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { className: "bt-bcards" }, state.ids.map((tabId, index) => { const page = pages[tabId] ?? {}; const label = page.title || shortUrl(page.url) || t("New tab"); - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { key: tabId, className: "bt-bcard", "data-active": state.active === tabId || void 0, style: { animationDelay: `${index * 30}ms` } }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "button", { type: "button", className: "bt-bcard-shot", "aria-label": t("Open {title}", { title: label }), title: page.url, onClick: () => actions.browserPick(sessionId, tabId) }, - page.excerpt ? (0, import_react43.createElement)( + page.excerpt ? (0, import_react41.createElement)( "span", { className: "bt-bcard-preview" }, - (0, import_react43.createElement)("span", { className: "bt-bcard-ptitle" }, label), - (0, import_react43.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) - ) : (0, import_react43.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react43.createElement)(GlobeIcon), (0, import_react43.createElement)("span", null, label)) + (0, import_react41.createElement)("span", { className: "bt-bcard-ptitle" }, label), + (0, import_react41.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) + ) : (0, import_react41.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react41.createElement)(GlobeIcon), (0, import_react41.createElement)("span", null, label)) ), - (0, import_react43.createElement)("button", { + (0, import_react41.createElement)("button", { type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), @@ -17766,36 +16718,36 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-bcard-row" }, - (0, import_react43.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), - (0, import_react43.createElement)("span", { className: "bt-bcard-title" }, label), - page.url ? (0, import_react43.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null + (0, import_react41.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), + (0, import_react41.createElement)("span", { className: "bt-bcard-title" }, label), + page.url ? (0, import_react41.createElement)("span", { className: "bt-bcard-host" }, hostOf(page.url)) : null ) ); }), - (0, import_react43.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react43.createElement)(PlusIcon), t("New tab")) + (0, import_react41.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react41.createElement)(PlusIcon), t("New tab")) ); } function About({ text, title = "About" }) { - const [open, setOpen] = (0, import_react43.useState)(false); + const [open, setOpen] = (0, import_react41.useState)(false); const long = text.split("\n").length > 5 || text.length > 240; - const body = (0, import_react43.useRef)(null); + const body = (0, import_react41.useRef)(null); useMorph(body, open); - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", null, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t(title)), - (0, import_react43.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), - long ? (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t(title)), + (0, import_react41.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), + long ? (0, import_react41.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_react43.useState)(room2.pendingApprovals ?? []); - const [busy, setBusy] = (0, import_react43.useState)(""); + const [items, setItems] = (0, import_react41.useState)(room2.pendingApprovals ?? []); + const [busy, setBusy] = (0, import_react41.useState)(""); const face = room2.origin === "face-to-face"; - (0, import_react43.useEffect)(() => { + (0, import_react41.useEffect)(() => { const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length; if (haveItems) setItems(room2.pendingApprovals); if (room2.viewerRole !== "invited-owner" && !face) return void 0; @@ -17821,28 +16773,28 @@ function SharedRoomApprovals({ room: room2 }) { setBusy(""); } }; - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react43.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), - items.map((item) => (0, import_react43.createElement)( + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react41.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), + items.map((item) => (0, import_react41.createElement)( "div", { key: item.id, className: "bt-set-row" }, - (0, import_react43.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), - (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), - (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + (0, import_react41.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), + (0, import_react41.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), + (0, import_react41.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) )) ); } function FaceMembers({ room: room2, roster, actions }) { const people2 = facePeople(room2, roster); const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", null, - (0, import_react43.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), - (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react43.createElement)(FacePersonRow, { + (0, import_react41.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), + (0, import_react41.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react41.createElement)(FacePersonRow, { key: person.key, person, roster, @@ -17866,79 +16818,79 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti }; const face = room2.origin === "face-to-face"; return [ - (0, import_react43.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), - invited && room2.sharedFrom && !face ? (0, import_react43.createElement)( + (0, import_react41.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react41.createElement)( "div", { key: "from", className: "bt-shared-from" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, title), - (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), - room2.sharedFrom.invitedAt ? (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, - (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) - ) : face ? null : room2.notice ? (0, import_react43.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react43.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - face ? null : (0, import_react43.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), - face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react43.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react43.createElement)( + (0, import_react41.createElement)("div", { className: "bt-section-title" }, title), + (0, import_react41.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_react41.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, + (0, import_react41.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) + ) : face ? null : room2.notice ? (0, import_react41.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react41.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + face ? null : (0, import_react41.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react41.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react41.createElement)( "div", { key: "members" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), - (0, import_react43.createElement)("div", { className: "bt-members", role: "list" }, [ - ...humans.map((person) => (0, import_react43.createElement)( + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), + (0, import_react41.createElement)("div", { className: "bt-members", role: "list" }, [ + ...humans.map((person) => (0, import_react41.createElement)( "div", { key: person.id, role: "listitem" }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-member" }, - (0, import_react43.createElement)("span", { className: "bt-member-name" }, (0, import_react43.createElement)("span", null, person.displayName || person.id)), - (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Human")), - person.role === "admin" ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + (0, import_react41.createElement)("span", { className: "bt-member-name" }, (0, import_react41.createElement)("span", null, person.displayName || person.id)), + (0, import_react41.createElement)("span", { className: "bt-chip" }, t("Human")), + person.role === "admin" ? (0, import_react41.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ) )), - ...remotes.map((person) => (0, import_react43.createElement)( + ...remotes.map((person) => (0, import_react41.createElement)( "div", { key: person.handle || person.id, role: "listitem" }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-member" }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "span", { className: "bt-member-name" }, - (0, import_react43.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), - (0, import_react43.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) + (0, import_react41.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react41.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) ), - face ? null : (0, import_react43.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + face ? null : (0, import_react41.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ) )), ...members.map((member) => { const entry = entries2.find((item) => item.id === member.id); - return (0, import_react43.createElement)( + return (0, import_react41.createElement)( "div", { key: member.id, role: "listitem" }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { className: "bt-member" }, - (0, import_react43.createElement)( + (0, import_react41.createElement)( "button", { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) }, - (0, import_react43.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), - (0, import_react43.createElement)("span", null, member.name) + (0, import_react41.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), + (0, import_react41.createElement)("span", null, member.name) ), - room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + room2.admin === member.id ? (0, import_react41.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ), - entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null + entry ? (0, import_react41.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null ); }) ]) ), - face ? null : (0, import_react43.createElement)( + face ? null : (0, import_react41.createElement)( "div", { key: "mode" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Who answers")), - (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Who answers")), + (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) ), - face || !usage ? null : (0, import_react43.createElement)( + face || !usage ? null : (0, import_react41.createElement)( "div", { key: "usage" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, room2.name), (0, import_react43.createElement)("span", null, usage)) + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Usage")), + (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, room2.name), (0, import_react41.createElement)("span", null, usage)) ) ]; } @@ -17946,15 +16898,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_react43.useState)("details"); + const [tab, setTab] = (0, import_react41.useState)("details"); const room2 = roster.roomsById[sessionId]; - const [view, setView] = (0, import_react43.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); - const [editing, setEditing] = (0, import_react43.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); - const [draftName, setDraftName] = (0, import_react43.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); - const [copied, setCopied] = (0, import_react43.useState)(false); - const [error, setError] = (0, import_react43.useState)(""); + const [view, setView] = (0, import_react41.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); + const [editing, setEditing] = (0, import_react41.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); + const [draftName, setDraftName] = (0, import_react41.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); + const [copied, setCopied] = (0, import_react41.useState)(false); + const [error, setError] = (0, import_react41.useState)(""); const glide = useGlide(tab); - const last = (0, import_react43.useRef)({ view, tab, move: void 0 }); + const last = (0, import_react41.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; @@ -17979,43 +16931,43 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setTimeout(() => setCopied(false), 1600); }); }; - const errorLine = error ? (0, import_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; + const errorLine = error ? (0, import_react41.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; if ((bot || room2) && view === "settings" && !bot?.remote) { return [ - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { key: "head", className: "bt-subhead", "data-move": move }, - (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react43.createElement)(ChevronLeftIcon)), - (0, import_react43.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), - hasStrip ? null : (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react43.createElement)(CloseIcon)) + (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react41.createElement)(ChevronLeftIcon)), + (0, import_react41.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), + hasStrip ? null : (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ), - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { key: "set-body", className: "bt-drawer-body bt-set-body", "data-move": move }, errorLine, - bot ? (0, import_react43.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react43.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) + bot ? (0, import_react41.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react41.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } - const top = (0, import_react43.createElement)( + const top = (0, import_react41.createElement)( "div", { className: "bt-drawer-top", "data-move": side }, - bot || room2 ? (0, import_react43.createElement)("button", { + bot || room2 ? (0, import_react41.createElement)("button", { type: "button", className: "bt-round", "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), "data-info-row": "settings", onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings") - }, (0, import_react43.createElement)(GearIcon)) : null, - bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null, - bot || room2 ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "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)) + }, (0, import_react41.createElement)(GearIcon)) : null, + bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react41.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_react41.createElement)(GlobeIcon)) : null, + bot || room2 ? (0, import_react41.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_react41.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react41.createElement)(ShareIcon)) : null, + hasStrip ? null : (0, import_react41.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ); if (!bot && !room2) { return [ - (0, import_react43.createElement)(import_react43.Fragment, { key: "top" }, top), - (0, import_react43.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react43.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) + (0, import_react41.createElement)(import_react41.Fragment, { key: "top" }, top), + (0, import_react41.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react41.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) ]; } const current = bot?.name ?? room2.name; @@ -18029,12 +16981,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_react43.createElement)(import_react43.Fragment, { key: "top" }, top), - (0, import_react43.createElement)( + (0, import_react41.createElement)(import_react41.Fragment, { key: "top" }, top), + (0, import_react41.createElement)( "div", { key: "id", className: "bt-drawer-id", "data-move": side }, - bot ? (0, import_react43.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react43.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - bot?.remote ? (0, import_react43.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react43.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react43.createElement)("input", { + bot ? (0, import_react41.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react41.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), + bot?.remote ? (0, import_react41.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react41.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react41.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, @@ -18049,59 +17001,59 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a setEditing(false); } } - }) : (0, import_react43.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { + }) : (0, import_react41.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { setDraftName(room2 && !room2.named ? "" : current); setEditing(true); } }, current), - subtitle ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null + subtitle ? (0, import_react41.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react41.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ), - bot?.remote ? null : (0, import_react43.createElement)( + bot?.remote ? null : (0, import_react41.createElement)( "div", { key: "tabs", ref: glide.box, className: "bt-tabs", role: "tablist", "data-move": side }, - (0, import_react43.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - tabs.map((key) => (0, import_react43.createElement)("button", { key, type: "button", role: "tab", className: "bt-tab", "aria-selected": tab === key, onClick: () => setTab(key) }, t(TAB_LABELS[key]))) + (0, import_react41.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), + tabs.map((key) => (0, import_react41.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)( + (0, import_react41.createElement)( "div", { key: `body:${tab}`, className: "bt-drawer-body", "data-move": move }, errorLine, - room2 && tab === "details" ? (0, import_react43.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, + room2 && tab === "details" ? (0, import_react41.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, bot && bot.remote && tab === "details" ? [ - bot.remote.handle ? (0, import_react43.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, - (0, import_react43.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) + bot.remote.handle ? (0, import_react41.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, + (0, import_react41.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) ] : null, bot && !bot.remote && tab === "details" ? [ - (0, import_react43.createElement)( + (0, import_react41.createElement)( "div", { key: "model", className: "bt-drawer-model" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Model")), - (0, import_react43.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), - modelOf(bot, roster) ? null : (0, import_react43.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name })) + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Model")), + (0, import_react41.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), + modelOf(bot, roster) ? null : (0, import_react41.createElement)("div", { className: "bt-note" }, t("Pick a model and {name} can start.", { name: bot.name })) ), - (0, import_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), - (0, import_react43.createElement)( + (0, import_react41.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), + (0, import_react41.createElement)( "div", { key: "folder", className: "bt-drawer-model" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Working folder")), - (0, import_react43.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) }) + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Working folder")), + (0, import_react41.createElement)(FolderMenu, { value: bot.workDir, actions, run, wide: true, onPick: (workDir) => run(actions.updateBot(bot.id, { workDir })) }) ), - bot.instructions ? (0, import_react43.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react43.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), - roster.memory === false ? null : (0, import_react43.createElement)(MemoryRow, { key: "memory", bot, actions }), - usage ? (0, import_react43.createElement)( + bot.instructions ? (0, import_react41.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react41.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), + roster.memory === false ? null : (0, import_react41.createElement)(MemoryRow, { key: "memory", bot, actions }), + usage ? (0, import_react41.createElement)( "div", { key: "usage" }, - (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Usage")), - (0, import_react43.createElement)("div", { className: "bt-kv" }, (0, import_react43.createElement)("span", null, bot.name), (0, import_react43.createElement)("span", null, usage)) + (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Usage")), + (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, bot.name), (0, import_react41.createElement)("span", null, usage)) ) : null, - (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions }) + (0, import_react41.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ] : null, - bot && !bot.remote && tab === "browser" ? (0, import_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null + bot && !bot.remote && tab === "browser" ? (0, import_react41.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } // src/client/browser-pane.js -var import_react44 = require("react"); +var import_react42 = require("react"); var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; @@ -18111,10 +17063,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_react44.createElement)( + return (0, import_react42.createElement)( "svg", { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, - paths.map((d, index) => (0, import_react44.createElement)("path", { key: index, d })) + paths.map((d, index) => (0, import_react42.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"]); @@ -18126,9 +17078,9 @@ var CloseIcon2 = icon2(["M5.5 5.5l9 9M14.5 5.5l-9 9"]); var PlusTabIcon = icon2(["M10 5v10M5 10h10"]); var LockIcon2 = icon2(["M6.5 9V7a3.5 3.5 0 0 1 7 0v2", "M5.5 9h9v7h-9z"]); function Favicon({ src, className }) { - const [failed, setFailed] = (0, import_react44.useState)(null); - if (!src || failed === src) return (0, import_react44.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react44.createElement)(GlobeIcon)); - return (0, import_react44.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); + const [failed, setFailed] = (0, import_react42.useState)(null); + if (!src || failed === src) return (0, import_react42.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react42.createElement)(GlobeIcon)); + return (0, import_react42.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); } var savedWidth = () => { try { @@ -18187,11 +17139,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_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 wrap = (0, import_react42.useRef)(null); + const ind = (0, import_react42.useRef)(null); + const spot = (0, import_react42.useRef)(null); + const under = (0, import_react42.useRef)(null); + (0, import_react42.useLayoutEffect)(() => { const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; @@ -18220,7 +17172,7 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, trail.cancel(); }; }); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { @@ -18234,13 +17186,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, observer.observe(box); return () => observer.disconnect(); }, []); - return (0, import_react44.createElement)( + return (0, import_react42.createElement)( "div", { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") }, - (0, import_react44.createElement)( + (0, import_react42.createElement)( "div", { className: "bt-ptabs-in", ref: wrap }, - (0, import_react44.createElement)( + (0, import_react42.createElement)( "button", { type: "button", @@ -18250,13 +17202,13 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, title: bot.name, onClick: () => onView("details") }, - (0, import_react44.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), - view === "details" ? (0, import_react44.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null + (0, import_react42.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), + view === "details" ? (0, import_react42.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null ), ids.map((tabId) => { const page = pages[tabId] ?? EMPTY_PAGE; const on = view === "browser" && tabId === active; - return (0, import_react44.createElement)( + return (0, import_react42.createElement)( "button", { key: tabId, @@ -18277,9 +17229,9 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, } } }, - (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", { + (0, import_react42.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }), + (0, import_react42.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)), + (0, import_react42.createElement)("span", { className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), @@ -18291,20 +17243,20 @@ function PanelStrip({ bot, subject, view, ids, active, reading, onView, actions, }, "×") ); }), - (0, import_react44.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) + (0, import_react42.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) ), // New tab and Close stay outside the scrolling tabs, so a narrow panel keeps both. - (0, import_react44.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react44.createElement)(PlusTabIcon)), - (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn bt-ptab-close", "aria-label": t("Close panel"), title: t("Close panel"), onClick: () => actions.closePanel() }, (0, import_react44.createElement)(CloseIcon2)) + (0, import_react42.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react42.createElement)(PlusTabIcon)), + (0, import_react42.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_react42.createElement)(CloseIcon2)) ); } function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) { const page = useBrowserPages((pages) => pages[tabId ?? ""]) ?? EMPTY_PAGE; - const frame = (0, import_react44.useRef)(null); - const input = (0, import_react44.useRef)(null); - const [draft, setDraft] = (0, import_react44.useState)(null); + const frame = (0, import_react42.useRef)(null); + const input = (0, import_react42.useRef)(null); + const [draft, setDraft] = (0, import_react42.useState)(null); const showGuest = landed && !leaving && Boolean(page.url) && !page.error; - (0, import_react44.useLayoutEffect)(() => { + (0, import_react42.useLayoutEffect)(() => { const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); @@ -18330,7 +17282,7 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) window.removeEventListener("resize", schedule); }; }, [tabId, showGuest, leaving]); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); @@ -18346,27 +17298,27 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) }; const editing = draft !== null; const secure = /^https:/i.test(page.url); - return (0, import_react44.createElement)( + return (0, import_react42.createElement)( "div", { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) }, - (0, import_react44.createElement)( + (0, import_react42.createElement)( "div", { className: "bt-browser-bar" }, - (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back"), title: t("Back"), disabled: !page.canGoBack, onClick: () => actions.browserCommand(tabId, "back") }, (0, import_react44.createElement)(BackIcon)), - (0, import_react44.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Forward"), title: t("Forward"), disabled: !page.canGoForward, onClick: () => actions.browserCommand(tabId, "forward") }, (0, import_react44.createElement)(ForwardIcon)), - (0, import_react44.createElement)("button", { + (0, import_react42.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_react42.createElement)(BackIcon)), + (0, import_react42.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_react42.createElement)(ForwardIcon)), + (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", disabled: !page.url, "aria-label": page.loading ? t("Stop") : t("Reload"), title: page.loading ? t("Stop") : t("Reload"), onClick: () => actions.browserCommand(tabId, page.loading ? "stop" : "reload") - }, (0, import_react44.createElement)(page.loading ? StopIcon : ReloadIcon)), - (0, import_react44.createElement)( + }, (0, import_react42.createElement)(page.loading ? StopIcon : ReloadIcon)), + (0, import_react42.createElement)( "form", { className: "bt-browser-address", onSubmit: go, "data-editing": editing || void 0 }, - (0, import_react44.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react44.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), - (0, import_react44.createElement)("input", { + (0, import_react42.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react42.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), + (0, import_react42.createElement)("input", { ref: input, type: "text", spellCheck: false, @@ -18390,25 +17342,25 @@ function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) } }) ), - page.loading ? (0, import_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null + page.loading ? (0, import_react42.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ), - (0, import_react44.createElement)( + (0, import_react42.createElement)( "div", { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 }, - page.error ? (0, import_react44.createElement)( + page.error ? (0, import_react42.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), - (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), - page.url ? (0, import_react44.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => { + (0, import_react42.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), + (0, import_react42.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), + page.url ? (0, import_react42.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => { void actions.browserNavigate(tabId, page.url); } }, t("Try again")) : null - ) : !page.url ? (0, import_react44.createElement)( + ) : !page.url ? (0, import_react42.createElement)( "div", { className: "bt-browser-empty" }, - (0, import_react44.createElement)(BotMark, { bot, size: 48, state: "idle", live: true }), - (0, import_react44.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), - (0, import_react44.createElement)("div", { className: "bt-browser-empty-text" }, t("Open a page here and {name} can read it with you. Logins and cookies stay with {name}; other Bots never see them.", { name: bot.name })) + (0, import_react42.createElement)(BotMark, { bot, size: 48, 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-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 ) ); @@ -18420,41 +17372,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_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 root = (0, import_react42.useRef)(null); + const fly = (0, import_react42.useRef)(null); + const [landed, setLanded] = (0, import_react42.useState)(false); + const [settling, setSettling] = (0, import_react42.useState)(false); + const [width, setWidth] = (0, import_react42.useState)(savedWidth); + const [viewport, setViewport] = (0, import_react42.useState)(() => window.innerWidth); + const [dragging, setDragging] = (0, import_react42.useState)(false); + const [resizing, setResizing] = (0, import_react42.useState)(false); const instant = dragging || resizing; - const [reading, setReading] = (0, import_react44.useState)(false); + const [reading, setReading] = (0, import_react42.useState)(false); const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport)); - (0, import_react44.useLayoutEffect)(() => { + (0, import_react42.useLayoutEffect)(() => { if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH); }, [view, width]); - (0, import_react44.useLayoutEffect)(() => { + (0, import_react42.useLayoutEffect)(() => { chatRoom.hold(instant); }, [instant]); - const [last, setLast] = (0, import_react44.useState)({ view, width: panelWidth }); + const [last, setLast] = (0, import_react42.useState)({ view, width: panelWidth }); if (last.view !== view || last.width !== panelWidth) { setLast({ view, width: panelWidth }); if (last.view !== view) setSettling(last.width !== panelWidth && !instant); } - const shownView = (0, import_react44.useRef)(view); - (0, import_react44.useLayoutEffect)(() => { + const shownView = (0, import_react42.useRef)(view); + (0, import_react42.useLayoutEffect)(() => { const switched = settling && shownView.current !== view; shownView.current = view; if (switched) chatRoom.hold(true); chatRoom.set(leaving ? null : panelWidth); if (switched) chatRoom.hold(false); }, [view, panelWidth, leaving]); - (0, import_react44.useEffect)(() => () => { + (0, import_react42.useEffect)(() => () => { chatRoom.set(null); chatRoom.hold(false); }, []); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { let timer; const onResize = () => { setResizing(true); @@ -18468,28 +17420,28 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu window.removeEventListener("resize", onResize); }; }, []); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { if (!settling) return void 0; const timer = setTimeout(() => setSettling(false), SPRING_SHAPE.duration + 40); return () => clearTimeout(timer); }, [settling, view]); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { const timer = setTimeout(() => setLanded(true), 360); return () => clearTimeout(timer); }, []); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { if (canBrowse) actions.browserEnsure(bot.id); }, [bot?.id, canBrowse]); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details"); }, [view, active, tabState.ids.length]); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { if (view !== "browser" || leaving) void actions.browserShow?.(null); }, [view, leaving]); - (0, import_react44.useEffect)(() => () => { + (0, import_react42.useEffect)(() => () => { void actions.browserShow?.(null); }, []); - (0, import_react44.useEffect)(() => { + (0, import_react42.useEffect)(() => { if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); @@ -18526,7 +17478,7 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu return actions[name](...args); }; } - (0, import_react44.useLayoutEffect)(() => { + (0, import_react42.useLayoutEffect)(() => { const move = fly.current; if (!move) return; fly.current = null; @@ -18609,7 +17561,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_react44.createElement)( + return (0, import_react42.createElement)( "aside", { ref: root, @@ -18626,15 +17578,15 @@ function BotPanel({ pane, leaving, roster, actions, useSessions, useSessionStatu if (event.target === event.currentTarget) setLanded(true); } }, - leaving ? null : (0, import_react44.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }), + leaving ? null : (0, import_react42.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_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)( + dragging ? (0, import_react_dom8.createPortal)((0, import_react42.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null, + hasStrip ? (0, import_react42.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null, + view === "browser" && bot ? (0, import_react42.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react42.createElement)( "div", { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" }, - (0, import_react44.createElement)(DetailsDrawer, { + (0, import_react42.createElement)(DetailsDrawer, { sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, @@ -18731,7 +17683,7 @@ var BROWSER_CSS = ` `; // src/client/remote-bot-settings.js -var import_react45 = require("react"); +var import_react43 = 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} @@ -18755,8 +17707,8 @@ var REMOTE_BOT_SETTINGS_CSS = ` function RemoteBotSettingsSheet({ bot, roster, actions, leaving = false }) { const close = () => actions.closeSettings(); useEscape(close); - const dialog = (0, import_react45.useRef)(null); - (0, import_react45.useEffect)(() => { + const dialog = (0, import_react43.useRef)(null); + (0, import_react43.useEffect)(() => { const before = document.activeElement; dialog.current?.focus?.({ preventScroll: true }); return () => { @@ -18771,37 +17723,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_react45.createElement)( + return (0, import_react43.createElement)( "div", { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" }, - (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }), - (0, import_react45.createElement)( + (0, import_react43.createElement)("div", { className: "bt-scrim", onMouseDown: close }), + (0, import_react43.createElement)( "div", { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown }, - (0, import_react45.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-remote-sheet-head" }, - (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }), - (0, import_react45.createElement)( + (0, import_react43.createElement)(BotMark, { bot, size: 40, live: true }), + (0, import_react43.createElement)( "div", { className: "bt-remote-sheet-who" }, - (0, import_react45.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), - (0, import_react45.createElement)("span", { className: "bt-remote-sheet-sub" }, who) + (0, import_react43.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), + (0, import_react43.createElement)("span", { className: "bt-remote-sheet-sub" }, who) ), - (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react45.createElement)(CloseIcon)) + (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react43.createElement)(CloseIcon)) ), - (0, import_react45.createElement)( + (0, import_react43.createElement)( "div", { className: "bt-remote-sheet-body" }, - bot.remote.handle ? (0, import_react45.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, - (0, import_react45.createElement)(FlyBotDetails, { bot, roster, actions }) + bot.remote.handle ? (0, import_react43.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, + (0, import_react43.createElement)(FlyBotDetails, { bot, roster, actions }) ) ) ); } // src/client/overlays.js -var import_react46 = require("react"); +var import_react44 = require("react"); var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; @@ -18810,15 +17762,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_react46.useRef)(current); - (0, import_react46.useEffect)(() => { + const previous = (0, import_react44.useRef)(current); + (0, import_react44.useEffect)(() => { if (previous.current === current) return; previous.current = current; if (current) actions.followPanel?.(current); }, [current]); const open = ui2.details ? { id: ui2.details, view: ui2.view ?? "details", renaming: ui2.renaming, tab: ui2.tab } : null; - const [kept, setKept] = (0, import_react46.useState)(open); - (0, import_react46.useEffect)(() => { + const [kept, setKept] = (0, import_react44.useState)(open); + (0, import_react44.useEffect)(() => { if (open) { setKept(open); return void 0; @@ -18834,20 +17786,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_react46.createElement)( - import_react46.Fragment, + return (0, import_react44.createElement)( + import_react44.Fragment, 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 + pane ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, + ui2.palette ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, + ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, + menu ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, + remoteBotSettings ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, + inbox ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, + board ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); } @@ -19802,7 +18754,7 @@ function writeMode(mode, storage = globalThis.localStorage) { } // src/client/user-message.js -var import_react47 = require("react"); +var import_react45 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { @@ -19888,38 +18840,38 @@ function userTextParts(text, bots, referenceLabels = []) { return parts; } function UserText({ text, bots, referenceLabels, skillNames, references, actions }) { - const chip = (part) => [(0, import_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; + const chip = (part) => [(0, import_react45.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_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + if (!part.bot) return (0, import_react45.createElement)(import_react45.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); const title = `@${part.bot.name}`; - 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)); + return typeof part.bot.id === "string" ? (0, import_react45.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react45.createElement)("span", { key: index, className: "bt-umention", title }, chip(part)); }); } function Attachments({ attachments, renderMessageImages }) { const compact2 = attachments.length > 1; - return (0, import_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { + return (0, import_react45.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { if (block.type === "image") { - return renderMessageImages ? (0, import_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; + return renderMessageImages ? (0, import_react45.createElement)(import_react45.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_react47.createElement)( + return (0, import_react45.createElement)( "span", { key: index, className: "bt-ufile", title: file.name }, - 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) + ui.FileTypeIcon ? (0, import_react45.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, + (0, import_react45.createElement)("span", { className: "bt-ufile-text" }, (0, import_react45.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react45.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ); })); } function CopyButton({ text }) { - const [copied, setCopied] = (0, import_react47.useState)(false); + const [copied, setCopied] = (0, import_react45.useState)(false); const copy2 = () => { void navigator.clipboard?.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1e3); }); }; - 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)); + return (0, import_react45.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_react45.createElement)(CheckIcon) : (0, import_react45.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 }) { @@ -19936,7 +18888,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_react47.createElement)( + return (0, import_react45.createElement)( "div", { className: incoming ? "bt-umsg bt-umsg-other" : "bt-umsg", @@ -19946,22 +18898,22 @@ function UserMessageCell({ node, sessionId, useRoster, actions, renderMessageIma "data-author-kind": author?.authorKind, "data-author-name": author?.name }, - 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)( + person ? (0, import_react45.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react45.createElement)(PersonMark, { name: author.name })) : null, + faceMine ? (0, import_react45.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react45.createElement)(PersonMark, { name: myName || void 0 })) : null, + (0, import_react45.createElement)( "div", { className: "bt-ustack" }, - 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)( + faceMine && myName ? (0, import_react45.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react45.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react45.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, + attachments.length > 0 ? (0, import_react45.createElement)(Attachments, { attachments, renderMessageImages }) : null, + text !== "" || rest.length > 0 ? (0, import_react45.createElement)( "div", { className: "bt-ubody", title: clock3(data.time) }, - (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) + (0, import_react45.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_react45.createElement)(ui.JsonBlock, { key: index, label: t("Content"), payload: block }) : null) ) : null, - referenceLabels.length > 0 ? (0, import_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null + referenceLabels.length > 0 ? (0, import_react45.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ), - text === "" ? null : (0, import_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text })) + text === "" ? null : (0, import_react45.createElement)("div", { className: "bt-utools" }, (0, import_react45.createElement)(CopyButton, { text })) ); } var USER_MESSAGE_CSS = ` @@ -20059,7 +19011,7 @@ function noticeApprovals({ work, roster, shownSession, openSession, since: since } // src/client/stats-dock.js -var import_react48 = require("react"); +var import_react46 = require("react"); var STATS_DOCK = "conversation.composer.dock"; var STATS_ID = "stats"; var STATS_IDS = [STATS_ID, "activity"]; @@ -20075,17 +19027,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_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); + const t2 = (0, import_react46.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_react48.createElement)(entry.component, { ...props, t: t2 }); + return (0, import_react46.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_react49 = require("react"); +var import_react47 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { const flybot = createClientApi2(); @@ -20730,8 +19682,6 @@ function apply(ctx) { setBotConnector: (botId, connector, enabled) => after(call("bot-connectors", { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), tasks: (payload) => call("tasks", payload ?? {}), - deliveryBoard: (payload) => call("delivery-board", payload ?? {}), - deliveryProbe: (payload) => call("delivery-probe", payload ?? {}), taskUpdate: (payload) => call("task-update", payload), taskCreate: (payload) => call("task-create", payload), schedules: () => call("schedules"), @@ -21047,7 +19997,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_react49.createElement)(TurnFailures, { ...props, work }) }); + const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react47.createElement)(TurnFailures, { ...props, work }) }); return () => { off(); style.remove(); diff --git a/core/index.js b/core/index.js index 7c256ec3e..c394174b0 100644 --- a/core/index.js +++ b/core/index.js @@ -41,7 +41,6 @@ import * as update from './src/host/update.js' 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' @@ -54,7 +53,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, workflowStore, 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, workflowStore, channel] export function apply(ctx, config = {}) { const rt = createRuntime(ctx, config) diff --git a/core/scripts/delivery-probe.mjs b/core/scripts/delivery-probe.mjs deleted file mode 100644 index ef03a659a..000000000 --- a/core/scripts/delivery-probe.mjs +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -// The delivery board's diagnostic entry, as a shell (#1211): the same `probeGitHub()` the view's -// 诊断 button calls, print-only. It asks the five reads the board makes and prints one line each, -// so an instance that "does not come up" can be explained by pasting five lines instead of guessing -// which permission is missing. There is no second implementation here on purpose. -// -// Which repositories it reads is the host's own question (#1250, #1265), answered by the same -// `resolveRepos()` the board installs: `--repo` (repeatable), else the `origin` of `--workspace` -// (default the current folder), else the package's own `repository.url`. Every repository the board -// would read is probed under its own name and source, because a status without its repository -// cannot be acted on. -// -// node core/scripts/delivery-probe.mjs # repo from cwd's git remote, token from GITHUB_TOKEN -// node core/scripts/delivery-probe.mjs --repo o/r --token-env MY_TOKEN -// node core/scripts/delivery-probe.mjs --repo o/r --repo acme/widget -// node core/scripts/delivery-probe.mjs --workspace ../my-checkout -import { probeGitHub } from '../src/host/delivery-board.js' -import { NO_REPO_MESSAGE, resolveRepos } from '../src/host/delivery-repo.js' -import { repoSlug } from '../src/host/update.js' - -const argv = process.argv.slice(2) -const argOf = (name, fallback = null) => { - const index = argv.indexOf(name) - return index === -1 ? fallback : argv[index + 1] ?? fallback -} -const allOf = (name) => { - const values = [] - for (let index = 0; index < argv.length; index += 1) { - if (argv[index] === name && argv[index + 1] !== undefined) values.push(argv[index + 1]) - } - return values -} - -const repos = resolveRepos({ - setting: allOf('--repo'), - workspace: argOf('--workspace', process.cwd()), - packageSlug: repoSlug(), -}) -if (repos.length === 0) { - console.error(`delivery-probe: ${NO_REPO_MESSAGE} (pass --repo owner/name, or --workspace with a GitHub origin)`) - process.exit(2) -} -const tokenEnv = argOf('--token-env', 'GITHUB_TOKEN') -const token = process.env[tokenEnv] ?? null -let answered = true -for (const found of repos) { - const report = await probeGitHub({ repo: found.repo, token }) - console.log(`delivery-probe ${found.repo} · ${found.source} · ${report.at}${token === null ? ` · no ${tokenEnv}` : ''}`) - for (const row of report.rows) { - const status = row.status === null ? '—' : String(row.status) - console.log(`${row.ok ? 'ok ' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind}${row.message === null ? '' : `: ${row.message}`}`}`) - } - console.log(report.ok ? 'all five answer' : 'one or more of the five is refused; the FAIL line names which') - answered = answered && report.ok -} -process.exit(answered ? 0 : 1) diff --git a/core/src/client/board-card.js b/core/src/client/board-card.js index ae1bcbd64..33332de55 100644 --- a/core/src/client/board-card.js +++ b/core/src/client/board-card.js @@ -1,29 +1,17 @@ -// One card component behind both boards (#1265, step 1). A delivery card and a task card draw the -// same pieces — an identity line, a title, what it wears, who has it, what it is doing — so they -// render through this one component with the `source` their card model carries. -// -// `skin` is the calling board's class vocabulary. Two skins, not a third look: the delivery board -// keeps its stylesheet and the task board keeps its own while the shape is shared, and moving the -// columns into one set (step 2) no longer has to touch either stylesheet's card markup. +// The task board's card frame. A card says `source: 'task'` and carries `data-key`, and the +// board passes `TASK_SKIN` so the markup stays on this board's class names. import { createElement as h } from 'react' -import { t } from './i18n.js' - -export const DELIVERY_SKIN = { - card: 'bt-del-card', top: 'bt-del-card-top', id: 'bt-del-id', badge: 'bt-del-badge', - title: 'bt-del-title', labels: 'bt-del-labels', label: 'bt-del-label', owner: 'bt-del-owner', - linked: 'bt-del-linked', sha: 'bt-del-sha', -} export const TASK_SKIN = { card: 'bt-board-card', top: 'bt-board-card-top', id: 'bt-board-id', badge: 'bt-board-badge', title: 'bt-board-title', labels: 'bt-board-labels', label: 'bt-board-label', owner: 'bt-board-who', - linked: 'bt-board-linked', sha: 'bt-board-sha', note: 'bt-board-note', + note: 'bt-board-note', } -// `attrs` is the board's own extra attributes (a task's `data-column`, a card's `data-number`), so -// the existing selectors and tests keep reading the same elements. +// `attrs` is the board's own extra attributes (a task's `data-column`), so the existing +// selectors and tests keep reading the same elements. export function BoardCard({ - card, skin = DELIVERY_SKIN, attrs = {}, idText = '', topExtra = null, badge = '', + card, skin = TASK_SKIN, attrs = {}, idText = '', topExtra = null, badge = '', meta = null, note = '', footer = null, actions = null, emptyTitle = '', draggable = false, dragValue = '', onDragStart = null, onDragEnd = null, }) { @@ -52,11 +40,8 @@ export function BoardCard({ ? h('span', { className: skin.labels }, labels.map(name => h('span', { key: name, className: skin.label, 'data-label': name }, name))) : null, card?.owner ? h('span', { className: skin.owner, 'data-kind': card.ownerKind ?? 'human' }, card.owner) : null, - note ? h('span', { className: skin.note ?? skin.linked }, note) : null, + note ? h('span', { className: skin.note }, note) : null, meta, - card?.linked ? h('span', { className: skin.linked }, t('linked to #{number}', { number: card.linked })) : null, - card?.sha ? h('span', { className: skin.sha }, card.sha) : null, - card?.url ? h('a', { className: skin.sha, href: card.url, target: '_blank', rel: 'noreferrer' }, t('open on GitHub')) : null, footer, actions) } diff --git a/core/src/client/delivery-chart.js b/core/src/client/delivery-chart.js deleted file mode 100644 index e9ff2131c..000000000 --- a/core/src/client/delivery-chart.js +++ /dev/null @@ -1,243 +0,0 @@ -// Delivery trend charts (#1212): one 7-day line per countable metric, drawn from `metrics.daily`. -// The host half builds the series (Asia/Shanghai days, the last entry carries `partial: true` until -// the day is over); this module only draws it, and a snapshot without the series says so instead of -// guessing. A day the host could not read breaks the line — an unknown is never drawn as zero. -import { createElement as h } from 'react' -import { t } from './i18n.js' - -export const CHART_WIDTH = 168 -export const CHART_HEIGHT = 28 -const PAD = 3 -const DOT_RADIUS = 2 -// The dot that is not a measurement yet (today, still running) is drawn a touch larger so the -// hollow ring stays visible at this size. -const HOLLOW_RADIUS = 2.5 -// The most days a chart draws; the host sends 7 today and a longer series is cut to its end. -export const CHART_DAYS = 7 - -// Keys line up with the rows `metricRows()` builds, in that order: the first five are countable and -// carry a line, the two medians after them cannot be added up and get no chart at all (#1212). -// The five countable metrics draw a line; the CI share draws one too but is a ratio, so it never -// takes a Σ total. Ties to the rows `metricRows()` builds, by key. -export const CHART_KEYS = ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci'] -export const COUNTING_KEYS = ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests'] - -// The days to draw, or null when the snapshot carries no series at all (host half not landed yet, -// or an older host) — that is the empty state, not seven zeros. -export function chartDays(daily) { - if (!Array.isArray(daily)) return null - const days = daily.filter(day => day && typeof day === 'object') - return days.length ? days.slice(-CHART_DAYS) : null -} - -// True while today is still running: the last day says `partial: true`. -export function todayPartial(daily) { - const days = chartDays(daily) - return Boolean(days && days.length && days[days.length - 1].partial === true) -} - -// One day's number for one metric; null means the host did not get it, which is not a zero. -export function dayValue(day, key) { - if (!day || typeof day !== 'object') return null - if (key === 'ci') { - const failed = day.ci?.failed - const counted = day.ci?.counted - const total = day.ci?.total - // A superseded run is not in the denominator when the host counted them (#1212 contract). - 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)) - } - const value = day[key] - return Number.isFinite(value) ? value : null -} - -// The calendar date the day stands for, in the zone the host cuts days by (Asia/Shanghai, UTC+8, -// no DST). A start the host did not send leaves the date out of the title, never invents one. -export function dayLabel(day) { - // The contract is a plain date: 'YYYY-MM-DD'; an older answer carried an ISO start, read the - // same way so a host mid-upgrade still labels its days. - if (typeof day?.date === 'string' && /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(day.date)) return day.date - const at = Date.parse(day?.start ?? '') - if (!Number.isFinite(at)) return '' - return new Date(at + 8 * 3_600_000).toISOString().slice(0, 10) -} - -// `2026-10-26 · 12`, and `2026-10-26 · 未取到` for a day the host could not read. -export function pointTitle(day, key) { - const date = dayLabel(day) - const value = dayValue(day, key) - const shown = value === null - ? t('not available') - : (key === 'ci' ? `${Math.round(value * 100)}%` : String(value)) - return date ? `${date} · ${shown}` : shown -} - -// How many of the drawn days carry a number for this metric; fewer than the window means the total -// is incomplete and wears the star (#1212). -export function knownDays(daily, key) { - const days = chartDays(daily) ?? [] - return days.filter(day => dayValue(day, key) !== null).length -} - -const round = value => Math.round(value * 100) / 100 - -// The SVG geometry for one metric: one x per day, a y for each known day, and the path split at -// every unknown day. Pure, so the shape can be asserted without React (#1212). -export function chartGeometry(daily, key, { width = CHART_WIDTH, height = CHART_HEIGHT } = {}) { - const days = chartDays(daily) - if (!days) return null - const values = days.map(day => dayValue(day, key)) - const known = values.filter(value => value !== null) - const top = known.length ? Math.max(...known, 0) : 0 - // All-zero days still sit on the floor; a flat line on the baseline is the honest picture, and a - // filled dot there is how a real zero stays apart from a missing day. - const span = top > 0 ? top : 1 - const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0 - const dots = days.map((day, i) => { - const value = values[i] - const last = i === days.length - 1 - const state = value === null ? 'missing' : (last && day.partial === true ? 'partial' : 'full') - return { - index: i, - x: round(PAD + step * i), - y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), - today: last, - // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. - hollow: state === 'partial', - partial: state === 'partial', - state, - title: pointTitle(day, key), - value, - } - }) - const segments = [] - let run = null - for (const dot of dots) { - if (dot.y === null) { - if (run) { segments.push(run); run = null } - continue - } - if (!run) run = { dots: [] } - run.dots.push(dot) - } - if (run) segments.push(run) - return { - width, - height, - dots, - known: known.length, - segments: segments.map(segment => ({ - d: segment.dots.map((dot, i) => `${i ? 'L' : 'M'}${dot.x} ${dot.y}`).join(' '), - // Only the stretch that ends today is the day that is not over yet. - dashed: segment.dots.some(dot => dot.partial), - today: segment.dots.some(dot => dot.today), - })), - } -} - -// One metric's line: 168×28, 1.5px stroke, r=2 dots, a `title` on every point. Each dot says its -// own state (`full` / `partial`); a missing day has no dot at all, so a break can never be read as -// a zero, and a real zero is a filled dot on the baseline (#1212). -export function DeliveryChart({ daily, metricKey, label }) { - const geo = chartGeometry(daily, metricKey) - if (!geo || !geo.known) return null - const name = label ? t('{label} over the last 7 days', { label }) : t('Last 7 days') - return h('svg', { - className: 'bt-del-chart', - 'data-flybot': 'delivery-chart', - 'data-metric': metricKey, - width: geo.width, - height: geo.height, - viewBox: `0 0 ${geo.width} ${geo.height}`, - role: 'img', - 'aria-label': name, - }, - geo.segments.map((segment, i) => h('path', { - key: `s${i}`, - className: 'bt-del-chart-line', - 'data-today': segment.today ? 'true' : undefined, - ...(segment.dashed ? { 'stroke-dasharray': '2 2' } : {}), - d: segment.d, - })), - geo.dots.filter(dot => dot.y !== null).map(dot => h('circle', { - key: `d${dot.index}`, - className: 'bt-del-chart-dot', - 'data-state': dot.state, - 'data-hollow': dot.hollow ? 'true' : undefined, - cx: dot.x, - cy: dot.y, - r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS, - }, h('title', null, dot.title)))) -} - -// The metrics block #1212 draws: `label | line | today | 7 days`, one row per metric, the five -// countable lines and the CI share stacked. Rows keep the numbers `metricRows()` already formatted; a total is -// prefixed with Σ and muted so only one number per row is the day's, and it wears a star when the -// window has days the host could not read. The two median rows get no line, no placeholder and no -// total — a median cannot be added up. Below 900px the line moves under its row (DELIVERY_CHART_CSS). -export function DeliveryMetrics({ rows = [], daily }) { - const days = chartDays(daily) - const short = CHART_KEYS - .map(key => knownDays(days, key)) - .filter(count => count < (days?.length ?? 0)) - const fewest = short.length ? Math.min(...short) : 0 - const head = h('div', { className: 'bt-del-dm-head', 'data-flybot': 'delivery-metrics-head' }, - h('span', { className: 'bt-del-dm-label' }, ''), - h('span', { className: 'bt-del-dm-chart-head' }, ''), - h('span', { className: 'bt-del-dm-value' }, t('Today')), - h('span', { className: 'bt-del-dm-value bt-del-dm-week' }, t('7 days'))) - const body = rows.map((row, index) => { - // The row names the series it belongs to, so reordering the rows cannot hand one metric - // another metric's line; a row outside CHART_KEYS (a median) simply gets none. - const key = CHART_KEYS.includes(row?.key) ? row.key : null - const chart = days && key - ? h(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) - : null - // A ratio is not a sum: the CI share keeps its week number plain, and the star marks an - // incomplete count only. - const counted = key !== null && COUNTING_KEYS.includes(key) - const star = days && counted && knownDays(days, key) < days.length ? '*' : '' - const cells = [ - h('span', { key: 'label', className: 'bt-del-dm-label' }, row?.label ?? ''), - // A median row gets no chart cell at all: no line, no placeholder box, no total. - ...(key ? [h('span', { key: 'chart', className: 'bt-del-dm-chart' }, chart)] : []), - h('span', { key: 'today', className: 'bt-del-dm-value bt-del-dm-today' }, row?.today ?? '—'), - h('span', { key: 'week', className: 'bt-del-dm-value bt-del-dm-week' }, counted ? `Σ ${row?.week ?? '—'}${star}` : (row?.week ?? '—')), - ] - return h('div', { key: row?.label ?? String(index), className: 'bt-del-dm-row', 'data-metric': key ?? undefined }, cells) - }) - return h('div', { className: 'bt-del-dm', 'data-flybot': 'delivery-metrics' }, - head, - body, - days ? null : h('p', { className: 'bt-del-chart-empty', 'data-flybot': 'delivery-chart-empty' }, t('No 7-day data yet.')), - days && short.length - ? h('p', { className: 'bt-del-chart-foot', 'data-flybot': 'delivery-chart-foot' }, t('* {count} of {days} days had data', { count: fewest, days: days.length })) - : null) -} - -export const DELIVERY_CHART_CSS = ` -.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} -.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} -.bt-del-dm-head{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -.bt-del-dm-label{flex:1 1 auto;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -.bt-del-dm-head .bt-del-dm-label{color:inherit} -.bt-del-dm-chart,.bt-del-dm-chart-head{flex:0 0 168px;display:flex;align-items:center;min-height:28px} -.bt-del-dm-value{flex:0 0 auto;min-width:56px;text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--bt-ink)} -.bt-del-dm-head .bt-del-dm-value{color:inherit;font-weight:inherit} -.bt-del-dm-today{color:var(--bt-ink);font-weight:600} -.bt-del-dm-week{color:var(--bt-ink-3);font-weight:400} -.bt-del-chart{display:block;overflow:visible} -.bt-del-chart-line{fill:none;stroke:var(--bt-accent);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} -.bt-del-chart-dot{fill:var(--bt-accent);stroke:none} -.bt-del-chart-dot[data-state=partial]{fill:none;stroke:var(--bt-accent);stroke-width:1.5} -.bt-del-chart-empty,.bt-del-chart-foot{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -@media (max-width:899.98px){ - .bt-del-dm-head{display:none} - .bt-del-dm-row{flex-wrap:wrap;gap:4px 8px;min-height:44px;align-content:center} - .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} - .bt-del-dm-value{min-width:0} -} -` diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js deleted file mode 100644 index 5bc6c761a..000000000 --- a/core/src/client/delivery-view.js +++ /dev/null @@ -1,721 +0,0 @@ -// Delivery board (#1173): the repository's GitHub issues and pull requests by delivery stage, -// in the same dialog as the per-Bot Tasks board. This is a view only — nothing here writes to -// GitHub, and the snapshot it renders is built host-side (src/host/delivery-board.js). -// -// #1211 makes the failure visible: four states instead of "no snapshot" for everything — -// `loading` counts the seconds, `failed` names the endpoint, the status and what to do about it, -// `stale` keeps the old board and hangs the reason beside it, `partial` says which part was not -// read. The poll is fast while a round is running (2 s) and slow once there is something to read. -// -// #1326: One board that shows both task cards and delivery cards together, columns from workflow -// definitions, filters for stages with column:false (review, done-today). -import { createElement as h, useEffect, useState } from 'react' -import { t } from './i18n.js' -import { columnOf } from '../shared/task-board.js' -import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' -import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN } from './board-card.js' -import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' - -const POLL_MS = 60_000 -// While the host is still building there is nothing to see, so ask often; once the board is up, -// asking often only burns GitHub reads (#1211). -const LOADING_POLL_MS = 2_000 - -// A failed or stale read carries the host's `retryAt` (#1211): the next read is scheduled for it -// instead of a fixed minute, so the host's 10 s → 30 s → 2 min backoff actually happens. The floor -// keeps a clock skew from spinning, and the ceiling keeps `setTimeout`'s 32-bit limit from turning a -// long wait into an immediate 0 ms re-fire. -const RETRY_FLOOR_MS = 1_000 -const TIMER_MAX_MS = 2 ** 31 - 1 -export function pollDelay(state, payload = null, now = Date.now()) { - if (state === 'loading' || state === 'idle') return LOADING_POLL_MS - const at = Number(payload?.retryAt) - if ((state === 'failed' || state === 'stale') && Number.isFinite(at) && at > 0) { - return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS) - } - return POLL_MS -} - -// #1326: Columns from workflow definitions. The three visible stages of repo-dev. -// Stages with column:false (review, done-today) are filters, not columns. -export const DELIVERY_COLUMNS = [ - { id: 'not-started', label: 'Not started' }, - { id: 'in-progress', label: 'In progress' }, - { id: 'done', label: 'Done' }, -] - -// The columns to draw for one payload, in one place (#1265 step 4). The snapshot names which -// columns exist (its own `columns` map) and DELIVERY_COLUMNS gives their order and labels; an id -// this build does not label still renders, under its own id. When a workflow definition becomes the -// source (#1272) it replaces this function's data, not the renderer. -export function stagesOf(view) { - const columns = view?.columns - if (columns === null || typeof columns !== 'object') return DELIVERY_COLUMNS - const present = Object.keys(columns) - if (present.length === 0) return DELIVERY_COLUMNS - const known = DELIVERY_COLUMNS.filter(column => present.includes(column.id)) - const unlabelled = present.filter(id => !DELIVERY_COLUMNS.some(column => column.id === id)).map(id => ({ id, label: id })) - return [...known, ...unlabelled] -} - -const BADGES = { - inc: 'INC · rework', - 'pass-enqueue': 'PASS · waiting for enqueue', - 'pass-merge': 'PASS · waiting for merge', - 'ci-red': 'CI red', - 'ci-running': 'CI running', - 'no-verdict': 'No verdict', - // The checks could not be read: that is not "no verdict", and saying so blamed the pull request - // for a permission the instance is missing (#1211). - 'checks-unknown': 'Checks unknown', -} - -export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '') - -// #1326: Task cards in workflow columns. Task status maps to column: open/claimed → not-started, -// working/blocked/result-ready → in-progress, accepted/cancelled/failed → done. -function taskColumnOf(task) { - const col = columnOf(task) - if (col === 'not-started') return 'not-started' - if (col === 'in-progress') return 'in-progress' - return 'done' -} - -// A column takes a card dropped on it, and the board's own `onMove` decides whether that move is -// allowed — the drop is a request, never a write from the client (#1326). -const dropProps = (column, { dragging, onMove, onDrag }) => ({ - onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, - onDrop: (event) => { - event.preventDefault() - const id = event.dataTransfer?.getData('text/plain') ?? '' - if (id) onMove?.(id, column) - onDrag?.(null) - }, -}) - -// Simplified task column renderer for degraded states (no GitHub snapshot). -function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { - const columns = { 'not-started': [], 'in-progress': [], 'done': [] } - for (const task of tasks ?? []) { - const col = taskColumnOf(task) - if (columns[col]) columns[col].push(task) - } - const columnTitles = { - 'not-started': t('Not started'), - 'in-progress': t('In progress'), - 'done': t('Done'), - } - return h('div', { className: 'bt-board-columns' }, - ['not-started', 'in-progress', 'done'].map(column => h('section', { - key: column, - className: 'bt-board-column', - 'data-column': column, - 'aria-label': columnTitles[column], - ...dropProps(column, { dragging, onMove, onDrag }), - }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, columnTitles[column]), - h('span', { className: 'bt-board-count' }, String(columns[column].length))), - columns[column].length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) - : columns[column].map(task => h(TaskBoardCard, { - key: task.id, - task, - roster, - movable: canMoveTask(roster, task), - onMove, - onDrag - }))))) -} - -export function duration(ms) { - if (!Number.isFinite(ms) || ms < 0) return '—' - const hours = ms / 3_600_000 - if (hours < 1) return `${Math.max(1, Math.round(ms / 60_000))}m` - if (hours < 48) return `${Math.round(hours)}h` - return `${Math.round(hours / 24)}d` -} - -const secondsOf = (ms) => String(Math.max(0, Math.round((Number(ms) || 0) / 1000))) - -// The three states have three sentences, and none of them is "no snapshot yet" (#1211). -// Seconds until the host retries on its own (#1211). The number is the host's `retryAt`, so the line -// states a fact instead of promising a cadence the page would not keep. -export function retryCountdown(retryAt, now = Date.now()) { - const at = Number(retryAt) - if (!Number.isFinite(at) || at <= 0) return null - return Math.max(0, Math.round((at - now) / 1000)) -} - -export const loadingText = (elapsedMs) => t('First load in progress, {seconds} s elapsed', { seconds: secondsOf(elapsedMs) }) - -export const staleText = (asOf, error) => t('Showing the snapshot from {time} · refresh failed: {message}', { - time: clockOf(asOf), message: String(error?.message ?? error ?? ''), -}) - -// What a person can do about the failure, by kind and by the part of GitHub that refused (#1211). -// A wait is printed only when GitHub itself gave one. -export function hintFor(error, { repo = null } = {}) { - if (!error) return '' - const scope = error.scope ?? null - const kind = error.kind ?? null - const target = repo ?? t('this repository') - if (kind === 'no-repo') return t('Set delivery.repo in this instance\'s settings to the repository this instance works in.') - if (kind === 'no-token') return t('Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.', { repo: target }) - if (kind === 'rate-limit') { - return error.retryAfterMs === null || error.retryAfterMs === undefined - // No number from GitHub means no number here: the backoff is a rule, not a measurement. The - // retrying is the open page's job — the host installs no timer (#1173) (#1227). - ? t('GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).') - : t('GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) - } - if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt happens while this page is open.') - if (scope === 'actions' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Actions: Read.') - if (scope === 'checks' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Checks: Read.') - if ((scope === 'comments' || scope === 'issues') && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Issues: Read.') - if (scope === 'pulls' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Pull requests: Read.') - if (scope === 'repo' || kind === 'not-found' || kind === 'forbidden' || kind === 'auth') { - return t('Check the repository name, and that the token can see {repo}.', { repo: target }) - } - if (kind === 'server' || kind === 'network') return t('GitHub is not answering this instance right now; retry in a moment.') - return t('The next attempt happens while this page is open; the reason above names what failed.') -} - -// A part that could not be read is named where it is missing, not hidden (#1211). With more than -// one repository the failing read is one repository's, so the line says which (#1265). -export function missingText(entry) { - const status = entry?.status === null || entry?.status === undefined ? '' : ` · ${entry.status}` - const part = entry?.part === 'ci' ? t('CI data') - : entry?.part === 'budget' ? t('The rest of the board') - : entry?.part ?? t('Part of the board') - const where = entry?.repo ? `${entry.repo} · ` : '' - return t('{part} was not fetched this time: {message}', { part: `${where}${part}`, message: `${String(entry?.message ?? '')}${status}` }) -} - -const clockOf = (iso) => { - const at = Date.parse(iso ?? '') - if (!Number.isFinite(at)) return '—' - return new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) -} - -const timeOf = (iso) => { - const at = Date.parse(iso ?? '') - if (!Number.isFinite(at)) return '—' - return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) -} - -// The header rows the dialog renders; kept pure so the numbers can be checked without React. -export function metricRows(snapshot) { - const today = snapshot?.today ?? {} - const week = snapshot?.week ?? {} - const ci = today.ci ?? null - // The CI share is a ratio of the runs that counted, so it is written as a percentage and is - // never summed. A day the host could only count by `total` still has a denominator — the one the - // 7-day line beside this row draws — so the two halves read the same day the same way (#1244); - // only a window with no runs at all reads '—', never 0%. - const share = (entry) => { - if (!entry || !Number.isFinite(entry.failed)) return '—' - const denominator = Number.isFinite(entry.counted) && entry.counted > 0 - ? entry.counted - : (Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null) - return denominator === null ? '—' : `${Math.round((entry.failed / denominator) * 100)}%` - } - const count = (value) => (value === null || value === undefined ? '—' : String(value)) - const rows = [ - { label: t('New issues'), key: 'newIssues' }, - { label: t('Closed issues'), key: 'closedIssues' }, - { label: t('New pull requests'), key: 'newPullRequests' }, - { label: t('Merged pull requests'), key: 'mergedPullRequests' }, - { key: 'ci', label: t('CI red share'), today: share(today.ci), week: share(week.ci) }, - { label: t('Median issue close'), key: 'medianIssueCloseMs', format: duration }, - { label: t('Median PR merge'), key: 'medianPullMergeMs', format: duration }, - ].map(row => { - // The CI row already carries its own formatted share; every other row reads its number from - // today/week by key. - if (row.today !== undefined) return { key: row.key, label: row.label, today: row.today, week: row.week } - const format = row.format ?? count - // `key` travels with the row so the chart finds its own series by name, not by position. - return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) } - }) - return { - dayStart: snapshot?.dayStart ?? null, - rows, - // `null` is "not read this time", which the header prints as unknown; a real 0 stays a 0. - ci: ci === null ? null : { - failed: ci.failed ?? 0, - total: ci.total ?? 0, - counted: ci.counted ?? 0, - caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total, - superseded: ci.superseded ?? 0, - unknownDays: ci.unknownDays ?? [], - }, - open: snapshot?.open ?? {}, - } -} - -// The poll loop, kept out of React so a fake clock can prove the cadence (#1211). A retry paints -// `loading` before the request leaves, so the button is not silent, and the schedule after a failure -// is the host's own `retryAt` rather than a minute of nothing. -export function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = id => clearTimeout(id) }) { - let stopped = false - let timer = null - let state = 'idle' - let last = null - const schedule = () => { - if (stopped) return - if (timer !== null) clearTimer(timer) - timer = setTimer(() => { timer = null; void run() }, pollDelay(state, last, now())) - } - const run = async (force = false) => { - try { - const next = await load(force) - if (stopped) return - if (next && typeof next === 'object') { - last = next - state = typeof next.state === 'string' ? next.state : 'fresh' - onView(next) - } else { - state = 'failed' - last = null - onView({ state: 'failed', error: { message: 'The host did not answer', kind: 'network', endpoint: null, status: null, at: null } }) - } - schedule() - } catch (error) { - if (stopped) return - state = 'failed' - last = null - onView({ state: 'failed', error: { message: String(error?.message ?? error), kind: 'network', endpoint: null, status: null, at: null } }) - schedule() - } - } - return { - start: () => { void run() }, - // Retry: the screen changes now, then the force read runs; the old view is kept so its columns - // do not flash away while the round starts. - retry: (from = null) => { - if (stopped) return - state = 'loading' - last = null - onView({ ...(from && typeof from === 'object' ? from : {}), state: 'loading', since: new Date(now()).toISOString(), elapsedMs: 0, refreshing: false }) - if (timer !== null) { clearTimer(timer); timer = null } - void run(true) - }, - stop: () => { stopped = true; if (timer !== null) clearTimer(timer); timer = null }, - } -} - -// Where the repository name came from (#1250). A bare source word is deliberate: the page says what -// the host read, not a sentence that hides the difference between a setting and a default. -export function repoSourceText(source) { - if (source === 'config') return t('from config') - if (source === 'remote') return t('from remote') - if (source === 'package') return t('from the package (default)') - return null -} - -export function useDeliveryBoard(actions) { - const [view, setView] = useState({ state: 'idle' }) - const [retry, setRetry] = useState(() => () => {}) - useEffect(() => { - let current = { state: 'idle' } - const poller = createPoller({ - load: force => (typeof actions?.deliveryBoard === 'function' - ? actions.deliveryBoard(force ? { force: true } : {}) - : Promise.resolve(null)), - onView: next => { current = next; setView(next) }, - }) - setRetry(() => () => poller.retry(current)) - poller.start() - return () => poller.stop() - }, [actions]) - return [view, retry] -} - -// The diagnostic report as text, so the 诊断 button can show and copy the same five lines the -// `--probe` CLI prints (#1211). -export function probeText(report) { - if (!report) return '' - const repos = Array.isArray(report.repos) ? report.repos : [] - const head = report.repo ?? `${repos.length} repositories` - const lines = [`${head}${report.repoSource ? ` · ${report.repoSource}` : ''} · ${timeOf(report.at)}`] - for (const row of report.rows ?? []) { - const status = row.status === null || row.status === undefined ? '—' : String(row.status) - const where = report.repo === null && row.repo ? `${row.repo} ` : '' - lines.push(`${where}${row.ok ? 'ok' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind ?? ''}`}`) - } - if (report.error) lines.push(String(report.error)) - return lines.join('\n') -} - -function Diagnose({ onProbe }) { - const [report, setReport] = useState(null) - const [busy, setBusy] = useState(false) - const run = () => { - if (busy) return - setBusy(true) - Promise.resolve(onProbe?.()) - .then(next => { setReport(next ?? { rows: [], error: 'no answer' }) }) - .catch(error => setReport({ rows: [], error: String(error?.message ?? error) })) - .finally(() => setBusy(false)) - } - return h('span', { className: 'bt-del-diag' }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: run, 'aria-label': t('Diagnose') }, busy ? t('Checking…') : t('Diagnose')), - report - ? h('div', { className: 'bt-del-diag-box' }, - h('pre', { className: 'bt-del-diag-text', 'data-probe': 'report' }, probeText(report)), - h('button', { - type: 'button', className: 'bt-soft bt-soft-sm', - onClick: () => { try { globalThis.navigator?.clipboard?.writeText?.(probeText(report)) } catch { /* copy is a convenience */ } }, - }, t('Copy')), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => setReport(null) }, t('Close'))) - : null) -} - -export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { - const payload = view ?? snapshot - if (!payload) return null - const metricsPayload = payload.metrics ?? payload - const { rows, ci, open } = metricRows(metricsPayload) - const error = payload.error ?? null - const state = payload.state ?? 'fresh' - const repoName = payload.repo ?? repo ?? null - const repoSource = payload.repoSource ?? null - const sourceText = repoSourceText(repoSource) - // More than one repository has no single name to print, so the line carries the count instead - // (#1265 step 4); each repository block names itself below. - const repoCount = Array.isArray(payload.repos) ? payload.repos.length : 0 - const shared = repoCount > 1 - const openCount = value => (value === null || value === undefined ? '—' : String(value)) - // One time, one source (#1244): while today is still running the hint follows the time, so the - // paragraph prints the zone-clocked time once instead of repeating a local-clock rendering of the - // same instant — the two can disagree, not merely repeat. - const partial = state !== 'loading' && todayPartial(metricsPayload.daily) - return h('header', { className: 'bt-del-head' }, - // Whose numbers these are, and where the name came from (#1250): a package default that looks - // like a fact is what showed one instance another repository's board. - h('p', { className: 'bt-del-repo', 'data-repo': shared ? undefined : repoName ?? undefined, 'data-repo-source': shared ? undefined : repoSource ?? undefined, 'data-repos': shared ? String(repoCount) : undefined }, - shared ? t('{count} repositories', { count: repoCount }) : repoName === null ? t('no repository — set delivery.repo') : repoName, - shared || sourceText === null ? '' : ` · ${sourceText}`), - h('div', { className: 'bt-del-metrics' }, - h(DeliveryMetrics, { rows, daily: metricsPayload.daily }), - // An unreadable CI number is "unknown", never "0 of 0" (#1211). - h('p', { className: 'bt-del-ci', 'data-ci': ci === null ? 'unknown' : 'read' }, - ci === null ? t('CI not available this time') : t('CI failed {failed} of {total}', { failed: ci.failed, total: ci.total }), - ci !== null && ci.caveat ? ` · ${t('counted {counted} of {total}', { counted: ci.counted, total: ci.total })}` : '', - ci !== null && ci.superseded ? ` · ${t('{count} superseded', { count: ci.superseded })}` : '', - ci !== null && ci.unknownDays?.length ? ` · ${t('{count} day(s) not read', { count: ci.unknownDays.length })}` : ''), - h('p', { className: 'bt-del-open' }, t('Open: {issues} issues ({needs} need a human), {prs} pull requests', { - issues: openCount(open.issues), needs: openCount(open.needsHuman), prs: openCount(open.pullRequests), - }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined - ? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')), - h('p', { className: 'bt-del-asof' }, - state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), - partial ? ` · ${t('today is partial')}` : '', - payload.refreshing ? ` · ${t('refreshing…')}` : '', - error ? ` · ${hintFor(error, { repo })}` : '', - onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null, - onProbe ? h(Diagnose, { onProbe }) : null)) -} - -// #1326: the stages a definition draws no column for — a review, today's finished cards — are -// filters, not lanes. A filter narrows which cards a column shows; it never reorders the columns, and -// one board keeps one set of them. -const BOARD_FILTERS = ['all', 'review', 'done-today'] -function BoardFilters({ filter, onFilter }) { - return h('div', { className: 'bt-del-filters', role: 'group', 'aria-label': t('Filter') }, - BOARD_FILTERS.map(id => h('button', { - key: id, - type: 'button', - className: 'bt-soft bt-soft-sm', - 'data-filter': id, - 'aria-pressed': filter === id ? 'true' : 'false', - onClick: () => onFilter(id), - }, id === 'all' ? t('All') : id === 'review' ? t('Review') : t('Done today')))) -} - -const stageOf = (card, column) => card?.stage ?? column -const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter - -// One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. -function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = 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 deliveryCards = (id) => (columns[id] ?? []) - .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) - .filter((card) => matchesFilter(card, id, filter)) - - // #1326: Task cards map into the same columns. Map task column to delivery column. - const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } - // A task card names no review or done-today stage, so a stage filter narrows it away rather than - // moving it to a lane it never named (#1326). - for (const task of filter === 'all' ? tasks ?? [] : []) { - const col = taskColumnOf(task) - if (tasksByColumn[col]) tasksByColumn[col].push(task) - } - - const allCards = (id) => { - const delivery = deliveryCards(id) - const taskList = tasksByColumn[id] ?? [] - return [...taskList, ...delivery] - } - - // The two kinds share these columns, so which one a card is has to come from where it was read, - // never from guessing at its fields. - const taskIds = new Set((tasks ?? []).map(task => task?.id)) - const isTask = (item) => taskIds.has(item?.id) - - return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }), - }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), - allCards(column.id).length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : allCards(column.id).map(item => - isTask(item) - ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) -} - -// 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, filter = 'all' }) { - 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 => { - const cards = column.cards.filter(card => matchesFilter(card, column.id, filter)) - return 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(cards.length))), - cards.length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : 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))) -} - -// #1326: Multi-repo unified board — merge successful repos into one shared column set with tasks -// appearing once, and show failed repos as error blocks. -function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag }) { - const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) - const failed = (entries ?? []).filter(entry => entry?.columns === undefined) - - // Merge all successful repo columns into one view, keeping only the workflow columns. - // Stamp entry.repo onto each card so data-key stays unique across repos (#1326). - const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } - for (const entry of successful) { - for (const [column, cards] of Object.entries(entry.columns)) { - // Only merge columns that exist in the workflow column set - if (merged.columns[column]) { - merged.columns[column].push(...cards.map(card => ({ ...card, repo: card.repo ?? entry.repo }))) - } - } - } - - return h('div', { className: 'bt-del-multi' }, - // Successful repos: one shared column set with all cards and tasks once - successful.length > 0 - ? h(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) - : null, - // Failed repos: show error blocks - failed.map(entry => h(DeliveryRepoBlock, { - key: entry.repo ?? 'unknown', - entry, - tasks: [], // Don't pass tasks to failed repo blocks - roster, - dragging, - onMove, - onDrag, - filter - }))) -} - -// A repository that cannot be read keeps its block and says so; the readable ones are drawn beside -// it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { - const state = entry?.state ?? 'fresh' - const source = repoSourceText(entry?.repoSource) - return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, - h('h3', { className: 'bt-del-repo-head' }, entry?.repo ?? t('no repository — set delivery.repo'), - source === null ? '' : ` · ${source}`), - entry?.columns === undefined - ? 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, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag })) -} - -export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { - const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) - const state = payload.state ?? 'fresh' - // The countdown promises a number that moves, so the screen redraws once a second while a retry - // is actually scheduled — and only then: a fresh or loading board must not tick (#1227). The - // timer lives in the browser, not in the host, which installs none (#1173). - const retryAt = Number(payload.retryAt) - const ticking = (state === 'failed' || state === 'stale') && Number.isFinite(retryAt) && retryAt > 0 - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - if (!ticking) return undefined - setNow(Date.now()) - const timer = setInterval(() => setNow(Date.now()), 1_000) - return () => clearInterval(timer) - }, [ticking, retryAt]) - const countdown = retryCountdown(payload.retryAt, now) - // The filter is view state: it narrows which cards the columns show and never reorders them (#1326). - const [filter, setFilter] = useState('all') - // More than one repository is one block per repository; exactly one is today's single board. - const entries = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null - // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. - // #1326: Task cards still render when delivery fails. - const hasTasks = Array.isArray(tasks) && tasks.length > 0 - if (state === 'idle') { - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - message - ? h('p', { className: 'bt-board-error', role: 'alert' }, message) - : h('p', { className: 'bt-board-empty' }, t('No snapshot yet.'))) - } - if (state === 'loading') { - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - h('p', { className: 'bt-del-loading', role: 'status' }, loadingText(payload.elapsedMs)), - h('p', { className: 'bt-del-hint' }, t('The first round reads the repository once; the columns appear as soon as it lands.'))) - } - // A board exists when the payload carries its columns, or when at least one repository does; with - // several repositories one failure is `partial`, not an empty page (#1265). - const hasBoard = Boolean(payload.columns) || (entries !== null && entries.some(entry => entry.columns !== undefined)) - if (state === 'failed' || !hasBoard) { - const error = payload.error ?? null - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - h('div', { className: 'bt-board-error', role: 'alert' }, - h('p', null, String(error?.message ?? message ?? t('The delivery board is not ready yet.'))), - error ? h('p', { className: 'bt-del-hint' }, hintFor(error, { repo })) : null, - h('p', { className: 'bt-del-hint', 'data-retry-at': payload.retryAt ?? undefined }, - countdown === null - ? t('While this page is open it retries on its own; press Retry to try again now.') - : t('Retrying in {seconds} s · press Retry to try again now', { seconds: countdown })), - onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null)) - } - const missing = payload.missing ?? [] - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, - state === 'stale' && payload.error - ? h('p', { className: 'bt-del-stale', role: 'status' }, staleText(payload.asOf, payload.error), - ` · ${hintFor(payload.error, { repo })}`, - countdown === null ? '' : ` · ${t('Retrying in {seconds} s · press Retry to try again now', { seconds: countdown })}`) - : null, - state !== 'stale' && missing.length > 0 - ? 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, filter }) - : null, - entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) - : multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag })) -} - -export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { - const [view, retry] = useDeliveryBoard(actions) - return h(DeliveryBoard, { - view, - repo: view?.repo ?? null, - tasks, - roster, - dragging, - onMove, - onDrag, - onRetry: actions?.deliveryBoard ? retry : null, - onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null, - }) -} - -// A repository row as a card on the one board (#1265 step 1): the same component the task cards -// use, keyed `owner/name#number` so an issue and the pull request that closes it are one identity. -export function DeliveryCard({ card, repo = null }) { - const model = githubCard(card, repo) ?? { source: 'github', key: null, title: card?.title ?? '', badge: card?.badge ?? null } - return h(BoardCard, { - card: model, - skin: DELIVERY_SKIN, - attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined, 'data-stage': card?.stage ?? undefined }, - idText: `#${card?.number ?? '?'}`, - badge: badgeLabel(card?.badge), - }) -} - -export const 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-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} -.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} -.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 diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index f351948e9..ac5e6c25d 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1348,86 +1348,6 @@ const HOST_ZH = { 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', 'No FlyBot core update is available': '没有可用的 FlyBot 核心更新', 'saved by a newer FlyBot; update FlyBot': '由更新的 FlyBot 保存;请更新 FlyBot', - // #1173: the delivery board — one repository-wide snapshot beside the per-Bot task board. - 'Delivery': '交付', - 'Board view': '看板视图', - 'Original issue': '原始单', - 'Ready to start': '待开工', - 'In review': '审查中', - 'Done today': '今天完成', - 'INC · rework': '打回 · 返工', - 'PASS · waiting for enqueue': '通过 · 待入队', - 'PASS · waiting for merge': '通过 · 待合入', - 'CI red': 'CI 红', - 'CI running': 'CI 跑着', - 'No verdict': '还没结论', - 'New issues': '新单', - 'Closed issues': '关单', - 'New pull requests': '新 PR', - 'Merged pull requests': '合入 PR', - 'Median issue close': '关单中位', - 'Median PR merge': '合入中位', - 'Today': '今天', - 'No GitHub connection yet; the board fills in once one is added.': '还没接 GitHub,接上后这里就有数了。', - 'The delivery board is not ready yet.': '交付看板还没准备好。', - 'Showing the last snapshot from {time}: {error}': '显示 {time} 的快照:{error}', - 'linked to #{number}': '关联 #{number}', - 'open on GitHub': '在 GitHub 上打开', - 'CI failed {failed} of {total}': 'CI 红 {failed} / 共 {total}', - 'counted {counted} of {total}': '已统计 {counted} / 共 {total}', - '{count} superseded': '{count} 条被取代', - 'Open: {issues} issues ({needs} need a human), {prs} pull requests': '开着:{issues} 张单({needs} 张等人),{prs} 个 PR', - 'median open age {age}': '开着的单中位 {age}', - 'as of {time}': '截至 {time}', - 'refreshing…': '刷新中…', - // #1212: the 7-day lines. A day the host could not read, and a snapshot with no series at all. - 'not available': '未取到', - 'No 7-day data yet.': '7 天数据未取到', - '{label} over the last 7 days': '{label}近 7 天', - 'Last 7 days': '近 7 天', - 'so far': '截至现在', - 'today is partial': '今天为部分日', - '* {count} of 7 days had data': '* 7 天中 {count} 天有数', - '* {count} of {days} days had data': '* {days} 天中 {count} 天有数', - 'CI red share': 'CI 红占比', - 'No snapshot yet.': '还没有快照。', - 'Nothing here.': '这里还没有。', - 'First load in progress, {seconds} s elapsed': '首次加载中,已 {seconds} 秒', - 'Showing the snapshot from {time} · refresh failed: {message}': '显示 {time} 的快照 · 刷新失败:{message}', - 'The first round reads the repository once; the columns appear as soon as it lands.': '首轮要把这个仓读一遍,列会在拿到的同时出现。', - 'While this page is open it retries on its own; press Retry to try again now.': '页面开着时自动重试,也可点重试立即再试。', - 'Retrying in {seconds} s · press Retry to try again now': '{seconds} 秒后自动重试,也可点重试立即再试。', - 'Checks unknown': '检查结果未取到', - 'CI not available this time': '本次未取到 CI 数据', - '{count} day(s) not read': '{count} 天没取到', - 'Diagnose': '诊断', - 'Checking…': '检查中…', - Copy: '复制', - Close: '关闭', - 'this repository': '这个仓', - // #1250: whose repository the board read, and where that name came from — a package default - // shown as a fact is what put one instance's numbers on another. - 'no repository — set delivery.repo': '没有仓库 —— 请设置 delivery.repo', - '{count} repositories': '{count} 个仓库', - 'from config': '来自 config', - 'from remote': '来自 remote', - 'from the package (default)': '来自安装包(默认值)', - 'Set delivery.repo in this instance\'s settings to the repository this instance works in.': '在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。', - 'Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.': '在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。', - 'GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。', - 'GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。', - 'GitHub did not answer in time; the next attempt happens while this page is open.': 'GitHub 没在时限内回应,页面开着时下一次重试会自动进行。', - 'Give the token Actions: Read.': '去设置里给 token 勾 Actions: Read。', - 'Give the token Checks: Read.': '去设置里给 token 勾 Checks: Read。', - 'Give the token Issues: Read.': '去设置里给 token 勾 Issues: Read。', - 'Give the token Pull requests: Read.': '去设置里给 token 勾 Pull requests: Read。', - 'Check the repository name, and that the token can see {repo}.': '检查仓库名对不对、token 有没有 {repo} 的访问权。', - 'GitHub is not answering this instance right now; retry in a moment.': 'GitHub 现在没回应这个实例,稍后重试。', - 'The next attempt happens while this page is open; the reason above names what failed.': '页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。', - '{part} was not fetched this time: {message}': '{part}这次没取到:{message}', - 'CI data': 'CI 数据', - 'The rest of the board': '看板剩下的部分', - 'Part of the board': '看板的一部分', } Object.assign(ZH, HOST_ZH) diff --git a/core/src/client/index.js b/core/src/client/index.js index 3a6c433ca..831fd81df 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -615,8 +615,6 @@ export function apply(ctx) { setBotConnector: (botId, connector, enabled) => after(call('bot-connectors', { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), tasks: (payload) => call('tasks', payload ?? {}), - deliveryBoard: (payload) => call('delivery-board', payload ?? {}), - deliveryProbe: (payload) => call('delivery-probe', payload ?? {}), taskUpdate: (payload) => call('task-update', payload), taskCreate: (payload) => call('task-create', payload), schedules: () => call('schedules'), diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index cee4c91db..d3dd00e34 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -7,7 +7,6 @@ import { taskCard } from '../shared/board-card.js' import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' -import { DeliveryView, DELIVERY_CSS } from './delivery-view.js' import { t } from './i18n.js' const POLL_MS = 15_000 @@ -224,8 +223,8 @@ function RailButton({ row, selected, meta, onSelect }) { meta ? h('span', { className: 'bt-board-rail-meta' }, meta) : null) } -// A task record as a card on the one board (#1265 step 1): the same component the delivery cards -// use, with the task's own attributes and actions hanging off it. +// A task record as a card. The shared frame draws the identity line; this board hangs its own +// attributes and actions off it. export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { const column = columnOf(task) const priority = priorityOf(task) @@ -358,36 +357,8 @@ function scopeName(roster, scope) { return '' } -// #1326: one board that shows both task cards and delivery cards together, no tab split. -// Columns come from workflow definitions; task cards map into the same columns as delivery cards. -function UnifiedBoard({ roster, tasks, rail, scope, counts, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { - const selectedBot = scope?.kind === 'bot' ? scope.botId : '' - const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' - const lanes = scope?.kind === 'group' - return h('div', { className: 'bt-board-body' }, - h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, - h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), - rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { - const count = counts?.[row.id] ?? { done: 0, total: 0 } - return h(RailButton, { - key: row.id, row, selected: row.id === selectedBot, onSelect, - meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', - }) - }), - rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, - rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), - h('div', { className: 'bt-board-main' }, - scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, - h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), - h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, - error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, - lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, - h(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }))) -} - export function KanbanDialog({ roster, actions, leaving = false }) { - // One board (#1326): task cards and delivery cards together, no tab split. The rail is per-Bot - // for task scope; delivery is repo-wide and reads from actions.deliveryBoard. + // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) @@ -435,16 +406,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-status' }, name), h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), - h(UnifiedBoard, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, actions, + h(BoardView, { + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } export const KANBAN_CSS = ` .bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -.bt-board-views{display:flex;gap:6px;align-items:center} -.bt-board-views .bt-soft[aria-selected=true]{background:var(--bt-active,var(--bt-hover));color:var(--bt-ink)} .bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} .bt-board-mark svg{width:18px;height:18px} .bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -505,4 +474,4 @@ export const KANBAN_CSS = ` .bt-board-columns{flex-direction:column} .bt-board-column{min-width:0} } -` + DELIVERY_CSS +` diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 74148bdba..ede61bead 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -402,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' || ep === 'workflows') return ALLOW + || 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 3e6255d98..8d858b243 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -215,9 +215,7 @@ export function install(rt) { // What a host that only reads the team may still answer. // Updates change the dsh profile, not the team, so a reading host may run them too. // 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', 'workflows']) + 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', '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). @@ -655,12 +653,6 @@ export function install(rt) { case 'task-answer-input': await rt.load() return ok(await rt.taskEndpoint(endpoint, body, actor)) - case 'delivery-board': - await rt.load() - return ok(await rt.deliveryBoard(body)) - 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). diff --git a/core/src/host/delivery-board.js b/core/src/host/delivery-board.js deleted file mode 100644 index 817a572bb..000000000 --- a/core/src/host/delivery-board.js +++ /dev/null @@ -1,1031 +0,0 @@ -// Delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull -// requests and CI runs, laid out by delivery stage. This module is a view over GitHub, not a -// writer: every request it makes is a GET, the token stays in this process, and the payload the -// client receives carries no credential. The snapshot is cached; a stale cache is still served -// while a refresh runs, and the payload says when it was taken. -// -// A round neither blocks the caller nor fails whole (#1211): the first read answers -// `state: 'loading'` at once and the round runs behind `inflight`; a part that cannot be read is -// named in `missing` and the rest of the board still comes back; a round that outlives its 90 -// second budget lands as `partial` with what it has. Every failure carries -// `{ message, endpoint, status, kind, scope, at }` so the view can say what failed, where, and what -// 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' - -// #1326: Workflow-based columns. The three visible stages of repo-dev; review and done-today are filters. -export const DELIVERY_COLUMNS = ['not-started', 'in-progress', 'done'] - -// State labels first on a card, then the areas; the order is the order the board shows. -const STATE_LABELS = ['needs-human', 'needs-info', 'agent-ready', 'agent-working', 'agent-pr', 'awaiting-approval', 'hold'] -const DAY_MS = 86_400_000 -// Asia/Shanghai has no daylight saving; the day boundary the owner reads is its 00:00. -const SHANGHAI_OFFSET_MS = 8 * 3_600_000 -const CACHE_TTL_MS = 5 * 60_000 -const PAGE = 100 -// GitHub caps a list at 1000 items per query; the CI payload says counted/total instead of -// pretending the tail does not exist. -const MAX_PAGES = 10 -const REQUEST_TIMEOUT_MS = 20_000 -const MAX_DONE = 40 -// A round that runs past this lands as `partial` instead of reaching the browser as a timeout -// (#1211): the instance sits behind a reverse proxy that gives up long before an unbounded round -// would finish. -export const BUILD_BUDGET_MS = 90_000 -// After a failed round the next one waits 10 s, then 30 s, then two minutes, and never longer than -// that. The fixed 60 second poll either hammered a broken instance or hid a recovery (#1211). -export const BACKOFF_MS = [10_000, 30_000, 120_000] - -export const startOfDay = (now = Date.now()) => Math.floor((now + SHANGHAI_OFFSET_MS) / DAY_MS) * DAY_MS - SHANGHAI_OFFSET_MS - -// `YYYY-MM-DD` for a day boundary, read in the zone the day is cut in — the date the owner sees, -// not the UTC one, which is a day behind for eight hours of every Shanghai day (#1222). -export const shanghaiDate = (start) => new Date(start + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10) - -// The seven-day series the chart draws (#1222): one entry per Asia/Shanghai day, oldest first, the -// last one today and marked `partial`. It is assembled from the two windows the round already read -// — the week's seven days and today's own — so it costs no request. A window that could not be -// read, or a day nobody counted, makes the whole series `null`: the view says it has no data -// instead of drawing seven quiet zero days. -export function dailySeries(week, todayMetrics, today) { - const buckets = new Map() - for (const day of [...(week?.daily ?? []), ...(todayMetrics?.daily ?? [])]) buckets.set(day.start, day) - const series = [] - for (let offset = 6; offset >= 0; offset -= 1) { - const start = today - offset * DAY_MS - const bucket = buckets.get(start) - if (bucket === undefined) return null - series.push({ - date: shanghaiDate(start), - ...(offset === 0 ? { partial: true } : {}), - newIssues: bucket.newIssues, - closedIssues: bucket.closedIssues, - newPullRequests: bucket.newPullRequests, - mergedPullRequests: bucket.mergedPullRequests, - ci: bucket.ci, - }) - } - return series -} - -// The per-day buckets are the series' raw material; the payload carries the assembled series only. -const withoutBuckets = (metrics) => { - if (!metrics) return metrics ?? null - const { daily: _buckets, ...rest } = metrics - return rest -} -const withoutCiDays = (ci) => { - if (!ci) return ci ?? null - const { days: _days, ...rest } = ci - return rest -} - -export function median(values) { - const list = (values ?? []).filter(value => Number.isFinite(value)).sort((a, b) => a - b) - if (list.length === 0) return null - const middle = Math.floor(list.length / 2) - return list.length % 2 === 0 ? (list[middle - 1] + list[middle]) / 2 : list[middle] -} - -const labelNames = (row) => (row.labels ?? []).map(label => (typeof label === 'string' ? label : label?.name)).filter(Boolean) -const orderLabels = (names) => [...names].sort((a, b) => { - const left = STATE_LABELS.indexOf(a), right = STATE_LABELS.indexOf(b) - if (left !== -1 || right !== -1) return (left === -1 ? STATE_LABELS.length : left) - (right === -1 ? STATE_LABELS.length : right) - return a.localeCompare(b) -}) -// The issues endpoint returns pull requests too; the pulls endpoint never does, so the two -// lists are told apart by that field rather than by sniffing a PR's shape. -const isPull = (row) => row.pull_request !== undefined || row.merge_commit_sha !== undefined -// GitHub's closing keywords are the only mention that links a pull request to an issue (#1189). -// `also see #12`, `same as #12` or a changelog line quoting a number is prose, and a card that -// showed those as a link would claim a pull request that closes nothing. -const CLOSING_KEYWORD = /\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)[ \t]*:?[ \t]*#(\d+)/gi -const closingLinks = (text) => [...String(text).matchAll(CLOSING_KEYWORD)].map(match => Number(match[1])) -const verdictOf = (comments) => { - for (let index = (comments ?? []).length - 1; index >= 0; index -= 1) { - const body = String(comments[index]?.body ?? '') - const match = /^\s*Verdict:\s*(PASS|INC)\b(.*)$/im.exec(body) - if (!match) continue - // The Verdict line names the tip the conclusion is about, so its sha is the marker; an - // Attestation line only stands in when the verdict line carries no sha. Reading them the other - // way round would let an older attestation hide a verdict that names the current tip (#1173). - const at = /^\s*Verdict:\s*(?:PASS|INC)\s+at\s+([0-9a-f]{7,40})/im.exec(body)?.[1] ?? null - const attestation = at ?? /^\s*Attestation:\s*([0-9a-f]{7,40})/im.exec(body)?.[1] ?? null - return { verdict: match[1].toUpperCase(), attestation } - } - return null -} - -// 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, - workflow: prefixed('workflow:'), - node: prefixed('node:'), - owner, - created: row.created_at ?? null, - updated: row.updated_at ?? null, - linked, - badge, - sha, - draft: row.draft === true, - } -} - -// #1326: Map delivery stage to workflow columns. PRs are in-progress (build/verify/gate); -// review is a filter (column:false). Issues map by labels. -export function columnOfDelivery(row, { kind }) { - const labels = labelNames(row) - if (kind === 'pr') return 'in-progress' - if (labels.includes('agent-working') || labels.includes('agent-pr')) return 'in-progress' - if (labels.includes('agent-ready')) return 'not-started' - return 'not-started' -} - -// Which part of the board a path belongs to. The view turns this into advice a person can act on -// ("give the token Actions: Read"), so it travels with every failure (#1211). -export function scopeOf(pathname) { - const path = String(pathname ?? '') - if (/\/actions\/runs/.test(path)) return 'actions' - if (/\/check-runs/.test(path)) return 'checks' - if (/\/comments/.test(path)) return 'comments' - if (/\/pulls/.test(path)) return 'pulls' - if (/\/issues/.test(path)) return 'issues' - if (/^\/repos\/[^/]+\/[^/]+$/.test(path)) return 'repo' - return null -} - -const headerOf = (response, name) => { - try { - const value = response?.headers?.get?.(name) - return typeof value === 'string' && value !== '' ? value : null - } catch { return null } -} - -// How long GitHub itself said to wait, or null when it said nothing usable. A number from here is -// the only time the view may print a wait (#1211): an invented "about 60 seconds" is disproved by -// the next rate limit. -export function retryAfterMsOf(response, now = Date.now) { - const retryAfter = headerOf(response, 'retry-after') - if (retryAfter !== null) { - const seconds = Number(retryAfter) - if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000) - const at = Date.parse(retryAfter) - if (Number.isFinite(at)) return Math.max(0, at - now()) - } - const reset = Number(headerOf(response, 'x-ratelimit-reset')) - if (Number.isFinite(reset) && reset > 0) return Math.max(0, reset * 1000 - now()) - return null -} - -// One failure, described well enough to tell five causes apart (#1211): a timeout is not a 404, a -// rate limit is not a missing permission, and neither is "no token at all". -export function describeResponse(response, pathname, { now = Date.now } = {}) { - const status = Number(response?.status ?? 0) - const remaining = headerOf(response, 'x-ratelimit-remaining') - const wait = retryAfterMsOf(response, now) - const rateLimited = status === 429 || (status === 403 && (wait !== null || remaining === '0')) - const kind = rateLimited ? 'rate-limit' - : status === 401 ? 'auth' - : status === 403 ? 'forbidden' - : status === 404 ? 'not-found' - : status >= 500 ? 'server' - : 'http' - const scope = scopeOf(pathname) - const message = rateLimited - ? `GitHub rate-limited this request for ${pathname}${wait === null ? '' : ` (retry after ${Math.ceil(wait / 1000)} s)`}` - : `GitHub answered ${status} for ${pathname}` - return { message, endpoint: pathname, status, kind, scope, retryAfterMs: rateLimited ? wait : null } -} - -export const failureOf = (message, detail = {}) => Object.assign(new Error(message), detail) - -async function readJson(response) { - if (!response.ok) throw new Error(`GitHub answered ${response.status}`) - return response.json() -} - -// The diagnostic entry (#1211): the same reads the board makes, once, with their statuses, so an -// instance that "does not come up" can be explained instead of guessed at. The UI button and the -// `--probe` CLI both call this function; there is no second implementation. -export async function probeGitHub({ repo, token, request = fetch, now = Date.now, sha = null, issue = null } = {}) { - const base = `https://api.github.com/repos/${repo}` - const bearer = typeof token === 'function' ? token() : token - const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'flybot-delivery-board' } - if (bearer) headers.Authorization = `Bearer ${bearer}` - const rows = [] - const ask = async (key, url) => { - const pathname = new URL(url).pathname - try { - const response = await request(url, { method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - if (!response.ok) { - const detail = describeResponse(response, pathname, { now }) - rows.push({ key, ok: false, ...detail }) - return null - } - rows.push({ key, ok: true, endpoint: pathname, status: response.status, kind: null, scope: scopeOf(pathname), retryAfterMs: null, message: null }) - return await response.json().catch(() => null) - } catch (error) { - const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' - rows.push({ - key, ok: false, endpoint: pathname, status: null, - kind: timedOut ? 'timeout' : 'network', scope: scopeOf(pathname), retryAfterMs: null, - message: timedOut - ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1000} seconds for ${pathname}` - : `GitHub request failed for ${pathname}: ${error?.message ?? error}`, - }) - return null - } - } - // The pull request list is asked first because the checks read needs a sha to ask about. - const pullsBody = await ask('pulls', `${base}/pulls?per_page=1`) - const openPull = Array.isArray(pullsBody) ? pullsBody[0] : null - await ask('repo', base) - await ask('actions', `${base}/actions/runs?per_page=1`) - const target = sha ?? openPull?.head?.sha ?? null - if (target) await ask('checks', `${base}/commits/${target}/check-runs?per_page=1`) - else rows.push({ key: 'checks', ok: false, endpoint: `/repos/${repo}/commits/{sha}/check-runs`, status: null, kind: 'skipped', scope: 'checks', retryAfterMs: null, message: 'no sha to read checks at' }) - await ask('comments', `${base}/issues/${issue ?? openPull?.number ?? 1}/comments?per_page=1`) - // The order the view prints them in; the order they were asked in is an implementation detail. - const order = ['repo', 'actions', 'checks', 'comments', 'pulls'] - rows.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) - return { ok: rows.every(row => row.ok), repo, at: new Date(now()).toISOString(), rows } -} - -export function createDeliveryBoard({ repo, repoSource = null, token, request = fetch, now = Date.now, ttl = CACHE_TTL_MS, warn = () => {}, budgetMs = BUILD_BUDGET_MS } = {}) { - // Secrets are read asynchronously after install, so the token is resolved per request: a value - // captured at install time is empty until the next restart (#1173). - const tokenNow = () => (typeof token === 'function' ? token() : token) - const base = `https://api.github.com/repos/${repo}` - const cache = new Map() - let snapshot = null - let lastError = null - let inflight = null - let since = null - let attempt = 0 - let nextAttemptAt = 0 - // The round in progress: the deadline every request checks, and the parts that could not be read - // this time. Null outside a round. - let round = null - - const note = (part, error) => { - if (round === null) return - const detail = error !== null && typeof error === 'object' ? error : { message: String(error) } - round.missing.push({ - part, - scope: detail.scope ?? null, - endpoint: detail.endpoint ?? null, - status: detail.status ?? null, - kind: detail.kind ?? 'unknown', - retryAfterMs: detail.retryAfterMs ?? null, - message: detail.message ?? String(error), - }) - } - - // Every URL this board asks for is a read. A write would be a bug, so the shape is refused - // here rather than trusted from the call sites (#1173 read-only invariant). - async function get(url) { - const pathname = new URL(url).pathname - if (round !== null && now() > round.deadline) { - throw failureOf(`the round used its ${Math.round(budgetMs / 1000)} second budget`, { budget: true, endpoint: pathname, kind: 'budget', scope: scopeOf(pathname) }) - } - const cached = cache.get(url) - const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'flybot-delivery-board' } - const bearer = tokenNow() - if (bearer) headers.Authorization = `Bearer ${bearer}` - if (cached?.etag) headers['If-None-Match'] = cached.etag - let response - try { - response = await request(url, { method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - } catch (error) { - const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' - throw failureOf( - timedOut - ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1000} seconds for ${pathname}` - : `GitHub request failed for ${pathname}: ${error?.message ?? error}`, - { endpoint: pathname, scope: scopeOf(pathname), status: null, kind: timedOut ? 'timeout' : 'network' }, - ) - } - if (response.status === 304 && cached !== undefined) { - cache.set(url, { ...cached, at: now() }) - return cached.body - } - if (!response.ok) { - const detail = describeResponse(response, pathname, { now }) - throw failureOf(detail.message, detail) - } - const body = await readJson(response) - cache.set(url, { etag: response.headers?.get?.('etag') ?? null, body, at: now() }) - return body - } - - // A walk that ends on its last allowed page with that page full, and with no row the `stop` - // predicate wanted, left a tail nobody read: the caller must say "unknown", not "fewer" - // (#1211, #1222). A full page alone is not that answer — the stop may have matched on it, which - // means the window was read whole and only the list ended (#1240). - async function walk(path, { max = MAX_PAGES, stop = null } = {}) { - const rows = [] - let truncated = false - for (let page = 1; page <= max; page += 1) { - const separator = path.includes('?') ? '&' : '?' - const chunk = await get(`${base}${path}${separator}per_page=${PAGE}&page=${page}`) - if (!Array.isArray(chunk)) break - rows.push(...chunk) - if (chunk.length < PAGE || (stop !== null && chunk.some(stop))) break - if (page === max) truncated = true - } - return { rows, truncated } - } - - async function pages(path, options) { - return (await walk(path, options)).rows - } - - // Every page, not only the first (#1189). Past 100 comments — ordinary on this repository - // (#1170, #1183) — the newest verdict sat on a page nothing read, and the badge fell back to - // "no verdict at this tip" while one existed. The cap is the same MAX_PAGES as every other list - // and each page is a cached GET (ETag), so a long thread costs up to MAX_PAGES reads of one URL - // and a normal one still costs one. - async function commentsOf(number) { - return pages(`/issues/${number}/comments`) - } - - async function runsOfDay(from, to) { - // GitHub reads `created` as a date-time range in ISO 8601; epoch milliseconds are accepted - // silently and match nothing, which reads as "no CI today" rather than an error (#1173). - const created = `${new Date(from).toISOString()}..${new Date(to).toISOString()}` - let total = null - let counted = 0 - let failed = 0 - let superseded = 0 - let truncated = false - for (let page = 1; page <= MAX_PAGES; page += 1) { - const body = await get(`${base}/actions/runs?created=${created}&per_page=${PAGE}&page=${page}`) - if (total === null) total = Number(body?.total_count ?? 0) - const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [] - counted += runs.length - for (const run of runs) { - if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') failed += 1 - else if (run.conclusion === 'cancelled') superseded += 1 - } - if (runs.length < PAGE) break - // The page cap is not the end of the day: a day whose runs outnumber MAX_PAGES * PAGE has a - // tail nobody counted, and its number is unknown rather than "no failures" (#1211). - if (page === MAX_PAGES) truncated = true - } - return { failed, superseded, counted, total: total ?? counted, truncated } - } - - const windowOf = (from) => ({ from, to: from + DAY_MS }) - - // The CI half of a window, kept apart from the issue and pull counts: `/actions/runs` missing a - // permission is one unknown number in the header, not an empty board (#1211). - async function ciFor(from, ciDays) { - const ci = { failed: 0, superseded: 0, counted: 0, total: 0, unknownDays: [], days: [] } - for (let day = 0; day < ciDays; day += 1) { - // Forward from the window's start, the way every other count in this module reads its - // window: walking back from `from` described the week before the one the other numbers - // cover, so a seven-day CI share was a week out of step with its own row (#1222). - const window = windowOf(from + day * DAY_MS) - const runs = await runsOfDay(window.from, window.to) - if (runs.truncated) { - // The tail was not read, so this day's number is unknown: it is named and left out rather - // than added as a partial truth (#1211). - note('ci', failureOf(`the workflow runs for ${new Date(window.from).toISOString()} were truncated at ${MAX_PAGES * PAGE}`, { kind: 'truncated', scope: 'actions', endpoint: '/actions/runs', status: null })) - ci.unknownDays.push(new Date(window.from).toISOString()) - ci.days.push({ start: window.from, unknown: true }) - continue - } - ci.failed += runs.failed - ci.superseded += runs.superseded - ci.counted += runs.counted - ci.total += runs.total - // The per-day block the chart reads (#1222). A cancelled run never judged the code, so it is - // left out of the denominator; the header keeps its own aggregate untouched. - ci.days.push({ start: window.from, failed: runs.failed, counted: Math.max(0, runs.counted - runs.superseded), total: runs.total }) - } - return ci - } - - // A list that stopped at the page cap has a tail nobody read, so the counts built from it are - // unknown rather than smaller numbers (#1222). The flag is the walk's own answer, not the row - // count: a page four that is full because `stop` matched an old row there was read whole (#1240). - const capped = (listing) => listing.truncated === true - - async function metricsFor(from, { days = 1, ciDays = 1, buckets = false } = {}) { - const to = from + days * DAY_MS - const [issueList, pullList] = await Promise.all([ - walk(`/issues?state=all&since=${new Date(from - DAY_MS).toISOString()}`, { max: 4, stop: row => row.created_at !== undefined && Date.parse(row.created_at) < from - DAY_MS }), - walk('/pulls?state=all&sort=created&direction=desc', { max: 4, stop: row => Date.parse(row.created_at ?? 0) < from }), - ]) - const issues = issueList.rows - const pulls = pullList.rows - const plainIssues = issues.filter(row => !isPull(row)) - const inWindow = (value) => { const at = Date.parse(value ?? ''); return Number.isFinite(at) && at >= from && at < to } - const newIssues = plainIssues.filter(row => inWindow(row.created_at)).length - const closedIssues = plainIssues.filter(row => inWindow(row.closed_at)) - const newPrs = pulls.filter(row => inWindow(row.created_at)).length - const merged = pulls.filter(row => inWindow(row.merged_at)) - const cappedIssues = capped(issueList) - const cappedPulls = capped(pullList) - // A CI window that cannot be read leaves `ci: null` — the header says unknown, never zero. - let ci = null - if (ciDays > 0) { - try { ci = await ciFor(from, ciDays) } catch (error) { - if (error?.budget) throw error - note('ci', error) - } - } - // One bucket per day of the window, for the seven-day series (#1222). A bucket is built from - // the rows this round already read, so it costs no request; a day whose list or run query was - // truncated, or whose CI could not be read at all, says `null` instead of a smaller number. - const dayBuckets = buckets - ? Array.from({ length: days }, (_, index) => { - const start = from + index * DAY_MS - const end = start + DAY_MS - const inDay = (value) => { const at = Date.parse(value ?? ''); return Number.isFinite(at) && at >= start && at < end } - const ciDay = ci?.days?.find(day => day.start === start) ?? null - return { - start, - newIssues: cappedIssues ? null : plainIssues.filter(row => inDay(row.created_at)).length, - closedIssues: cappedIssues ? null : plainIssues.filter(row => inDay(row.closed_at)).length, - newPullRequests: cappedPulls ? null : pulls.filter(row => inDay(row.created_at)).length, - mergedPullRequests: cappedPulls ? null : pulls.filter(row => inDay(row.merged_at)).length, - ci: ciDay === null || ciDay.unknown === true - ? null - : { failed: ciDay.failed, counted: ciDay.counted, total: ciDay.total }, - } - }) - : undefined - return { - newIssues, - closedIssues: closedIssues.length, - closedReasons: { - completed: closedIssues.filter(row => row.state_reason === 'completed').length, - notPlanned: closedIssues.filter(row => row.state_reason === 'not_planned').length, - duplicate: closedIssues.filter(row => row.state_reason === 'duplicate').length, - }, - newPullRequests: newPrs, - mergedPullRequests: merged.length, - medianIssueCloseMs: median(closedIssues.map(row => Date.parse(row.closed_at) - Date.parse(row.created_at))), - medianPullMergeMs: median(merged.map(row => Date.parse(row.merged_at) - Date.parse(row.created_at))), - ci: withoutCiDays(ci), - ...(dayBuckets ? { daily: dayBuckets } : {}), - } - } - - function badgeFor(pr, verdict, checks) { - const head = pr.head?.sha ?? '' - const atTip = verdict !== null && verdict.attestation !== null && (head.startsWith(verdict.attestation) || verdict.attestation.startsWith(head)) - // A verdict only counts at the tip it names; an older one is history, so CI speaks. - if (atTip) { - if (verdict.verdict === 'INC') return { badge: 'inc', basis: 'INC at this tip' } - } else { - // `checks === null` is "could not be read", not "none ran": calling that "no verdict" would - // blame the pull request for a permission the instance is missing (#1211). - if (checks === null) return { badge: 'checks-unknown', basis: 'check-runs could not be read' } - const states = (checks ?? []).map(run => run.status === 'completed' ? run.conclusion : run.status) - if (states.some(state => state === 'in_progress' || state === 'queued' || state === 'requested' || state === 'waiting')) { - return { badge: 'ci-running', basis: 'checks running' } - } - if (states.some(state => state === 'failure' || state === 'timed_out')) return { badge: 'ci-red', basis: 'checks failed' } - return { badge: 'no-verdict', basis: verdict === null ? 'no verdict at this tip' : `verdict at ${verdict.attestation?.slice(0, 7) ?? 'another tip'} is older than this tip` } - } - // GitHub's REST API does not expose the merge queue. A clean, green PR is waiting to be - // enqueued; one that is blocked or still waiting on checks is waiting for its merge. - if (pr.mergeable_state === 'clean') return { badge: 'pass-enqueue', basis: 'PASS, mergeable, not queued yet' } - return { badge: 'pass-merge', basis: `PASS, ${pr.mergeable_state ?? 'unknown'}` } - } - - async function ownerOf(row, kind, comments = null) { - const assignee = (row.assignees ?? [])[0]?.login - if (typeof assignee === 'string' && assignee !== '') return { owner: assignee, ownerKind: 'human' } - if (kind === 'pr') { - const body = String(row.body ?? '') - const who = /^\s*(?:Written-by|Owner):\s*([^\n]+)$/im.exec(body)?.[1]?.trim() - if (who) return { owner: who, ownerKind: /bot/i.test(who) ? 'bot' : 'human' } - } - const list = comments ?? await commentsOf(row.number) - for (let index = list.length - 1; index >= 0; index -= 1) { - const claim = /^\s*Claimed by\s+([^\n]+)$/im.exec(String(list[index]?.body ?? ''))?.[1]?.trim() - if (claim) return { owner: claim, ownerKind: /bot/i.test(claim) ? 'bot' : 'human' } - } - return { owner: null, ownerKind: null } - } - - // One part of the round: a failure is noted by name and the fallback is used, except the budget, - // which ends the whole round as a partial snapshot (#1211). - async function part(name, work, fallback) { - try { return await work() } catch (error) { - if (error?.budget) throw error - note(name, error) - return fallback - } - } - - async function snapshotFromGitHub({ startedAt }) { - const today = startOfDay(startedAt) - const weekStart = today - 7 * DAY_MS - let openIssues = [] - let openPulls = [] - let closedToday = [] - let mergedToday = [] - let todayMetrics = null - let weekMetrics = null - // #1326: Three workflow columns; done-today cards go in 'done' with data-stage='done-today' for filtering. - const columns = { 'not-started': [], 'in-progress': [], 'done': [] } - try { - // The two open lists are the board itself: when they cannot be read there is no board to - // show, so that failure is the round's, not a part's (#1211). - ;[openIssues, openPulls] = await Promise.all([ - pages('/issues?state=open&sort=updated&direction=desc'), - pages('/pulls?state=open&sort=updated&direction=desc'), - ]) - // Two windows, settled separately: a week that cannot be read must not take today with it. - const metricsResults = await Promise.allSettled([ - metricsFor(today, { buckets: true }), - metricsFor(weekStart, { days: 7, ciDays: 7, buckets: true }), - ]) - const settle = (result, name) => { - if (result.status === 'fulfilled') return result.value - if (result.reason?.budget) throw result.reason - note(name, result.reason) - return null - } - todayMetrics = settle(metricsResults[0], 'metrics.today') - weekMetrics = settle(metricsResults[1], 'metrics.week') - closedToday = await part('closed-today', () => pages(`/issues?state=closed&since=${new Date(today - DAY_MS).toISOString()}`, { max: 3 }), []) - mergedToday = await part('merged-today', () => pages('/pulls?state=closed&sort=updated&direction=desc', { max: 3, stop: row => Date.parse(row.updated_at ?? 0) < today - 2 * DAY_MS }), []) - } catch (error) { - // The budget is not a failure: everything read so far is assembled and the snapshot lands as - // `partial`, so the view has something to show and the reason it is incomplete (#1211). - if (!error?.budget) throw error - note('budget', error) - } - const issues = openIssues.filter(row => !isPull(row)) - const pullRows = openPulls - const linkedTo = new Map() - for (const pr of pullRows) { - for (const number of closingLinks(`${pr.title ?? ''}\n${pr.body ?? ''}`)) { - if (!linkedTo.has(number)) linkedTo.set(number, pr.number) - } - } - // A card whose owner cannot be read loses its owner, not the board (#1211). - for (const issue of issues) { - const column = columnOfDelivery(issue, { kind: 'issue' }) - let owner = null - let ownerKind = null - if (column !== 'raw') { - try { ({ owner, ownerKind } = await ownerOf(issue, 'issue')) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${issue.number}`, error) - } - } - columns[column].push(cardOf(issue, { kind: 'issue', linked: linkedTo.get(issue.number) ?? null, owner, ownerKind })) - } - for (const pr of pullRows) { - let verdictComments = null - try { verdictComments = await commentsOf(pr.number) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${pr.number}`, error) - } - // `null` is "could not be read"; an empty list is "none ran". The badge says which (#1211). - let checks = null - if (pr.head?.sha) { - try { - const body = await get(`${base}/commits/${pr.head.sha}/check-runs?per_page=100`) - checks = body?.check_runs ?? [] - } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`checks:${pr.number}`, error) - } - } else { - note(`checks:${pr.number}`, failureOf('this pull request has no head sha', { kind: 'skipped', scope: 'checks', endpoint: '/commits/{sha}/check-runs', status: null })) - } - const verdict = verdictOf(verdictComments ?? []) - const { badge, basis } = badgeFor(pr, verdict, checks) - let owner = null - let ownerKind = null - try { ({ owner, ownerKind } = await ownerOf(pr, 'pr', verdictComments)) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${pr.number}`, error) - } - // An open pull request is work in progress, and `review` is a stage a card carries: the - // definition draws no column for it (#1326), so the card is placed by `columnOfDelivery`. - columns[columnOfDelivery(pr, { kind: 'pr' })].push({ - ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), - badgeBasis: basis, - stage: 'review', - }) - } - const closedInDay = (row) => Date.parse(row.closed_at ?? '') >= today - const doneIssues = closedToday.filter(row => !isPull(row) && closedInDay(row)) - const donePulls = mergedToday.filter(row => Date.parse(row.merged_at ?? '') >= today) - // A pull request closed today without a merge is finished work too, so it is done today and not - // nowhere (#1173). The issues endpoint describes its pull requests by the `pull_request` object - // and gives no top-level `merged_at` and no `head`: reading either would count a merged pull - // twice and name a commit this endpoint never returns. The numbers already counted as merged - // settle it even when that row carries no merge time. - const mergedNumbers = new Set(donePulls.map(row => row.number)) - const abandonedPulls = closedToday.filter(row => isPull(row) && !row.pull_request?.merged_at && !mergedNumbers.has(row.number) && closedInDay(row)) - // #1326: done-today cards go in 'done' column with stage='done-today' for filtering. - for (const row of donePulls) columns.done.push({ ...cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }), stage: 'done-today' }) - for (const row of abandonedPulls) columns.done.push({ ...cardOf(row, { kind: 'pr' }), stage: 'done-today' }) - for (const row of doneIssues) columns.done.push({ ...cardOf(row, { kind: 'issue' }), stage: 'done-today' }) - // done column holds all done cards; done-today is a filter, not a separate column. - columns.done = columns.done.slice(0, MAX_DONE) - const needsHuman = issues.filter(row => labelNames(row).includes('needs-human') || labelNames(row).includes('needs-info')).length - const openPrs = pullRows.length - return { - ok: true, - repo, - asOf: new Date(startedAt).toISOString(), - computedMs: now() - startedAt, - columns, - counts: Object.fromEntries(DELIVERY_COLUMNS.map(column => [column, columns[column].length])), - metrics: { - dayStart: new Date(today).toISOString(), - timeZone: 'Asia/Shanghai', - today: withoutBuckets(todayMetrics), - week: withoutBuckets(weekMetrics), - daily: dailySeries(weekMetrics, todayMetrics, today), - open: { - issues: issues.length, - pullRequests: openPrs, - needsHuman, - medianIssueAgeMs: median(issues.map(row => startedAt - Date.parse(row.created_at))), - }, - }, - } - } - - function view(extra = {}) { - const elapsedMs = since === null ? 0 : Math.max(0, now() - Date.parse(since)) - if (snapshot === null) { - if (inflight !== null) return { ok: true, state: 'loading', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: null, ...extra } - if (lastError !== null) return { ok: true, state: 'failed', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: nextAttemptAt || null, ...extra } - // Never tried: the one case where the view may say there is no snapshot yet (#1211). - return { ok: false, state: 'idle', repo, repoSource, since: null, attempt: 0, elapsedMs: 0, error: null, retryAt: null, ...extra } - } - const state = lastError !== null || inflight !== null ? 'stale' : (snapshot.missing?.length ? 'partial' : 'fresh') - return { - ok: true, - ...snapshot, - repoSource, - state, - stale: lastError !== null || inflight !== null, - refreshing: inflight !== null, - error: lastError, - since, - attempt, - elapsedMs, - retryAt: lastError !== null ? nextAttemptAt || null : null, - ...extra, - } - } - - // The round runs behind `inflight`; nobody awaits it. The first read starts it and answers - // `loading` at once, so the browser is never parked on a whole round (#1211). - function start() { - if (inflight !== null) return - const startedAt = now() - since = new Date(startedAt).toISOString() - attempt += 1 - const current = { deadline: startedAt + budgetMs, missing: [] } - round = current - inflight = (async () => { - try { - const built = await snapshotFromGitHub({ startedAt }) - const missing = [] - const seen = new Set() - for (const entry of current.missing) { - if (seen.has(entry.part)) continue - seen.add(entry.part) - missing.push(entry) - } - snapshot = { ...built, partial: missing.length > 0, missing } - lastError = null - nextAttemptAt = 0 - attempt = 0 - } catch (error) { - const detail = error !== null && typeof error === 'object' ? error : {} - const message = String(error?.message ?? error) - warn('ds-bot: delivery board refresh failed: %s', message) - // Kept for the next read: throwing the reason away is what made a failed instance look - // like one that was never connected (#1211). - lastError = { - message, - endpoint: detail.endpoint ?? null, - status: detail.status ?? null, - kind: detail.kind ?? 'unknown', - scope: detail.scope ?? null, - retryAfterMs: detail.retryAfterMs ?? null, - at: new Date(now()).toISOString(), - } - if (snapshot !== null) snapshot = { ...snapshot, error: message } - const delay = BACKOFF_MS[Math.min(Math.max(attempt, 1) - 1, BACKOFF_MS.length - 1)] - nextAttemptAt = now() + delay - } finally { - inflight = null - round = null - } - })() - } - - return { - columns: DELIVERY_COLUMNS, - // `wait` awaits the round in progress and is for diagnostics and tests; the browser never - // passes it, because parking a request on a whole round is the bug this fixed (#1211). - async read({ force = false, wait = false } = {}) { - if (force) nextAttemptAt = 0 - const age = snapshot === null ? Infinity : now() - Date.parse(snapshot.asOf) - if (inflight === null && now() >= nextAttemptAt && (force || age >= ttl)) start() - if (wait && inflight !== null) { - await inflight.catch(() => { - // The round stores its own failure in `error`; a read must not throw it (#1211). - }) - } - return view() - }, - // Tests and the host's own diagnostics read the cache without touching GitHub. - cached: () => snapshot, - probe: () => probeGitHub({ repo, token: tokenNow, request, now }), - } -} - -// One board out of several repositories (#1265 step 4). A repository that cannot be read does not -// take the board with it: it keeps its own entry and its own `data-state`, and the body says -// `partial`, the same word a half-read round uses (#1211). A single repository answers exactly the -// view it answered alone, so nothing about a one-repository instance changes on screen. -const sumOf = (values) => (values.every(value => Number.isFinite(value)) ? values.reduce((total, value) => total + value, 0) : null) - -const mergeCi = (list) => { - if (list.length === 0 || list.some(ci => ci === null || ci === undefined)) return null - return { - failed: sumOf(list.map(ci => Number(ci.failed ?? 0))), - total: sumOf(list.map(ci => Number(ci.total ?? 0))), - counted: sumOf(list.map(ci => Number(ci.counted ?? 0))), - superseded: sumOf(list.map(ci => Number(ci.superseded ?? 0))), - unknownDays: list.flatMap(ci => ci.unknownDays ?? []), - } -} - -// Seven days added day by day; a field any repository could not count stays `null` rather than -// becoming a smaller number (#1211, #1222). -const mergeDaily = (series) => { - if (series.length === 0 || series.some(days => !Array.isArray(days) || days.length !== series[0]?.length)) return null - const length = series[0].length - return Array.from({ length }, (_, index) => { - const field = (key) => sumOf(series.map(days => days[index]?.[key])) - return { - date: series[0][index]?.date ?? null, - ...(series.some(days => days[index]?.partial === true) ? { partial: true } : {}), - newIssues: field('newIssues'), - closedIssues: field('closedIssues'), - newPullRequests: field('newPullRequests'), - mergedPullRequests: field('mergedPullRequests'), - ci: mergeCi(series.map(days => days[index]?.ci ?? null)), - } - }) -} - -const mergeWindow = (windows) => { - if (windows.length === 0 || windows.some(window => window === null || window === undefined)) return null - return { - newIssues: sumOf(windows.map(window => window.newIssues)), - closedIssues: sumOf(windows.map(window => window.closedIssues)), - closedReasons: { - completed: sumOf(windows.map(window => window.closedReasons?.completed ?? 0)), - notPlanned: sumOf(windows.map(window => window.closedReasons?.notPlanned ?? 0)), - duplicate: sumOf(windows.map(window => window.closedReasons?.duplicate ?? 0)), - }, - newPullRequests: sumOf(windows.map(window => window.newPullRequests)), - mergedPullRequests: sumOf(windows.map(window => window.mergedPullRequests)), - // A median across repositories is not the sum of their medians, and an invented one is worse - // than none: the header reads '—' for both while the board shows more than one repository. - medianIssueCloseMs: null, - medianPullMergeMs: null, - ci: mergeCi(windows.map(window => window.ci ?? null)), - } -} - -// A repository that did not read contributes no numbers: the board's totals are then unknown, not -// the readable repositories' sum (#1297). The entry list is handed over whole — filtering the -// missing `metrics` out first is what printed one repository's numbers under "2 repositories". -const mergeMetrics = (list) => { - if (list.length === 0 || list.some(entry => entry === null || entry === undefined)) return null - const opens = list.map(entry => entry.open ?? null) - return { - dayStart: list[0].dayStart ?? null, - timeZone: list[0].timeZone ?? 'Asia/Shanghai', - today: mergeWindow(list.map(entry => entry.today ?? null)), - week: mergeWindow(list.map(entry => entry.week ?? null)), - daily: mergeDaily(list.map(entry => entry.daily)), - open: opens.some(open => open === null) ? null : { - issues: sumOf(opens.map(open => open.issues)), - pullRequests: sumOf(opens.map(open => open.pullRequests)), - needsHuman: sumOf(opens.map(open => open.needsHuman)), - medianIssueAgeMs: null, - }, - } -} - -export function combineRepos(views) { - if (!Array.isArray(views) || views.length === 0) return null - const entries = views.map(view => ({ - repo: view.repo ?? null, - repoSource: view.repoSource ?? null, - state: view.state ?? 'fresh', - ok: view.ok !== false, - since: view.since ?? null, - attempt: view.attempt ?? 0, - elapsedMs: view.elapsedMs ?? 0, - asOf: view.asOf ?? null, - computedMs: view.computedMs ?? 0, - columns: view.columns, - counts: view.counts ?? null, - metrics: view.metrics ?? null, - error: view.error ?? null, - retryAt: view.retryAt ?? null, - stale: view.stale === true, - refreshing: view.refreshing === true, - missing: view.missing ?? [], - partial: view.partial === true, - })) - // A one-repository instance answers with today's payload: the top-level fields are that - // repository's own, and `repos` is the same view for a caller that reads the list. - if (entries.length === 1) return { ...entries[0], repos: [{ ...entries[0] }] } - const withBoard = entries.filter(entry => entry.columns !== undefined) - const state = withBoard.length === 0 - ? (entries.every(entry => entry.state === 'idle') ? 'idle' : entries.some(entry => entry.state === 'loading') ? 'loading' : 'failed') - : withBoard.length < entries.length ? 'partial' - : entries.some(entry => entry.state === 'stale') ? 'stale' - : entries.some(entry => entry.state === 'partial') ? 'partial' : 'fresh' - const stamps = entries.map(entry => entry.asOf).filter(Boolean).sort() - const startsAt = entries.map(entry => entry.since).filter(Boolean).sort() - const retries = entries.map(entry => Number(entry.retryAt)).filter(at => Number.isFinite(at) && at > 0) - const missing = entries.flatMap(entry => (entry.missing ?? []).map(item => ({ ...item, repo: entry.repo }))) - // Every column the readable repositories have, in one list; the per-repository blocks draw their - // own slices of it, so this top-level copy is the union the payload always carried. - const columns = withBoard.length === entries.length - ? Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, withBoard.flatMap(entry => entry.columns?.[id] ?? [])])) - : undefined - return { - ok: entries.every(entry => entry.ok), - state, - // No single repository owns a shared board, so the header names the count instead (#1265). - repo: null, - repoSource: null, - repos: entries, - asOf: stamps[0] ?? null, - since: startsAt[0] ?? null, - attempt: Math.max(...entries.map(entry => entry.attempt ?? 0)), - elapsedMs: Math.max(...entries.map(entry => entry.elapsedMs ?? 0)), - computedMs: Math.max(...entries.map(entry => entry.computedMs ?? 0)), - error: entries.find(entry => entry.error !== null)?.error ?? null, - retryAt: retries.length > 0 ? Math.min(...retries) : null, - stale: entries.some(entry => entry.stale), - refreshing: entries.some(entry => entry.refreshing), - partial: entries.some(entry => entry.partial) || withBoard.length < entries.length, - missing, - columns, - counts: columns === undefined ? null : Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, columns[id].length])), - // The whole list, missing entries included: a repository that has never read answers `null` - // metrics, and dropping it here would make the rest look like the board's total (#1297). - metrics: mergeMetrics(entries.map(entry => entry.metrics ?? null)), - } -} - -export function install(rt) { - // One board per repository, not one global board with a repo baked in (#1250): a changed - // `delivery.repo` resolves to a new name, and the new name's first read is its own `loading` - // instead of the previous repository's snapshot. - const boards = new Map() - const notedDefaults = new Set() - const warn = (...args) => rt.warn?.(...args) - const tokenOf = () => { - try { return rt.secretValue?.('GITHUB_TOKEN') } catch { return undefined } - } - const request = (url, init) => (rt.fetch ?? fetch)(url, init) - // The workspace is the team folder, not one Bot's folder: the board is host-level, and a Bot's - // folder would make the answer depend on which Bot happened to be named (#1250). - const workspace = () => { - try { return rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { return null } - } - const setting = () => { - try { return instanceRepoSettings(rt.config) } catch { return [] } - } - // Level 3 is the package's own repository, not the instance's choice. The shipped package carries - // it; a host whose package.json has none (or a test) hands in '' and the board refuses rather than - // asking for `/repos/null` (#1250). - const packageSlug = () => { - try { return rt.packageRepository === undefined ? repoSlug() : rt.packageRepository } catch { return null } - } - // Resolved on every call, so the setting is read live and the caches below are keyed by its - // answer. Each repository keeps its own source, so two of them cannot share one line (#1265). - const resolveNow = () => { - const found = resolveRepos({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() }) - // A default is acceptable only while it is visible as a default: the page labels it, and this - // line records it too, so nothing falls back silently (#1250). - for (const entry of found) { - if (entry.source !== 'package' || notedDefaults.has(entry.repo)) continue - notedDefaults.add(entry.repo) - warn('ds-bot: delivery board: no delivery.repos and no git remote in the workspace; reading the package repository %s (default)', entry.repo) - } - return found - } - const boardFor = ({ repo }) => { - let board = boards.get(repo) - if (board === undefined) { - board = createDeliveryBoard({ repo, token: tokenOf, request, warn }) - boards.set(repo, board) - } - return board - } - // Nothing resolves: the answer is a failure, and it is answered before any board exists so no - // request can be made for `/repos/null` (#1250). - const noRepo = () => ({ - ok: false, state: 'failed', repo: null, repoSource: null, repos: [], since: null, attempt: 0, elapsedMs: 0, - error: { message: NO_REPO_MESSAGE, endpoint: null, status: null, kind: 'no-repo', scope: 'repo', retryAfterMs: null, at: new Date().toISOString() }, - retryAt: null, - }) - rt.deliveryBoard = async (payload = {}) => { - const found = resolveNow() - if (found.length === 0) return noRepo() - // The channel answers before a host restart would; load once so the first read is signed. - // The answer is `loading` either way — the round runs behind `inflight` (#1211). - await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery board: secrets did not load: %s', error?.message ?? error)) - const views = [] - for (const entry of found) { - const view = await boardFor(entry).read({ force: payload?.force === true, wait: payload?.wait === true }) - views.push({ ...view, repo: entry.repo, repoSource: entry.source }) - } - 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. - rt.deliveryProbe = async () => { - const found = resolveNow() - if (found.length === 0) return { ok: false, repo: null, repoSource: null, repos: [], at: new Date().toISOString(), rows: [], error: NO_REPO_MESSAGE } - await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery probe: secrets did not load: %s', error?.message ?? error)) - const reports = [] - for (const entry of found) { - try { reports.push({ repo: entry.repo, repoSource: entry.source, ...(await boardFor(entry).probe()) }) } catch (error) { - reports.push({ repo: entry.repo, repoSource: entry.source, ok: false, at: new Date().toISOString(), rows: [], error: String(error?.message ?? error) }) - } - } - const single = reports.length === 1 - const at = new Date().toISOString() - return { - ok: reports.every(report => report.ok), - repo: single ? reports[0].repo : null, - repoSource: single ? reports[0].repoSource : null, - repos: reports.map(report => ({ repo: report.repo, repoSource: report.repoSource, ok: report.ok })), - at, - rows: single ? reports[0].rows : reports.flatMap(report => (report.rows ?? []).map(row => ({ ...row, repo: report.repo }))), - ...(reports.some(report => report.error !== undefined) ? { error: reports.find(report => report.error !== undefined)?.error } : {}), - } - } -} diff --git a/core/src/host/delivery-repo.js b/core/src/host/delivery-repo.js deleted file mode 100644 index be7356c73..000000000 --- a/core/src/host/delivery-repo.js +++ /dev/null @@ -1,163 +0,0 @@ -// Which repositories the delivery board reads, and where each name came from (#1250, #1265). -// -// The board used to read the shipped package's `repository.url` and present it as a fact, so an -// instance set up on another fork showed this project's numbers and said nothing about whose they -// were. Since #1265 step 4 an instance may work in more than one repository, and each one is -// resolved on its own so no two of them share a source line. The same three steps, first answer -// wins, for every name: -// -// 1. the instance setting, `delivery.repos` (a list) or `delivery.repo` (one name, the older -// spelling, read as a list of one) on the plugin entry — the host hands the entry through -// verbatim, so all the spellings people write are read (`instanceRepoSettings`); -// 2. the workspace's own git remote — `/.git/config`, `[remote "origin"] url`; -// 3. the package's `repository.url`, which is a default and is labelled as one. -// -// Nothing is spawned: the host may run where `git` is not on PATH. A `.git` that is a file is -// followed through its `gitdir:` pointer: a submodule keeps `config` there, and a linked worktree -// keeps only a `commondir` pointer to the shared directory that does (#1258). A name is normalised -// to `owner/name`; a GitHub URL or the scp form `git@github.com:owner/name.git` is accepted where a -// name is, because that is what people paste. -import { readFileSync, statSync } from 'node:fs' -import { join, resolve } from 'node:path' - -// The wording acceptance criterion 4 asks for, in one place so the view, the host and the CLI agree. -export const NO_REPO_MESSAGE = 'no repository — set delivery.repo' - -// A segment is a plain repository name part: word characters, dots and dashes, but never a leading -// dot. `.` and `..` are path steps, not names, and a board that accepted them would build -// `/repos/../../issues` and normalise it to the API root (#1258). -const OWNER_NAME = /^(?!\.)[\w.-]+\/(?!\.)[\w.-]+$/ -const SCP_GITHUB = /^(?:[\w.-]+@)?github\.com:([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i - -const cleanName = text => { - const trimmed = String(text ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '') - return OWNER_NAME.test(trimmed) ? trimmed : null -} - -// `owner/name` out of whatever form the value arrived in; null when it is not a GitHub repository -// name. A URL on another host is refused rather than mined for its last two segments. -export function parseRepoName(value) { - const text = String(value ?? '').trim() - if (text === '') return null - const scp = SCP_GITHUB.exec(text) - if (scp) return cleanName(scp[1]) - if (/^[a-z][\w+.-]*:\/\//i.test(text)) { - let url - try { url = new URL(text) } catch { return null } - if (url.hostname.toLowerCase() !== 'github.com') return null - const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/') - return parts.length === 2 ? cleanName(parts.join('/')) : null - } - return cleanName(text) -} - -// The `[remote "origin"] url` out of git's own config text; null when that remote or its url is -// absent. Other remotes are ignored on purpose: one host shows one repository, and a union of -// disagreeing remotes would be a guess dressed as a fact. -export function originUrlIn(text) { - let section = null - for (const line of String(text ?? '').split(/\r?\n/)) { - const trimmed = line.trim() - if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith(';')) continue - const header = /^\[(.+)\]$/.exec(trimmed) - if (header) { section = header[1].trim(); continue } - if (section === null || !/^remote\s+"origin"$/i.test(section)) continue - const value = /^url\s*=\s*(.+)$/i.exec(trimmed) - if (value) return value[1].trim() - } - return null -} - -// Where git keeps the config for this folder: `.git/config`, or whatever a `.git` file's `gitdir:` -// pointer leads to. A submodule keeps `config` in that directory; a linked worktree keeps only a -// `commondir` file naming the shared directory that has it (#1258). Null when the folder is not a -// git checkout at all. -export function gitConfigPath(folder) { - if (typeof folder !== 'string' || folder === '') return null - const dot = join(folder, '.git') - try { - if (statSync(dot).isDirectory()) return join(dot, 'config') - const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8')) - if (!pointer) return null - const gitdir = resolve(folder, pointer[1].trim()) - const own = join(gitdir, 'config') - if (isFile(own)) return own - const common = commonDirOf(gitdir) - return common === null ? null : join(common, 'config') - } catch { return null } -} - -const isFile = path => { - try { return statSync(path).isFile() } catch { return false } -} - -// A linked worktree's gitdir holds no config: `commondir` names the common directory, relative to -// the gitdir, where the shared `config` lives. -const commonDirOf = gitdir => { - try { - const pointer = readFileSync(join(gitdir, 'commondir'), 'utf8').trim() - return pointer === '' ? null : resolve(gitdir, pointer) - } catch { return null } -} - -// The instance setting as the profile actually wrote it. The host passes the plugin entry through -// verbatim and expands no dotted key, so one setting reaches us as a flat `deliveryRepo`, a literal -// `'delivery.repo'`, or a nested `delivery.repo` — the hint, the page and the docs all say the last -// spelling, and reading only the first would ignore what a user just wrote (#1258). First defined -// answer wins; validation stays in `parseRepoName`. -export function instanceRepoSetting(config) { - if (config === null || typeof config !== 'object') return null - for (const value of [config.delivery?.repo, config['delivery.repo'], config.deliveryRepo]) { - if (value !== undefined && value !== null && value !== '') return value - } - return null -} - -// The same setting as a list (#1265 step 4). `delivery.repos` is the new shape and is read first; -// the three older spellings each describe one repository, so a profile written before this change -// keeps working as a list of one. An empty list is "nothing written", not "show nothing": the -// next level still answers, which is what a person who cleared the field expects. -export function instanceRepoSettings(config) { - if (config === null || typeof config !== 'object') return [] - for (const value of [config.delivery?.repos, config['delivery.repos'], config.deliveryRepos]) { - if (value === undefined || value === null || value === '') continue - const list = Array.isArray(value) ? value : [value] - const kept = list.map(entry => String(entry ?? '').trim()).filter(entry => entry !== '') - if (kept.length > 0) return kept - } - const single = instanceRepoSetting(config) - return single === null ? [] : [single] -} - -// The `owner/name` the folder's `origin` names, read from disk without running git. -export function gitOriginRepo(folder) { - const path = gitConfigPath(folder) - if (path === null) return null - try { return parseRepoName(originUrlIn(readFileSync(path, 'utf8'))) } catch { return null } -} - -// Every repository the board reads, each with its own source (#1265 step 4). The levels answer -// once: the first level that yields a name yields the whole list, because a list from `config` -// replaces the inferred one rather than adding to it — a workspace remote and a setting are two -// answers to one question, not two repositories. Duplicates are one repository, so a name written -// twice (as a URL and as `owner/name`) cannot draw two blocks of the same board. -export function resolveRepos({ setting = null, workspace = null, packageSlug = null } = {}) { - const raw = Array.isArray(setting) ? setting : (setting === null || setting === undefined || setting === '' ? [] : [setting]) - const configured = [] - for (const value of raw) { - const name = parseRepoName(value) - if (name !== null && !configured.some(entry => entry.repo === name)) configured.push({ repo: name, source: 'config' }) - } - if (configured.length > 0) return configured - const remote = gitOriginRepo(workspace) - if (remote !== null) return [{ repo: remote, source: 'remote' }] - const packaged = parseRepoName(packageSlug) - if (packaged !== null) return [{ repo: packaged, source: 'package' }] - return [] -} - -// The first answer, for a caller that asks about one repository (`core/scripts/delivery-probe.mjs` -// used to; it now asks for the list). An empty answer is the same shape it always had. -export function resolveRepo(options = {}) { - return resolveRepos(options)[0] ?? { repo: null, source: null } -} diff --git a/core/src/host/workflow-store.js b/core/src/host/workflow-store.js index 793cd0f05..a50095b17 100644 --- a/core/src/host/workflow-store.js +++ b/core/src/host/workflow-store.js @@ -232,7 +232,7 @@ export function install(rt) { } 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). + // Presets plus the workspace files. The settings page lists and opens them; nothing here draws a board. rt.workflowDefinitions = () => { try { return store()?.definitions() ?? PRESETS.map(normaliseWorkflow) } catch { return PRESETS.map(normaliseWorkflow) } } diff --git a/core/src/shared/board-card.js b/core/src/shared/board-card.js index d97bc0200..ee9a4c88a 100644 --- a/core/src/shared/board-card.js +++ b/core/src/shared/board-card.js @@ -1,54 +1,13 @@ -// One card behind two boards (#1265, step 1). The delivery board reads GitHub, the task board -// reads FlyBot's task records; on screen they are the same object, so a card says which side it -// came from and carries a key that is unique across both. -// -// A repository card is keyed by `owner/name#number`, not by kind: one issue is one card whether the -// row arrived as an issue or as the pull request that closes it, which is what lets step 3 hang a -// FlyBot record under the issue it belongs to. A task keeps its own id under `task:`. Nothing here -// fetches, writes or reads a clock. - -export const CARD_SOURCES = ['github', 'task'] - -export function githubCardKey(repo, number) { - const name = typeof repo === 'string' ? repo.trim() : '' - const value = Number(number) - if (name === '' || !Number.isInteger(value) || value <= 0) return null - return `${name}#${value}` -} +// Task-card identity for the per-Bot board. The key is `task:`, which the client's +// BoardCard puts on `data-key`. Nothing here fetches, writes or reads a clock. export function taskCardKey(id) { const text = String(id ?? '').trim() return text === '' ? null : `task:${text}` } -const repoOf = (repo) => (typeof repo === 'string' && repo.trim() !== '' ? repo.trim() : null) - -// A delivery-board row (`cardOf` in src/host/delivery-board.js) as a card. Without a repository -// there is no cross-repository identity, so the key falls back to the row's kind and number -// instead of implying one. -export function githubCard(row, repo) { - const number = Number(row?.number) - if (!Number.isInteger(number) || number <= 0) return null - const name = repoOf(repo) - return { - source: 'github', - key: githubCardKey(name, number) ?? `github:${row.kind ?? 'item'}:${number}`, - repo: name, - number, - kind: row.kind ?? null, - title: String(row.title ?? ''), - badge: row.badge ?? null, - labels: Array.isArray(row.labels) ? row.labels.slice() : [], - owner: row.owner ?? null, - ownerKind: row.ownerKind ?? null, - linked: row.linked ?? null, - sha: row.sha ?? null, - url: row.url ?? null, - } -} - -// A task record (`src/host/tasks.js`) as a card. The record's own fields stay on the record: the -// card carries what it draws, and the view reads the rest from the task it was built from. +// A task record (`src/host/tasks.js`) as a card. The record's own fields stay on the record: +// the card carries what it draws, and the view reads the rest from the task it was built from. export function taskCard(task) { const key = taskCardKey(task?.id) if (key === null) return null @@ -56,22 +15,6 @@ export function taskCard(task) { source: 'task', key, id: String(task.id), - repo: repoOf(task.repo), - number: null, - kind: 'task', title: String(task.title ?? ''), - badge: null, - labels: [], - owner: null, - ownerKind: null, - linked: null, - sha: null, - url: null, } } - -// Two cards are the same card when they answer the same key, which is the identity step 3 hangs a -// task record on. Kept here so both sides dedupe the same way. -export function sameCard(a, b) { - return Boolean(a?.key) && a.key === b?.key -} diff --git a/core/src/shared/workflow-board.js b/core/src/shared/workflow-board.js deleted file mode 100644 index 1ae1ec10a..000000000 --- a/core/src/shared/workflow-board.js +++ /dev/null @@ -1,87 +0,0 @@ -// 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/test/board-card.test.js b/core/test/board-card.test.js index c997ffd29..d8c2ddd45 100644 --- a/core/test/board-card.test.js +++ b/core/test/board-card.test.js @@ -1,12 +1,11 @@ -// One card behind two boards (#1265, step 1): the identity a card carries, and the one component -// that draws a GitHub card and a task card. The view tests next door prove each board emits it; -// this file proves the identity rules and the component itself, without React. +// Task-card identity, and the one component that draws a task card. The view test next door +// proves the board emits it; this file proves the identity rules and the component, without React. 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' -import { CARD_SOURCES, githubCard, githubCardKey, sameCard, taskCard, taskCardKey } from '../src/shared/board-card.js' +import { taskCard, taskCardKey } from '../src/shared/board-card.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/board-card.js', import.meta.url))], @@ -24,69 +23,35 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { BoardCard, DELIVERY_SKIN, TASK_SKIN } = module.exports +const { BoardCard, TASK_SKIN } = module.exports const all = (node, out = []) => { if (Array.isArray(node)) { for (const item of node) all(item, out); return out } - if (node && typeof node === 'object') { out.push(node); all(node.children, out) } + if (node === null || typeof node !== 'object') return out + out.push(node) + all(node.children, out) return out } const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? node.children.map(textOf).join('') : '') -const row = (number, extra) => ({ number, kind: 'pr', title: `Row ${number}`, labels: ['area:ui'], owner: 'Dev4', ownerKind: 'bot', badge: 'pass-enqueue', sha: 'abc1234', url: 'https://example.invalid/1', ...extra }) - -test('a repository card is keyed by repository and number, so an issue and its pull request are one card', () => { - assert.equal(githubCardKey('o/r', 12), 'o/r#12') - assert.equal(githubCardKey(' o/r ', '12'), 'o/r#12') - assert.equal(githubCardKey('', 12), null) - assert.equal(githubCardKey('o/r', 0), null) - assert.equal(githubCardKey('o/r', 'x'), null) - const issue = githubCard(row(12, { kind: 'issue' }), 'o/r') - const pull = githubCard(row(12, { kind: 'pr' }), 'o/r') - assert.equal(issue.key, pull.key) - assert.equal(sameCard(issue, pull), true) - assert.equal(issue.source, 'github') - assert.deepEqual(CARD_SOURCES, ['github', 'task']) -}) - -test('two repositories with the same number are two cards, and a row without one says so', () => { - assert.notEqual(githubCardKey('o/a', 7), githubCardKey('o/b', 7)) - assert.equal(githubCard(row(7), 'o/a').repo, 'o/a') - // No repository: no cross-repository identity is implied, so the key names the kind instead. - assert.equal(githubCard(row(7), null).key, 'github:pr:7') - assert.equal(githubCard({ number: 0 }, 'o/r'), null) -}) - -test('a task card keeps its own id, and the two sources never collide', () => { +test('a task card keeps its own id', () => { assert.equal(taskCardKey('task-abc'), 'task:task-abc') assert.equal(taskCardKey(' '), null) assert.equal(taskCard(null), null) const card = taskCard({ id: 'task-abc', title: 'Ship it', note: 'halfway', ownerBotName: 'Dev4' }) assert.equal(card.source, 'task') assert.equal(card.key, 'task:task-abc') + assert.equal(card.id, 'task-abc') assert.equal(card.title, 'Ship it') - assert.equal(card.owner, null) - assert.equal(sameCard(card, githubCard(row(7), 'task:task-abc')), false) - assert.equal(sameCard(card, { key: 'task:task-abc' }), true) }) -test('one component draws both sources, each with the skin and identity of its board', () => { - const repository = githubCard(row(12, { linked: 13 }), 'o/r') +test('the task card draws with the task skin and its identity', () => { const task = taskCard({ id: 'task-abc', title: 'Ship it' }) - const repositoryCard = BoardCard({ card: repository, skin: DELIVERY_SKIN, idText: '#12', badge: 'PASS · waiting for enqueue' }) - const taskCardNode = BoardCard({ card: task, skin: TASK_SKIN, idText: '#abc' }) - - assert.equal(repositoryCard.props.className, 'bt-del-card') - assert.equal(repositoryCard.props['data-source'], 'github') - assert.equal(repositoryCard.props['data-key'], 'o/r#12') - assert.match(textOf(repositoryCard), /PASS · waiting for enqueue/) - assert.match(textOf(repositoryCard), /linked to/) - - assert.equal(taskCardNode.props.className, 'bt-board-card') - assert.equal(taskCardNode.props['data-source'], 'task') - assert.equal(taskCardNode.props['data-key'], 'task:task-abc') - assert.match(textOf(taskCardNode), /Ship it/) - - // The badge slot is only drawn when a board hands one in, so a task card keeps its own top row. - assert.equal(all(taskCardNode).some(node => node.props?.className === TASK_SKIN.badge), false) + const node = BoardCard({ card: task, skin: TASK_SKIN, idText: '#abc', note: 'halfway' }) + assert.equal(node.props.className, 'bt-board-card') + assert.equal(node.props['data-source'], 'task') + assert.equal(node.props['data-key'], 'task:task-abc') + assert.match(textOf(node), /Ship it/) + assert.match(textOf(node), /halfway/) + assert.equal(all(node).some(item => item.props?.className === TASK_SKIN.badge), false) }) diff --git a/core/test/delivery-board.test.js b/core/test/delivery-board.test.js deleted file mode 100644 index 929c4fa2a..000000000 --- a/core/test/delivery-board.test.js +++ /dev/null @@ -1,1112 +0,0 @@ -// The delivery board (#1173) is a read-only view of GitHub: five columns, one cached snapshot, -// metrics with an Asia/Shanghai day boundary, and no write request anywhere. -// -// #1211 added the failure half: a round answers `loading` at once and runs in the background, a -// part that cannot be read is named in `missing` instead of emptying the board, an unreadable -// number is never printed as zero, and every failure carries the endpoint, the status and the kind -// so the view can tell "no token" from "no permission" from "too slow". -import assert from 'node:assert/strict' -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 { 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' - -const NOW = Date.parse('2026-10-25T04:00:00Z') // 12:00 in Asia/Shanghai -const AT = (iso) => Date.parse(iso) - -// A response whose headers can be asked by name, the way `fetch` answers (#1211). -const respond = (body, status = 200, headerMap = {}) => ({ - ok: status < 400, - status, - headers: { get: name => headerMap[String(name).toLowerCase()] ?? (status < 400 ? 'etag-1' : null) }, - json: async () => body, -}) - -function fakeGitHub({ comments = {}, checks = {}, closedExtra = [], mergedExtra = [], openPullsExtra = [], failures = {}, hang = false, runsTruncated = false, listsCapped = false, listsStale = false, listsStopLate = false, slug = 'o/r' } = {}) { - const calls = [] - const request = async (url, init = {}) => { - const parsed = new URL(url) - const path = parsed.pathname.replace(`/repos/${slug}`, '') || '/' - const page = Number(parsed.searchParams.get('page') ?? 1) - calls.push({ path, url, method: init.method, headers: init.headers ?? {}, query: parsed.search }) - if (init.method !== 'GET') throw new Error(`the delivery board must not send ${init.method}`) - if (hang) return new Promise(() => {}) - for (const [key, failure] of Object.entries(failures)) { - if (path.startsWith(key) || key === '*') { - if (failure === 'timeout') throw Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' }) - return respond(failure.body ?? { message: 'failure' }, failure.status, failure.headers ?? {}) - } - } - if (path === '/') return respond({ full_name: 'o/r', default_branch: 'main' }) - if (path === '/commits' || path.startsWith('/commits?')) return respond([{ sha: 'aaaaaaa1' }]) - if (path === '/issues' && parsed.searchParams.get('state') === 'open') { - return respond(page === 1 ? [ - { number: 10, title: 'Untriaged', html_url: 'https://x/10', labels: [{ name: 'bug' }], created_at: '2026-10-20T00:00:00Z', updated_at: '2026-10-24T00:00:00Z' }, - { number: 11, title: 'Ready', html_url: 'https://x/11', labels: [{ name: 'agent-ready' }, { name: 'enhancement' }], created_at: '2026-10-21T00:00:00Z', updated_at: '2026-10-23T00:00:00Z' }, - { number: 12, title: 'Working', html_url: 'https://x/12', labels: [{ name: 'agent-working' }, { name: 'agent-ready' }], created_at: '2026-10-19T00:00:00Z', updated_at: '2026-10-22T00:00:00Z' }, - { number: 13, title: 'Needs a human', html_url: 'https://x/13', labels: [{ name: 'needs-info' }], created_at: '2026-09-01T00:00:00Z', updated_at: '2026-10-01T00:00:00Z' }, - ] : []) - } - // The two list calls that feed the counts (#1222), when the page cap is what ends them: - // `listsCapped` answers four full pages of rows dated inside the week the board asks for, so a - // window's walk only stops at the cap; `listsStale` answers full pages of rows older than every - // window, so the walk ends on its first page and nothing was in fact left unread. - // `listsStopLate` is the same full walk with the `stop` row on page four: four full pages, and - // the only old row is the last one, so the walk ends because the window was read whole, not - // because it ran out of pages. - if ((path === '/issues' || path === '/pulls') && parsed.searchParams.get('state') === 'all' && listsStopLate) { - return respond(Array.from({ length: 100 }, (_, index) => ({ - number: 700 + page * 100 + index, - title: 'a row on a full page', - created_at: page === 4 && index === 99 ? '2026-10-01T00:00:00Z' : '2026-10-20T00:00:00Z', - closed_at: null, - ...(path === '/pulls' ? { merged_at: page === 4 && index === 99 ? '2026-10-01T00:00:00Z' : '2026-10-20T01:00:00Z' } : {}), - }))) - } - if ((path === '/issues' || path === '/pulls') && parsed.searchParams.get('state') === 'all' && (listsCapped || listsStale)) { - const created = listsStale ? '2026-10-01T00:00:00Z' : '2026-10-20T00:00:00Z' - return respond(Array.from({ length: 100 }, (_, index) => ({ - number: 900 + page * 100 + index, - title: 'a row on a full page', - created_at: created, - closed_at: null, - ...(path === '/pulls' ? { merged_at: listsStale ? '2026-10-01T01:00:00Z' : '2026-10-20T01:00:00Z' } : {}), - }))) - } - if (path === '/pulls' && (parsed.searchParams.get('state') ?? 'open') === 'open') { - return respond(page === 1 ? [ - { number: 40, title: 'Pending verdict', html_url: 'https://x/40', body: 'Owner: Dev', head: { sha: 'aaaaaaa1' }, mergeable_state: 'unstable', created_at: '2026-10-24T00:00:00Z', updated_at: '2026-10-24T10:00:00Z', labels: [] }, - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', body: 'Fixes #12', head: { sha: 'bbbbbbb2' }, mergeable_state: 'clean', created_at: '2026-10-23T00:00:00Z', updated_at: '2026-10-24T09:00:00Z', labels: [{ name: 'agent-pr' }] }, - ...openPullsExtra, - ] : []) - } - if (path === '/pulls' && parsed.searchParams.get('state') === 'all') { - return respond(page === 1 ? [ - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z' }, - { number: 42, title: 'Older merge', html_url: 'https://x/42', created_at: '2026-10-22T00:00:00Z', merged_at: '2026-10-24T02:00:00Z', updated_at: '2026-10-24T02:00:00Z' }, - ] : []) - } - if (path === '/pulls' && parsed.searchParams.get('state') === 'closed') { - return respond(page === 1 ? [ - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z', merge_commit_sha: 'ccccccc3' }, - ...mergedExtra, - ] : []) - } - if (path === '/issues' && parsed.searchParams.get('state') === 'all') { - return respond(page === 1 ? [ - { number: 10, title: 'Untriaged', created_at: '2026-10-20T00:00:00Z', closed_at: null, state_reason: null }, - { number: 11, title: 'Ready', created_at: '2026-10-21T00:00:00Z', closed_at: null, state_reason: null }, - { number: 12, title: 'Working', created_at: '2026-10-19T00:00:00Z', closed_at: null, state_reason: null }, - { number: 41, title: 'a PR, not an issue', pull_request: { url: 'x' }, created_at: '2026-10-23T00:00:00Z' }, - { number: 14, title: 'Closed today', created_at: '2026-10-20T00:00:00Z', closed_at: '2026-10-25T01:00:00Z', state_reason: 'completed' }, - { number: 15, title: 'Closed yesterday', created_at: '2026-10-21T00:00:00Z', closed_at: '2026-10-24T01:00:00Z', state_reason: 'not_planned' }, - ] : []) - } - if (path === '/issues' && parsed.searchParams.get('state') === 'closed') { - return respond(page === 1 ? [ - { number: 14, title: 'Closed today', html_url: 'https://x/14', created_at: '2026-10-20T00:00:00Z', closed_at: '2026-10-25T01:00:00Z', state_reason: 'completed', updated_at: '2026-10-25T01:00:00Z', labels: [] }, - // A merged pull as /issues?state=closed really returns it: the merge time lives on the - // pull_request object, there is no top-level merged_at and no head (#1173). - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', pull_request: { url: 'x', html_url: 'https://x/41', diff_url: 'https://x/41.diff', patch_url: 'https://x/41.patch', merged_at: '2026-10-25T02:00:00Z' }, created_at: '2026-10-23T00:00:00Z', closed_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z', labels: [] }, - ...closedExtra, - ] : []) - } - if (path.startsWith('/actions/runs')) { - // A day whose runs outnumber the page cap: every page is full, so the tail is never read. - if (runsTruncated) { - return respond({ total_count: 1000, workflow_runs: Array.from({ length: 100 }, () => ({ conclusion: 'success' })) }) - } - return respond({ - total_count: 3, - workflow_runs: [ - { conclusion: 'failure' }, { conclusion: 'cancelled' }, { conclusion: 'success' }, - ], - }) - } - if (/^\/issues\/\d+\/comments$/.test(path)) { - const number = Number(path.split('/')[2]) - const value = comments[number] ?? [] - // A thread longer than one page is passed as pages; a flat array is page one (#1189). - const pagesOf = Array.isArray(value[0]) ? value : [value] - return respond(pagesOf[page - 1] ?? []) - } - if (/^\/commits\/.+\/check-runs$/.test(path)) { - const sha = path.split('/')[2] - return respond({ check_runs: checks[sha] ?? [] }) - } - return respond({ message: 'not found' }, 404) - } - return { request, calls } -} - -const build = (options = {}, boardOptions = {}) => { - const github = fakeGitHub(options) - const board = createDeliveryBoard({ repo: 'o/r', token: 'SECRET-TOKEN', request: github.request, now: () => NOW, ...boardOptions }) - return { board, github } -} -// The browser never waits; tests do, so one round is settled before its result is read. -const settle = (board, options = {}) => board.read({ wait: true, ...options }) - -test('the delivery board is read-only and the payload carries no credential (#1173)', async () => { - const { board, github } = build() - const snapshot = await settle(board) - assert.ok(github.calls.length > 0) - assert.deepEqual([...new Set(github.calls.map(call => call.method))], ['GET']) - for (const call of github.calls) assert.match(call.headers.Authorization ?? '', /^Bearer SECRET-TOKEN$/) - assert.doesNotMatch(JSON.stringify(snapshot), /SECRET-TOKEN|Authorization/) -}) - -test('the first read answers loading and does not wait for the round (#1211)', async () => { - const github = fakeGitHub({ hang: true }) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => NOW }) - // The request never settles; the read must still answer, and it must say what it is doing. - const first = await board.read() - assert.equal(first.state, 'loading') - assert.equal(first.ok, true) - assert.equal(first.attempt, 1) - assert.equal(typeof first.since, 'string') - assert.equal(first.elapsedMs, 0) - assert.equal(first.columns, undefined, 'a loading answer carries no half-built board') -}) - -test('a 404 is described, not reported as "no GitHub connection" (#1211)', async () => { - const { board } = build({ failures: { '/issues': { status: 404 } } }) - const result = await settle(board) - assert.equal(result.state, 'failed') - assert.equal(result.ok, true) - assert.equal(result.error.status, 404) - assert.equal(result.error.kind, 'not-found') - assert.equal(result.error.scope, 'issues') - assert.match(result.error.message, /GitHub answered 404 for \/repos\/o\/r\/issues/) - assert.equal(typeof result.error.at, 'string') -}) - -test('a timeout keeps the endpoint and the kind, and is not a 404 (#1211)', async () => { - const { board } = build({ failures: { '/pulls': 'timeout' } }) - const result = await settle(board) - assert.equal(result.state, 'failed') - assert.equal(result.error.kind, 'timeout') - assert.equal(result.error.status, null) - assert.equal(result.error.scope, 'pulls') - assert.match(result.error.message, /did not answer within 20 seconds for \/repos\/o\/r\/pulls/) -}) - -test('a 403 rate limit carries only the wait GitHub gave (#1211)', async () => { - const limited = build({ failures: { '/actions/runs': { status: 403, headers: { 'retry-after': '30' } } } }) - const result = await settle(limited.board) - assert.equal(result.state, 'partial') - assert.equal(result.error, null, 'a named part failing is a partial board, not a failed one') - const entry = result.missing.find(item => item.part === 'ci') - assert.equal(entry.kind, 'rate-limit') - assert.equal(entry.retryAfterMs, 30_000) - assert.match(entry.message, /retry after 30 s/) - - // No header, no number: the view must not invent a wait it cannot keep. - const silent = build({ failures: { '/actions/runs': { status: 403 } } }) - const quiet = await settle(silent.board) - assert.equal(quiet.missing.find(item => item.part === 'ci').kind, 'forbidden') - assert.doesNotMatch(quiet.missing.find(item => item.part === 'ci').message, /retry after/) - - const raw = describeResponse(respond({}, 403, { 'x-ratelimit-remaining': '0' }), '/repos/o/r/issues', { now: () => NOW }) - assert.equal(raw.kind, 'rate-limit') - assert.equal(raw.retryAfterMs, null) - assert.doesNotMatch(raw.message, /retry after/) -}) - -test('an unreadable /actions/runs leaves the board, and the CI number unknown not zero (#1211)', async () => { - const { board } = build({ failures: { '/actions/runs': { status: 404 } } }) - const result = await settle(board) - assert.equal(result.state, 'partial') - assert.equal(result.partial, true) - assert.equal(result.ok, true) - // The board is still there: one failed request empties nothing. - // The column keeps its cards and the two open pull requests; only CI is missing. - assert.deepEqual(result.columns['in-progress'].map(card => card.number), [12, 40, 41]) - assert.deepEqual(result.counts['in-progress'], 3) - // Unknown is null, never 0 — and the reason is named. - assert.equal(result.metrics.today.ci, null) - assert.equal(result.metrics.week.ci, null) - const entry = result.missing.find(item => item.part === 'ci') - assert.equal(entry.status, 404) - assert.equal(entry.scope, 'actions') -}) - -test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async () => { - const { board } = build({ failures: { '/commits/bbbbbbb2/check-runs': { status: 404 } } }) - const result = await settle(board) - const card = result.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'checks-unknown') - assert.equal(result.state, 'partial') - const entry = result.missing.find(item => item.part === 'checks:41') - assert.equal(entry.scope, 'checks') -}) - -test('unreadable comments cost that card its owner, not the board (#1211)', async () => { - const { board } = build({ failures: { '/issues/12/comments': { status: 404 } } }) - const result = await settle(board) - const card = result.columns['in-progress'].find(item => item.number === 12) - assert.equal(card.owner, null) - assert.equal(result.state, 'partial') - assert.ok(result.missing.some(item => item.part === 'comments:12')) -}) - -test('a failed refresh keeps the old snapshot and its reason (#1211)', async () => { - let clock = NOW - let failing = false - const github = fakeGitHub() - const broken = fakeGitHub({ failures: { '*': { status: 404 } } }) - const request = async (url, init) => (failing ? broken.request : github.request)(url, init) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => clock, ttl: 60_000 }) - const first = await settle(board) - assert.equal(first.state, 'fresh') - failing = true - clock += 120_000 - const stale = await settle(board, { force: true }) - assert.equal(stale.state, 'stale') - assert.equal(stale.error.status, 404) - assert.equal(stale.asOf, first.asOf, 'the old snapshot is still served') - assert.equal(stale.columns['in-progress'].length, 3) -}) - -test('after a failure the next round waits 10 s, then 30 s, then two minutes (#1211)', async () => { - let clock = NOW - const github = fakeGitHub({ failures: { '*': { status: 500 } } }) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => clock, ttl: 0 }) - const first = await settle(board) - assert.equal(first.state, 'failed') - assert.equal(first.retryAt, NOW + BACKOFF_MS[0]) - - const afterFirst = github.calls.length - clock += BACKOFF_MS[0] - 1 - const tooEarly = await settle(board) - assert.equal(tooEarly.state, 'failed') - assert.equal(github.calls.length, afterFirst, 'inside the backoff no request is made') - - clock += 1 - await settle(board) - const afterSecond = github.calls.length - assert.ok(afterSecond > afterFirst, 'the backoff expired, so the round ran') - clock += BACKOFF_MS[0] - await settle(board) - assert.equal(github.calls.length, afterSecond, 'the second failure backs off for 30 s, not 10') - - // A person pressing retry skips the wait on purpose. - clock += 1 - await settle(board, { force: true }) - assert.ok(github.calls.length > afterSecond) -}) - -test('a round past its budget lands as partial, not as a failure (#1211)', async () => { - let clock = NOW - const github = fakeGitHub() - const request = async (url, init) => { clock += 60_000; return github.request(url, init) } - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => clock, budgetMs: 100 }) - const result = await board.read({ wait: true }) - assert.equal(result.state, 'partial') - assert.equal(result.ok, true) - assert.equal(result.partial, true) - assert.ok(Array.isArray(result.columns['not-started'])) - assert.equal(BUILD_BUDGET_MS, 90_000) -}) - -test('a workflow day truncated at the page cap is unknown, not zero failures (#1211)', async () => { - const github = fakeGitHub() - const fullPage = { total_count: 1000, workflow_runs: Array.from({ length: 100 }, () => ({ conclusion: 'success' })) } - const request = async (url, init) => { - const parsed = new URL(url) - if (parsed.pathname.endsWith('/actions/runs')) return respond(fullPage) - return github.request(url, init) - } - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => NOW }) - const result = await board.read({ wait: true }) - assert.equal(result.metrics.today.ci.unknownDays.length, 1) - assert.equal(result.metrics.today.ci.failed, 0) - assert.equal(result.metrics.today.ci.counted, 0, 'a truncated day is not counted as if it were read') - assert.ok(result.missing.some(item => item.kind === 'truncated')) -}) - -test('the probe asks the five endpoints and says which one is refused (#1211)', async () => { - const github = fakeGitHub({ failures: { '/actions/runs': { status: 404 } } }) - const report = await probeGitHub({ repo: 'o/r', token: 'SECRET-TOKEN', request: github.request, now: () => NOW }) - assert.deepEqual(report.rows.map(row => row.key), ['repo', 'actions', 'checks', 'comments', 'pulls']) - assert.equal(report.ok, false) - const actions = report.rows.find(row => row.key === 'actions') - assert.equal(actions.status, 404) - assert.equal(actions.scope, 'actions') - assert.equal(actions.ok, false) - for (const key of ['repo', 'pulls']) assert.equal(report.rows.find(row => row.key === key).ok, true) - // The checks read is asked at a real sha taken from the pull list, never `//`. - assert.equal(report.rows.find(row => row.key === 'checks').endpoint, '/repos/o/r/commits/aaaaaaa1/check-runs') -}) - -test('the --probe CLI is a shell over probeGitHub, not a second implementation (#1211)', () => { - const source = readFileSync(new URL('../scripts/delivery-probe.mjs', import.meta.url), 'utf8') - assert.match(source, /import \{ probeGitHub \} from '\.\.\/src\/host\/delivery-board\.js'/) - assert.doesNotMatch(source, /api\.github\.com/, 'the CLI makes no request of its own') -}) - -test('three workflow columns from one snapshot, an issue and its PR linked (#1173, #1326)', async () => { - const { board } = build() - const snapshot = await settle(board) - assert.deepEqual(Object.keys(snapshot.columns), DELIVERY_COLUMNS) - // #1326: Three workflow columns. Issues map to not-started, PRs to in-progress, done-today in done. - assert.deepEqual(snapshot.columns['not-started'].map(card => card.number).sort((a, b) => a - b), [10, 11, 13]) - assert.deepEqual(snapshot.columns['in-progress'].map(card => card.number).sort((a, b) => a - b), [12, 40, 41]) - assert.deepEqual(snapshot.columns.done.filter(c => c.stage === 'done-today').map(card => card.number).sort((a, b) => a - b), [14, 41]) - const progress = snapshot.columns['in-progress'].find(c => c.number === 12) - assert.equal(progress.linked, 41) - assert.equal(progress.owner, null) - const notStarted = snapshot.columns['not-started'].find(c => c.number === 11) - assert.deepEqual(notStarted.labels, ['agent-ready', 'enhancement']) - assert.deepEqual(snapshot.counts, { 'not-started': 3, 'in-progress': 3, done: 2 }) -}) - -test('only a closing keyword links a pull request to an issue (#1189)', async () => { - const { board } = build({ - openPullsExtra: [ - { number: 43, title: 'Related work', html_url: 'https://x/43', body: 'also see #10, same as #12', head: { sha: 'ddddddd4' }, mergeable_state: 'clean', created_at: '2026-10-22T00:00:00Z', updated_at: '2026-10-24T08:00:00Z', labels: [] }, - { number: 44, title: 'Close it', html_url: 'https://x/44', body: 'Closes: #13', head: { sha: 'eeeeeee5' }, mergeable_state: 'clean', created_at: '2026-10-22T00:00:00Z', updated_at: '2026-10-24T08:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const card = (column, number) => snapshot.columns[column].find(item => item.number === number) - // "also see #10, same as #12" is prose: #10 links to nothing, and #12 keeps the pull request - // that closes it (41), not the one that only mentions it. - assert.equal(card('not-started', 10).linked, null) - assert.equal(card('in-progress', 12).linked, 41) - // A closing keyword with a colon counts; nothing else does. - assert.equal(card('not-started', 13).linked, 44) -}) - -test('a pull request closed today without a merge is done today, not nowhere (#1173)', async () => { - const { board } = build({ - closedExtra: [ - // The shape /issues?state=closed gives a closed pull: no top-level merged_at, no head. - { number: 52, title: 'Closed, never merged', html_url: 'https://x/52', pull_request: { url: 'x', html_url: 'https://x/52', diff_url: 'https://x/52.diff', patch_url: 'https://x/52.patch', merged_at: null }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-25T03:00:00Z', updated_at: '2026-10-25T03:00:00Z', labels: [] }, - { number: 53, title: 'Closed yesterday, never merged', html_url: 'https://x/53', pull_request: { url: 'x', html_url: 'https://x/53', diff_url: 'https://x/53.diff', patch_url: 'https://x/53.patch', merged_at: null }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-24T03:00:00Z', updated_at: '2026-10-24T03:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const done = snapshot.columns.done - // Acceptance 2.5: today's merges plus today's closures, and a closed pull request counts even - // though it never merged. Yesterday's closure stays off the board. - assert.equal(done.filter(card => card.number === 41).length, 1) - assert.equal(done.filter(card => card.number === 52).length, 1) - assert.equal(done.filter(card => card.number === 14).length, 1) - const closed = done.find(card => card.number === 52) - assert.equal(closed.kind, 'pr') - // /issues?state=closed returns no head for a pull, so this card carries no merge commit. - assert.equal(closed.sha, null) - assert.equal(done.some(card => card.number === 53), false) - assert.equal(snapshot.counts.done, done.length) -}) - -test('a pull merged today is done once although the closed page lists it too (#1173)', async () => { - const { board } = build({ - mergedExtra: [ - { number: 44, title: 'Merged today', html_url: 'https://x/44', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:30:00Z', updated_at: '2026-10-25T02:30:00Z', merge_commit_sha: 'fffffff6' }, - ], - // The closed page returns the same pull, and a row there can reach us without a merge time on - // its pull_request object, so reading that object alone would count it twice. A pull closed - // without merging sits on that page too and must still reach the board. - closedExtra: [ - { number: 44, title: 'Merged today', html_url: 'https://x/44', pull_request: { url: 'x' }, created_at: '2026-10-23T00:00:00Z', closed_at: '2026-10-25T02:30:00Z', updated_at: '2026-10-25T02:30:00Z', labels: [] }, - { number: 52, title: 'Closed, never merged', html_url: 'https://x/52', pull_request: { url: 'x' }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-25T03:00:00Z', updated_at: '2026-10-25T03:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const done = snapshot.columns.done - for (const number of [41, 44, 14, 52]) assert.equal(done.filter(card => card.number === number).length, 1, `#${number} is done today once`) - assert.equal(done.some(card => card.number === 15), false) - assert.equal(snapshot.counts.done, done.length) -}) - -test('a verdict with no Attestation still counts at the sha it names (#1173)', async () => { - const { board } = build({ - comments: { 41: [{ body: 'Verdict: PASS at bbbbbbb2' }] }, - checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'pass-enqueue') -}) - -test('a verdict line wins over an Attestation that names an older sha (#1173)', async () => { - // Two markers, two tips: the Attestation points at #40's sha, the Verdict line at this PR's. - // Reading Attestation first would hide a verdict that names the current tip (#1173 INC). - const { board } = build({ - comments: { 41: [{ body: 'Verdict: PASS at bbbbbbb2\nAttestation: aaaaaaa1 role=Reviewer' }] }, - checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'pass-enqueue') -}) - -test('a verdict on a later page of comments still decides the badge (#1189)', async () => { - // 120 comments with the newest verdict at index 101: on a board that reads one page it never - // appears, and the card says "no verdict at this tip" while the tip has one. - const firstPage = Array.from({ length: 100 }, (_, index) => ({ body: `note ${index}` })) - const secondPage = [ - { body: 'note 100' }, - { body: 'Verdict: INC at bbbbbbb2' }, - ...Array.from({ length: 18 }, (_, index) => ({ body: `note ${102 + index}` })), - ] - const { board, github } = build({ - comments: { 41: [firstPage, secondPage] }, - checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'inc') - assert.ok(github.calls.some(call => call.path === '/issues/41/comments' && /[?&]page=2(&|$)/.test(call.query))) -}) - -test('the token is read per request, and install loads the secrets before the round (#1173)', async () => { - // A token captured at install time is empty until the host loads secrets; every request must - // resolve it again, and the first read must ask the host to load them (#1173 INC). - const direct = fakeGitHub() - let secret - const board = createDeliveryBoard({ repo: 'o/r', token: () => secret, request: direct.request, now: () => NOW }) - await settle(board) - assert.ok(direct.calls.every(call => call.headers.Authorization === undefined)) - secret = 'SECRET-TOKEN' - await settle(board, { force: true }) - assert.ok(direct.calls.slice(-10).some(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - - const github = fakeGitHub() - let loads = 0 - let loaded - const rt = { - config: { deliveryRepo: 'o/r' }, - // The secrets file is read asynchronously, so at install time there is nothing to read yet. - secretValue: name => (name === 'GITHUB_TOKEN' ? loaded : undefined), - loadSecrets: async () => { loads += 1; loaded = 'SECRET-TOKEN' }, - fetch: github.request, - warn: () => {}, - } - const installed = Object.assign({}, rt) - install(installed) - // The first answer is loading (#1211): the round runs behind it, and the secrets were loaded - // before any request went out. - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, 'o/r') - assert.equal(loading.repoSource, 'config') - assert.equal(loads, 1) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.ok, true) - assert.ok(github.calls.length > 0) - assert.ok(github.calls.every(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - // The 诊断 entry runs the same probe, through the host's own token. - const probe = await installed.deliveryProbe() - assert.ok(Array.isArray(probe.rows)) - assert.ok(probe.rows.every(row => row.ok)) - assert.equal(probe.repo, 'o/r') - assert.equal(probe.repoSource, 'config') -}) - -test('a PR badge reads the verdict at its own tip, and CI when there is none (#1173)', async () => { - const { board } = build({ - comments: { - 40: [{ body: 'Verdict: PASS at deadbee\nAttestation: deadbee role=Reviewer2' }], - 41: [{ body: 'Verdict: PASS at bbbbbbb\nAttestation: bbbbbbb2 role=Reviewer' }], - }, - checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const badges = Object.fromEntries(snapshot.columns['in-progress'].map(card => [card.number, card.badge])) - // Verdict names an older sha: it does not count at this tip, so CI speaks. - assert.equal(badges[40], 'ci-running') - assert.equal(badges[41], 'pass-enqueue') -}) - -test('metrics use the Shanghai day boundary, medians, and the CI counted/total caveat (#1173)', async () => { - assert.equal(startOfDay(NOW), AT('2026-10-24T16:00:00Z')) - assert.equal(median([5, 1, 3]), 3) - assert.equal(median([4, 1, 3, 2]), 2.5) - const { board } = build() - const snapshot = await settle(board) - const { today, week, open } = snapshot.metrics - assert.equal(today.newIssues, 0) - assert.equal(today.closedIssues, 1) - assert.equal(today.closedReasons.completed, 1) - assert.equal(today.closedReasons.notPlanned, 0) - assert.equal(today.mergedPullRequests, 1) - assert.equal(today.medianPullMergeMs, AT('2026-10-25T02:00:00Z') - AT('2026-10-23T00:00:00Z')) - assert.deepEqual(today.ci, { failed: 1, superseded: 1, counted: 3, total: 3, unknownDays: [] }) - assert.equal(week.newIssues, 5) - assert.equal(week.closedIssues, 1) - assert.equal(week.closedReasons.notPlanned, 1) - assert.equal(open.issues, 4) - assert.equal(open.pullRequests, 2) - assert.equal(open.needsHuman, 1) - assert.equal(open.medianIssueAgeMs, median([ - NOW - AT('2026-10-20T00:00:00Z'), NOW - AT('2026-10-21T00:00:00Z'), - NOW - AT('2026-10-19T00:00:00Z'), NOW - AT('2026-09-01T00:00:00Z'), - ])) -}) - -test('metrics.daily is seven Shanghai days ending today, and only today is partial (#1222)', async () => { - const { board, github } = build() - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - // Seven days, oldest first, the last one today: the window the chart draws is the same one the - // header's Today column sits at the end of. - assert.equal(daily.length, 7) - assert.deepEqual([...daily.map(day => day.date)], - ['2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22', '2026-10-23', '2026-10-24', '2026-10-25']) - assert.equal(daily[6].partial, true) - assert.equal(daily.slice(0, 6).some(day => 'partial' in day), false, 'only today is a part of a day') - const byDate = Object.fromEntries(daily.map(day => [day.date, day])) - // A day nobody counted is 0, and 0 is a measurement: 10-22 opened and closed nothing. - assert.deepEqual({ ...byDate['2026-10-22'] }, { - date: '2026-10-22', - newIssues: 0, - closedIssues: 0, - newPullRequests: 1, - mergedPullRequests: 0, - ci: { failed: 1, counted: 2, total: 3 }, - }) - assert.equal(byDate['2026-10-19'].newIssues, 1) - assert.equal(byDate['2026-10-20'].newIssues, 2) - assert.equal(byDate['2026-10-21'].newIssues, 2) - assert.equal(byDate['2026-10-24'].closedIssues, 1) - assert.equal(byDate['2026-10-24'].mergedPullRequests, 1) - // Today is the same day the Today column counts, not a day behind it. - assert.equal(byDate['2026-10-25'].closedIssues, snapshot.metrics.today.closedIssues) - assert.equal(byDate['2026-10-25'].mergedPullRequests, snapshot.metrics.today.mergedPullRequests) - // A cancelled run never judged the code, so it is out of the denominator; the header keeps its - // own aggregate, superseded included. - assert.deepEqual(byDate['2026-10-25'].ci, { failed: 1, counted: 2, total: 3 }) - assert.deepEqual(snapshot.metrics.today.ci, { failed: 1, superseded: 1, counted: 3, total: 3, unknownDays: [] }) - // The series costs no request: it is built from the two windows the round already reads. - assert.ok(github.calls.every(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - // The assembled series is the only thing the payload carries — no per-day buckets anywhere else. - assert.equal('daily' in snapshot.metrics.today, false) - assert.equal('daily' in snapshot.metrics.week, false) -}) - -test('the day boundary is Shanghai, not UTC: an evening in UTC is already tomorrow (#1222)', async () => { - // 2026-10-24T17:00Z is 2026-10-25 01:00 in Shanghai, so the newest day is the 25th. - const { board } = build({}, { now: () => AT('2026-10-24T17:00:00Z') }) - const snapshot = await settle(board) - assert.equal(snapshot.metrics.dayStart, '2026-10-24T16:00:00.000Z') - const daily = snapshot.metrics.daily - assert.equal(daily[6].date, '2026-10-25') - assert.equal(daily[6].partial, true) - assert.equal(daily[0].date, '2026-10-19') - assert.equal(daily.length, 7) -}) - -test('the seven-day CI share is read over the week its counts cover (#1222)', async () => { - // The week window is [2026-10-17T16:00Z, 2026-10-24T16:00Z). Walking backwards from its start - // read the seven days *before* it, so the CI row described a week the other numbers never saw. - const { board, github } = build() - await settle(board) - const ranges = github.calls - .filter(call => call.path.startsWith('/actions/runs')) - .map(call => new URLSearchParams(call.query).get('created')) - const starts = ranges.map(range => Date.parse(range.split('..')[0])) - assert.equal(starts.length, 8, 'seven days for the week, one for today') - assert.ok(starts.every(at => at >= AT('2026-10-17T16:00:00Z')), 'no window reaches back before the week it describes') - assert.equal(starts.filter(at => at === AT('2026-10-17T16:00:00Z')).length, 1, 'the first day of the week is read') - assert.equal(starts.filter(at => at === AT('2026-10-23T16:00:00Z')).length, 1, 'the week runs up to the day before today') - assert.equal(starts.filter(at => at === AT('2026-10-24T16:00:00Z')).length, 1, 'today is its own window') -}) - -test('a day whose runs were truncated is unknown, and unknown is not zero (#1222)', async () => { - const { board } = build({ runsTruncated: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - // The tail of every day was unread, so no day may claim a CI share — including a quiet-looking 0%. - assert.equal(daily.every(day => day.ci === null), true) - // The counts are still numbers: a CI read that failed does not erase them. - assert.equal(daily[0].newIssues, 1) - assert.ok(snapshot.missing.some(item => item.part === 'ci')) -}) - -test('a count built from a list that stopped at the page cap is unknown, not smaller (#1222)', async () => { - // Both lists answer four full pages, so the walk ends on the cap with a tail nobody read. The - // week's window is capped; today's own window stops on its first page because these rows are - // already older than it, so today keeps its measurements and the cap reaches only the six days - // whose list it truncated. - const { board, github } = build({ listsCapped: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - for (const day of daily.slice(0, 6)) { - assert.equal(day.newIssues, null, `${day.date} sits in a list that stopped at the page cap`) - assert.equal(day.closedIssues, null, `${day.date} has no issue count to show`) - assert.equal(day.newPullRequests, null, `${day.date} has no pull count to show`) - assert.equal(day.mergedPullRequests, null) - } - // Today read its own window whole: an unknown day never costs a day that was measured. - assert.equal(typeof daily[6].newIssues, 'number') - assert.equal(typeof daily[6].closedIssues, 'number') - assert.equal(typeof daily[6].newPullRequests, 'number') - assert.equal(typeof daily[6].mergedPullRequests, 'number') - // The header counts are built from the same lists but are not per-day: the cap nulls the series - // entry, never the number a reader compares across the week. - assert.equal(typeof snapshot.metrics.today.newIssues, 'number') - assert.equal(typeof snapshot.metrics.week.newIssues, 'number') - assert.equal(daily[6].newIssues, snapshot.metrics.today.newIssues) - const issueCalls = github.calls.filter(call => call.path === '/issues' && new URLSearchParams(call.query).get('state') === 'all') - assert.equal(issueCalls.filter(call => new URLSearchParams(call.query).get('page') === '4').length >= 1, true, 'the week really walked into the page cap') -}) - -test('a window that stopped early on an old row is complete, not unknown (#1222)', async () => { - // A full first page of rows older than the window ends the walk with nothing left unread. The - // page-count heuristic reads that as "the list had more to give"; it must not, or a quiet week - // would drift into permanent unknowns. - const { board, github } = build({ listsStale: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - // Nothing was opened or merged inside the seven days, and that is a measurement: 0, never null. - for (const day of daily) { - assert.equal(day.newIssues, 0, `${day.date} was read whole`) - assert.equal(day.closedIssues, 0) - assert.equal(day.newPullRequests, 0) - assert.equal(day.mergedPullRequests, 0) - } - // One page per window, not four: the walk stopped on the old row rather than at the cap. - const issueCalls = github.calls.filter(call => call.path === '/issues' && new URLSearchParams(call.query).get('state') === 'all') - assert.equal(issueCalls.length, 2, 'week plus today, one page each') - assert.equal(issueCalls.some(call => new URLSearchParams(call.query).get('page') === '2'), false) - assert.equal(snapshot.missing.some(item => item.part === 'issues' || item.part === 'pulls'), false) -}) - -test('a full page four that stopped on an old row is a complete window, not a capped one (#1240)', async () => { - // The page count cannot tell these apart: four full pages either hit the cap or ended because the - // stop row was on page four. Only the walk knows, so the flag has to come from it. - const { board } = build({ listsStopLate: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - const byDate = Object.fromEntries(daily.map(day => [day.date, day])) - // 300 rows on pages one to three plus 99 on page four; the hundredth is the old row that stopped - // the walk. Every one of them was measured, so none of these days is unknown. - assert.equal(byDate['2026-10-20'].newIssues, 399) - assert.equal(byDate['2026-10-20'].newPullRequests, 399) - assert.equal(daily.every(day => day.newIssues !== null && day.newPullRequests !== null), true) - assert.equal(typeof snapshot.metrics.week.newIssues, 'number') -}) - -test('CI that cannot be read leaves every day at null, never at a zero share (#1222)', async () => { - const { board } = build({ failures: { '/actions/runs': { status: 403, headers: { 'retry-after': '30' } } } }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.every(day => day.ci === null), true) - assert.equal(daily[6].closedIssues, 1) -}) - -test('no week, no series: a window nobody read is null, not seven zero days (#1222)', () => { - // The series is assembled from the two windows the round read; without the week there is nothing - // to draw, and the view says so instead of drawing a flat line at zero. - assert.equal(dailySeries(null, null, AT('2026-10-24T16:00:00Z')), null) - const today = AT('2026-10-24T16:00:00Z') - const only = { daily: [{ start: today, newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, ci: null }] } - assert.equal(dailySeries(null, only, today), null, 'six days without a bucket are six unknown days') - assert.equal(dailySeries({ daily: [] }, only, today), null) -}) - -test('a snapshot is cached, a stale one is served while it refreshes, and 304 reuses it (#1173)', async () => { - let clock = NOW - const github = fakeGitHub() - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => clock, ttl: 60_000 }) - const first = await settle(board, { force: true }) - assert.equal(first.state, 'fresh') - const requestsAfterFirst = github.calls.length - clock += 30_000 - const cached = await board.read() - assert.equal(github.calls.length, requestsAfterFirst, 'a fresh cache makes no request') - assert.equal(cached.asOf, first.asOf) - // Past the TTL the old snapshot is served at once, and the refresh runs in the background. - clock += 60_000 - const stale = await board.read() - assert.equal(stale.state, 'stale') - assert.equal(stale.stale, true) - assert.equal(stale.refreshing, true) - assert.equal(stale.asOf, first.asOf) - await board.read({ wait: true }) - const conditional = github.calls.filter(call => call.headers['If-None-Match'] !== undefined) - assert.ok(conditional.length > 0, 'a refresh asks with If-None-Match') -}) - -test('the host installs no timer: nothing refreshes behind the browser (#1173)', () => { - // A host-side interval would burn a turn's worth of GitHub reads with nobody looking; the - // snapshot moves only when the browser asks. The client's own poll is tested in delivery-view. - const source = readFileSync(new URL('../src/host/delivery-board.js', import.meta.url), 'utf8') - assert.equal(/\bset(?:Interval|Timeout)\s*\(/.test(source), false) -}) - -test('CI counts ask GitHub for the day in ISO time, not epoch numbers (#1173)', async () => { - // Epoch milliseconds answer 200 with no runs, so a wrong format reads as "CI never ran". - const { board, github } = build() - await settle(board) - const runs = github.calls.filter(call => call.path.startsWith('/actions/runs')) - assert.ok(runs.length > 0) - for (const call of runs) { - const created = new URLSearchParams(call.query).get('created') - assert.match(created, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\.\.\d{4}-\d{2}-\d{2}T[\d:.]+Z$/, `created=${created}`) - } -}) - -test('the Diagnose entry is on the API route, and refused without a session (#1211)', async () => { - // #1211: the probe has to be reachable the way the button reaches it — through the channel's - // route, past authz — because calling `rt.deliveryProbe()` directly skips both and left - // `delivery-probe` out of the allow list while every test stayed green (Reviewer, 03:30Z). - let route - const ctx = { - effect: fn => { const dispose = fn(); return () => { if (typeof dispose === 'function') dispose() } }, - connection: { fetch: { register: registered => { route = registered; return () => { route = undefined } } } }, - logger: { warn: () => {} }, - sessionController: { prompt: async () => {} }, - } - const state = { - authSessions: { tok1: { memberId: 'owner-1' } }, - members: { 'owner-1': { id: 'owner-1', displayName: 'Owner' } }, - bots: {}, - rooms: {}, - } - const probeRows = [{ name: '/repos/o/r', ok: true, status: 200, hint: '' }] - const rt = { - ctx, - state, - refresh: async () => {}, - readOnly: () => undefined, - authenticate: async token => (token === 'tok1' ? { memberId: 'owner-1' } : null), - load: async () => {}, - deliveryBoard: async () => ({ ok: true, state: 'fresh', columns: [] }), - deliveryProbe: async () => ({ rows: probeRows }), - ensureCapabilities: async () => {}, - warn: () => {}, - } - installAuthz(rt) - installChannel(rt) - assert.ok(route !== undefined, 'the channel registered its /api/bot route') - - const post = async (payload, token) => { - const headers = { 'content-type': 'application/json' } - if (token) headers['x-flybot-member-session'] = token - const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify(payload) })) - return response.json() - } - - // Signed in: both reads answer, so the 诊断 button's endpoint is not "Unknown endpoint". - for (const endpoint of ['delivery-board', 'delivery-probe']) { - const answer = await post({ endpoint }, 'tok1') - assert.equal(answer.ok, true, endpoint + ' should answer for a signed-in member: ' + JSON.stringify(answer)) - } - // The channel wraps a successful answer as `{ ok: true, value }`. - const probed = await post({ endpoint: 'delivery-probe' }, 'tok1') - assert.deepEqual(probed.value.rows, probeRows) - - // No session: both are refused, not silently served. - for (const endpoint of ['delivery-board', 'delivery-probe']) { - const answer = await post({ endpoint }) - assert.equal(answer.ok, false, endpoint + ' must not answer without a session') - assert.equal(answer.error.code, 'bot/unauthorized', JSON.stringify(answer)) - } -}) - -// #1250: the host resolves which repository it reads before any board exists, says where the name -// came from, and keeps one board per repository. The resolver's own levels are in -// delivery-repo.test.js; these tests hold the host wiring around it. -const hostWith = (github, extra = {}) => { - const rt = { - config: {}, - secretValue: () => 'SECRET-TOKEN', - loadSecrets: async () => {}, - fetch: github.request, - warn: () => {}, - ...extra, - } - const installed = Object.assign({}, rt) - install(installed) - return installed -} - -test('the instance setting names the repository for the board and the probe, and says "config" (#1250)', async () => { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config: { deliveryRepo: 'acme/widget' } }) - const loading = await installed.deliveryBoard() - assert.equal(loading.repo, 'acme/widget') - assert.equal(loading.repoSource, 'config') - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'config') - // Every read, not only the repo call, went to the repository the instance named. - assert.ok(github.calls.length > 0) - for (const call of github.calls) assert.match(call.url, /^https:\/\/api\.github\.com\/repos\/acme\/widget(\/|$|\?)/, call.url) - const probe = await installed.deliveryProbe() - assert.equal(probe.repo, 'acme/widget') - assert.equal(probe.repoSource, 'config') - assert.ok(probe.rows.every(row => row.ok)) -}) - -test('a dotted or nested delivery.repo names the repository too, as a user writes it (#1258)', async () => { - // The hint says `delivery.repo`, so that is what a profile carries — as a literal dotted key or - // nested; a literal key wins nowhere, it is simply read. The flat `deliveryRepo` is the third. - for (const config of [{ 'delivery.repo': 'acme/widget' }, { delivery: { repo: 'acme/widget' } }]) { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget', JSON.stringify(config)) - assert.equal(settled.repoSource, 'config', JSON.stringify(config)) - assert.equal(settled.state, 'fresh') - for (const call of github.calls) assert.match(call.url, /^https:\/\/api\.github\.com\/repos\/acme\/widget(\/|$|\?)/, call.url) - } -}) - -test('the workspace git remote names the repository when there is no setting (#1250)', async () => { - const dir = mkdtempSync(join(tmpdir(), 'delivery-install-')) - try { - mkdirSync(join(dir, '.git')) - writeFileSync(join(dir, '.git', 'config'), '[remote "origin"]\n\turl = git@github.com:acme/widget.git\n') - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { - // The team folder is the instance's workspace, which is where the board's one repository lives. - ctx: { workspaceRegistry: { get: id => (id === 'team' ? { path: dir } : undefined) } }, - state: { workspaceId: 'team' }, - }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'remote') - assert.equal(settled.state, 'fresh') - } finally { rmSync(dir, { recursive: true, force: true }) } -}) - -test('a changed setting re-resolves, and the new repository starts at loading (#1250)', async () => { - // The cache is keyed by repository, so the previous repository's snapshot can never answer for - // the new name: the new board's first read is its own `loading`. - const one = fakeGitHub({ slug: 'o/r' }) - const two = fakeGitHub({ slug: 'acme/widget' }) - const request = (url, init) => (url.includes('/repos/acme/widget') ? two.request(url, init) : one.request(url, init)) - const config = { deliveryRepo: 'o/r' } - const installed = hostWith({ request, calls: [] }, { config }) - const before = await installed.deliveryBoard({ wait: true }) - assert.equal(before.state, 'fresh') - assert.equal(before.repo, 'o/r') - - config.deliveryRepo = 'acme/widget' - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, 'acme/widget') - assert.equal(loading.columns, undefined, 'the old repository columns must not be served for the new name') - const after = await installed.deliveryBoard({ wait: true }) - assert.equal(after.state, 'fresh') - assert.equal(after.repo, 'acme/widget') - assert.ok(two.calls.length > 0) - assert.ok(two.calls.every(call => call.path === '/' || call.path.startsWith('/repos/acme/widget') || !call.path.startsWith('/repos/'))) -}) - -test('the package repository answers only last, labelled a default, and says so in the log (#1250)', async () => { - const github = fakeGitHub({ slug: 'ThinkFlowLab/FlyBot' }) - const warnings = [] - const installed = hostWith(github, { - packageRepository: 'git+https://github.com/ThinkFlowLab/FlyBot.git', - // The host's warning log is printf-shaped; substitute so the assertion reads the message a person sees. - warn: (format, ...rest) => { let index = 0; warnings.push(String(format).replace(/%s/g, () => String(rest[index++] ?? ''))) }, - }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'ThinkFlowLab/FlyBot') - assert.equal(settled.repoSource, 'package') - // A default is acceptable only while it is visible as one, in the log as well as on the page. - assert.equal(warnings.filter(line => /package repository ThinkFlowLab\/FlyBot \(default\)/.test(line)).length, 1) - const again = await installed.deliveryBoard() - assert.equal(again.repoSource, 'package') - assert.equal(warnings.filter(line => /\(default\)/.test(line)).length, 1, 'the default is recorded once, not on every read') -}) - -test('with nothing to read, the board and the probe refuse before any request (#1250)', async () => { - let requests = 0 - const installed = hostWith({ request: async () => { requests += 1; throw new Error('must not be asked') } }, { - packageRepository: '', - }) - const answer = await installed.deliveryBoard() - assert.equal(answer.state, 'failed') - assert.equal(answer.repo, null) - assert.equal(answer.repoSource, null) - assert.equal(answer.error.kind, 'no-repo') - assert.equal(answer.error.message, 'no repository — set delivery.repo') - const probe = await installed.deliveryProbe() - assert.equal(probe.ok, false) - assert.equal(probe.repo, null) - assert.equal(probe.error, 'no repository — set delivery.repo') - // Not `/repos/null`, not a probe of nothing: the resolution refuses first. - assert.equal(requests, 0) -}) - -test('a repository the token cannot read fails naming the repository and the endpoint (#1250)', async () => { - const github = fakeGitHub({ slug: 'acme/widget', failures: { '/issues': { status: 404 } } }) - const installed = hostWith(github, { config: { deliveryRepo: 'acme/widget' } }) - const answer = await installed.deliveryBoard({ wait: true }) - assert.equal(answer.state, 'failed') - assert.equal(answer.repo, 'acme/widget') - assert.equal(answer.error.status, 404) - assert.equal(answer.error.endpoint, '/repos/acme/widget/issues') - // The same failure shape as #1211/#1232, now naming whose 404 it is. - assert.match(answer.error.message, /acme\/widget/) - assert.match(answer.error.message, /\/repos\/acme\/widget\/issues/) -}) - -// #1265 step 4: one board with more than one repository. Each name resolves with its own source, -// each repository keeps its own snapshot and cache, and one that cannot be read no longer takes the -// board with it. -test('two repositories are two blocks of one board, each with its own source (#1265)', async () => { - const one = fakeGitHub({ slug: 'acme/one' }) - const two = fakeGitHub({ slug: 'acme/two' }) - const request = (url, init) => (url.includes('/repos/acme/two') ? two.request(url, init) : one.request(url, init)) - const installed = hostWith({ request, calls: [] }, { config: { delivery: { repos: ['acme/one', 'acme/two'] } } }) - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, null, 'no single repository owns a shared board') - assert.deepEqual(loading.repos.map(entry => [entry.repo, entry.repoSource, entry.state]), - [['acme/one', 'config', 'loading'], ['acme/two', 'config', 'loading']]) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.repo, null) - assert.equal(settled.repoSource, null) - assert.deepEqual(settled.repos.map(entry => [entry.repo, entry.repoSource, entry.state]), - [['acme/one', 'config', 'fresh'], ['acme/two', 'config', 'fresh']]) - // Each repository's reads went to its own API path: one board, two sources, never one line. - assert.ok(one.calls.length > 0 && two.calls.length > 0) - assert.ok(one.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/one'))) - assert.ok(two.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/two'))) - // The union carries both repositories' cards: the same issue number in two repositories is two - // cards, and the second one does not disappear behind the first. - assert.deepEqual(settled.repos[0].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) - assert.deepEqual(settled.repos[1].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) - assert.equal(settled.columns['not-started'].length, 6, 'the same number from each repository is its own card') - // The header's numbers are the repositories' sums; a median across repositories is not a median, - // so it is unknown instead of invented. - assert.equal(settled.metrics.today.newIssues, - settled.repos[0].metrics.today.newIssues + settled.repos[1].metrics.today.newIssues) - assert.equal(settled.metrics.open.issues, - settled.repos[0].metrics.open.issues + settled.repos[1].metrics.open.issues) - assert.equal(settled.metrics.today.medianIssueCloseMs, null) - assert.equal(settled.metrics.open.medianIssueAgeMs, null) - // The probe asks each repository and names it on every row, so a status can be acted on. - const probe = await installed.deliveryProbe() - assert.equal(probe.repo, null) - assert.deepEqual(probe.repos.map(entry => entry.repo), ['acme/one', 'acme/two']) - assert.ok(probe.rows.length > 0) - assert.deepEqual([...new Set(probe.rows.map(row => row.repo))].sort(), ['acme/one', 'acme/two']) -}) - -test('one repository that cannot be read leaves the other on the board as partial (#1265)', async () => { - const one = fakeGitHub({ slug: 'acme/one' }) - const two = fakeGitHub({ slug: 'acme/two', failures: { '/issues': { status: 404 } } }) - const request = (url, init) => (url.includes('/repos/acme/two') ? two.request(url, init) : one.request(url, init)) - const installed = hostWith({ request, calls: [] }, { config: { delivery: { repos: ['acme/one', 'acme/two'] } } }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'partial', 'a failed repository is partial, not a failed board') - const good = settled.repos.find(entry => entry.repo === 'acme/one') - const bad = settled.repos.find(entry => entry.repo === 'acme/two') - assert.equal(good.state, 'fresh') - assert.ok(good.columns) - assert.equal(bad.state, 'failed') - assert.equal(bad.columns, undefined) - assert.equal(bad.error.kind, 'not-found') - // The failure names its own repository, so the page can say whose read it was. - assert.match(bad.error.endpoint, /\/repos\/acme\/two\//) - assert.equal(settled.error.kind, 'not-found') - assert.equal(settled.missing.length, 0, 'a failed round answers its error, not a partial list') - assert.equal(settled.columns, undefined, 'a partial union is not passed off as a whole one') -}) - -test('a one-repository instance answers the payload it always answered (#1265)', async () => { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config: { delivery: { repos: ['acme/widget'] } } }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'config') - assert.equal(settled.state, 'fresh') - // One entry in the list, so the view can tell one repository from many without guessing. - assert.equal(settled.repos.length, 1) - assert.deepEqual(Object.keys(settled.columns), DELIVERY_COLUMNS) -}) - -test('the aggregation rules: oldest snapshot, partial body, and no invented median (#1265)', () => { - const stamp = '2026-10-25T04:00:00Z' - const view = (repo, extra = {}) => ({ - ok: true, state: 'fresh', repo, repoSource: 'config', asOf: stamp, since: stamp, attempt: 1, elapsedMs: 10, - computedMs: 10, columns: { 'not-started': [], 'in-progress': [], done: [] }, - counts: { 'not-started': 0, 'in-progress': 0, done: 0 }, - metrics: { - dayStart: stamp, timeZone: 'Asia/Shanghai', - today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: 1, medianPullMergeMs: 2, ci: { failed: 0, total: 1, counted: 1, superseded: 0 } }, - week: null, daily: null, - open: { issues: 1, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: 3 }, - }, - ...extra, - }) - const single = combineRepos([view('acme/one')]) - assert.equal(single.state, 'fresh') - assert.equal(single.repo, 'acme/one', 'one repository answers with today\'s payload') - assert.equal(single.repos.length, 1) - - const mixed = combineRepos([ - view('acme/one', { state: 'stale', stale: true, asOf: '2026-10-25T03:00:00Z', retryAt: 5_000 }), - view('acme/two', { asOf: '2026-10-25T02:00:00Z' }), - ]) - assert.equal(mixed.state, 'stale') - assert.equal(mixed.asOf, '2026-10-25T02:00:00Z', 'the oldest snapshot, never a fresher claim') - assert.equal(mixed.metrics.today.newIssues, 2) - // Nothing that could not be read becomes a zero, and no median is invented from two medians. - assert.equal(mixed.metrics.today.medianIssueCloseMs, null) - assert.equal(mixed.metrics.week, null) - assert.equal(mixed.metrics.daily, null) - assert.equal(mixed.metrics.open.medianIssueAgeMs, null) - - const partial = combineRepos([ - view('acme/one'), - { ok: false, state: 'failed', repo: 'acme/two', repoSource: 'config', since: null, attempt: 0, elapsedMs: 0, error: { message: 'GitHub answered 404 for /repos/acme/two/issues', kind: 'not-found' }, retryAt: 10_000 }, - ]) - assert.equal(partial.state, 'partial') - assert.equal(partial.partial, true) - assert.ok(partial.repos[0].columns) - assert.equal(partial.repos[1].columns, undefined) - // Nothing readable is left out. - assert.equal(partial.columns, undefined) - assert.equal(partial.retryAt, 10_000) - // A partial board prints no total it can only half count (#1297 INC): one repository read and - // one did not, so the sums are unknown. Printing the readable half under "2 repositories" is the - // very "a number that looks like a fact" failure the null rule in this file exists to prevent. - assert.equal(partial.metrics, null) - assert.equal(partial.repos[0].metrics.today.newIssues, 1, 'the readable repository keeps its own numbers') - // While every repository reads, the sums are the board's and stay numbers, medians excepted. - assert.equal(mixed.metrics.open.issues, 2) - assert.equal(mixed.metrics.open.medianIssueAgeMs, null) - - // A part that could not be read names its repository too, so one line cannot be read as another's. - const withMissing = combineRepos([ - view('acme/one', { state: 'partial', partial: true, missing: [{ part: 'ci', message: 'GitHub answered 404' }] }), - view('acme/two'), - ]) - 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-chart.test.js b/core/test/delivery-chart.test.js deleted file mode 100644 index a16eb3dd2..000000000 --- a/core/test/delivery-chart.test.js +++ /dev/null @@ -1,249 +0,0 @@ -// The delivery trend chart (#1212): one x per day, a line that breaks at an unknown day instead of -// drawing through zero, a real zero as a filled dot on the baseline, today hollow only while it is -// still running, and no SVG at all when the snapshot carries no series. The chart is pure, so the -// geometry is asserted without a browser; the component tree is read the way delivery-view.test.js -// reads its view. -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 bundle = await build({ - entryPoints: [fileURLToPath(new URL('../src/client/delivery-chart.js', import.meta.url))], - bundle: true, format: 'cjs', platform: 'node', write: false, - external: ['react'], -}) -const module = { exports: {} } -runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, Number, Math, String, Array, Object, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type === 'function') return type({ ...(props ?? {}), children: kids }) - return { type, props: props ?? {}, children: kids } - }, - } - }, -}) -const { - CHART_WIDTH, CHART_HEIGHT, CHART_DAYS, CHART_KEYS, chartDays, chartGeometry, dayValue, - dayLabel, pointTitle, knownDays, todayPartial, DeliveryChart, DeliveryMetrics, DELIVERY_CHART_CSS, -} = module.exports - -const walk = (node, out = []) => { - if (node === null || typeof node !== 'object') return out - out.push(node) - for (const child of node.children ?? []) walk(child, out) - return out -} -const textOf = (node) => { - if (node === null || node === undefined || typeof node !== 'object') return String(node ?? '') - return [node.props?.title ?? '', ...(node.children ?? []).map(textOf)].join(' ') -} - -// Seven Asia/Shanghai days, 2026-10-20 … 2026-10-26; the last one is today and still running. The -// contract the host half is written against: { date, partial?, , ci: { failed, counted, total } }. -const daily = [ - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 3, mergedPullRequests: 0, ci: { failed: 1, total: 10, counted: 10 } }, - { date: '2026-10-21', newIssues: 4, closedIssues: 2, newPullRequests: 1, mergedPullRequests: 2, ci: { failed: 0, total: 8, counted: 8 } }, - { date: '2026-10-22', newIssues: 1, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 3, total: 9, counted: 9 } }, - { date: '2026-10-23', newIssues: 6, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 4, ci: { failed: 0, total: 0, counted: 0 } }, - { date: '2026-10-24', newIssues: 3, closedIssues: 5, newPullRequests: 4, mergedPullRequests: 2, ci: { failed: 2, total: 5, counted: 4 } }, - { date: '2026-10-25', newIssues: 0, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 3, ci: { failed: 1, total: 6, counted: 6 } }, - { date: '2026-10-26', newIssues: 5, closedIssues: 2, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, total: 4, counted: 4 }, partial: true }, -] - -test('#1212: seven days, the rightmost is today, and a longer series keeps its end', () => { - assert.equal(CHART_DAYS, 7) - const days = chartDays(daily) - assert.equal(days.length, 7) - assert.equal(dayLabel(days[6]), '2026-10-26', 'the contract carries the date itself') - assert.equal(dayLabel({ start: '2026-10-25T16:00:00.000Z' }), '2026-10-26', 'an older ISO start still labels a day') - assert.equal(chartDays(undefined), null, 'no series is null, not seven zeros') - assert.equal(chartDays([]), null) - assert.equal(chartDays([...daily, { date: '2026-10-27', newIssues: 9 }]).length, 7) - assert.equal(todayPartial(daily), true) - assert.equal(todayPartial(daily.map(day => ({ ...day, partial: undefined }))), false) -}) - -test('#1212: 168×28, one point per day, x across the box, the largest day at the top', () => { - assert.equal(CHART_WIDTH, 168) - assert.equal(CHART_HEIGHT, 28) - const geo = chartGeometry(daily, 'newIssues') - assert.equal(geo.width, CHART_WIDTH) - assert.equal(geo.height, CHART_HEIGHT) - assert.equal(geo.dots.length, 7) - const xs = geo.dots.map(dot => dot.x) - assert.deepEqual(xs, [...xs].sort((a, b) => a - b), 'x rises with the day') - assert.equal(xs[0], 3) - assert.equal(xs[6], CHART_WIDTH - 3) - assert.equal(Math.min(...geo.dots.map(dot => dot.y)), 3, 'the largest value sits on the top padding') - assert.equal(Math.max(...geo.dots.map(dot => dot.y)), CHART_HEIGHT - 3, 'a zero day sits on the bottom padding') - assert.equal(geo.segments.length, 1) - assert.equal(geo.dots[6].title, '2026-10-26 · 5') -}) - -test('#1212: an unknown day breaks the line instead of drawing through zero', () => { - const gapped = daily.map((item, i) => (i === 3 ? { ...item, newIssues: null } : item)) - const geo = chartGeometry(gapped, 'newIssues') - assert.equal(geo.dots[3].state, 'missing', 'the day is known to be missing') - assert.equal(geo.dots[3].y, null, 'and has no point') - assert.equal(geo.known, 6) - assert.equal(geo.segments.length, 2) - assert.equal(geo.segments[0].d.split('L').length, 3, 'first stretch: three days') - assert.equal(geo.segments[1].d.split('L').length, 3, 'second stretch: the three days after the gap') - assert.equal(geo.dots[3].title, '2026-10-23 · not available') - // A real zero connects and keeps a filled dot on the baseline: never the same picture as a gap. - const zeroed = daily.map((item, i) => (i === 3 ? { ...item, newIssues: 0 } : item)) - const flat = chartGeometry(zeroed, 'newIssues') - assert.equal(flat.segments.length, 1, 'a real zero still connects') - assert.equal(flat.dots[3].state, 'full') - assert.equal(flat.dots[3].y, CHART_HEIGHT - 3, 'the zero dot sits on the baseline') -}) - -test('#1212: today is hollow and dashed only while it is still running', () => { - const partial = chartGeometry(daily, 'newIssues') - assert.equal(partial.dots[6].state, 'partial') - assert.equal(partial.dots[6].hollow, true) - assert.equal(partial.segments[0].dashed, true) - assert.equal(partial.dots.filter(dot => dot.state === 'partial').length, 1, 'only today is partial') - const finished = chartGeometry(daily.map(day => ({ ...day, partial: undefined })), 'newIssues') - assert.equal(finished.dots[6].state, 'full', 'a finished day is a filled dot') - assert.equal(finished.dots[6].hollow, false) - assert.equal(finished.segments[0].dashed, false, 'and a solid line') -}) - -test('#1212: the CI metric is the red share of the runs that counted', () => { - assert.equal(dayValue(daily[0], 'ci'), 0.1) - assert.equal(dayValue(daily[4], 'ci'), 0.5, 'counted 4 of 5 runs: 2/4') - assert.equal(dayValue(daily[3], 'ci'), null, 'no runs that day is unknown, not 0%') - const geo = chartGeometry(daily, 'ci') - assert.equal(geo.dots[3].state, 'missing') - assert.equal(geo.dots[0].title, '2026-10-20 · 10%') - assert.equal(knownDays(daily, 'ci'), 6) -}) - -test('#1244: a day counted only by total still draws the share the header row reads', () => { - // `counted: 0` means the host could not say which runs were superseded, not that the day had - // nothing to count: the line draws `failed / total`, and the header row now reads the same - // denominator instead of printing '—' beside a dot. - const countedByTotal = { date: '2026-10-26', partial: true, ci: { failed: 1, counted: 0, total: 5 } } - assert.equal(dayValue(countedByTotal, 'ci'), 0.2) - const geo = chartGeometry([countedByTotal], 'ci') - assert.equal(geo.dots[0].state, 'partial', 'today is still running, and it is still drawn') - assert.equal(geo.dots[0].title, '2026-10-26 · 20%') - // Both columns empty is still unknown: neither a dot nor a share. - assert.equal(dayValue({ date: '2026-10-26', ci: { failed: 0, counted: 0, total: 0 } }, 'ci'), null) -}) - -test('#1212: the component draws a 168×28 svg with a state and a title on every point', () => { - const node = DeliveryChart({ daily, metricKey: 'newIssues', label: 'New issues' }) - assert.equal(node.type, 'svg') - assert.equal(node.props.width, 168) - assert.equal(node.props.height, 28) - assert.equal(node.props.viewBox, '0 0 168 28') - const all = walk(node) - const circles = all.filter(item => item.type === 'circle') - assert.equal(circles.length, 7) - assert.equal(circles[6].props['data-state'], 'partial') - assert.equal(circles[6].props['data-hollow'], 'true') - assert.equal(circles[6].props.r, 2.5, 'the hollow today dot is drawn larger') - assert.equal(circles[0].props.r, 2, 'a measured day keeps the small dot') - assert.equal(circles[6].props.fill, undefined, 'hollow comes from CSS, not a hard-coded colour') - assert.equal(all.find(item => item.type === 'path').props['stroke-dasharray'], '2 2') - const titles = all.filter(item => item.type === 'title').map(item => item.children.join('')) - assert.equal(titles[6], '2026-10-26 · 5') - assert.equal(titles[3], '2026-10-23 · 6') - // A missing day is not a dot at all, so no circle can stand in for it. - const gapped = walk(DeliveryChart({ daily: daily.map((item, i) => (i === 3 ? { ...item, newIssues: null } : item)), metricKey: 'newIssues' })) - const gappedCircles = gapped.filter(item => item.type === 'circle') - assert.equal(gappedCircles.length, 6) - assert.equal(gappedCircles.some(circle => circle.props['data-state'] === 'missing'), false) - assert.equal(gappedCircles.filter(circle => circle.props['data-state'] === 'full').length, 5) -}) - -test('#1212: a metric with no readable day draws nothing', () => { - assert.equal(pointTitle({ date: '2026-10-23' }, 'newIssues'), '2026-10-23 · not available') - assert.equal(DeliveryChart({ daily: daily.map(item => ({ ...item, mergedPullRequests: null })), metricKey: 'mergedPullRequests' }), null) -}) - -test('#1212: one grey line instead of an empty chart when the snapshot has no series', () => { - const rows = CHART_KEYS.map(key => ({ key, label: key, today: '4', week: '30' })) - for (const missing of [undefined, null, [], 'nope']) { - const all = walk(DeliveryMetrics({ rows, daily: missing })) - assert.equal(all.some(item => item.type === 'svg'), false, 'no svg without data') - assert.equal(all.some(item => item.type === 'circle'), false) - const empty = all.find(item => item.props?.['data-flybot'] === 'delivery-chart-empty') - assert.ok(empty, 'the empty state is drawn') - assert.match(textOf(empty), /No 7-day data yet\.|7 天数据未取到/) - assert.equal(all.filter(item => item.props?.['data-metric'] && item.type === 'div').length, 5) - } -}) - -test('#1212: five lines, Σ totals, medians with no line and no total, 900px breakpoint', () => { - const rows = [ - { key: 'newIssues', label: 'New issues', today: '5', week: '21' }, - { key: 'closedIssues', label: 'Closed issues', today: '2', week: '14' }, - { key: 'newPullRequests', label: 'New pull requests', today: '2', week: '13' }, - { key: 'mergedPullRequests', label: 'Merged pull requests', today: '1', week: '13' }, - { key: 'ci', label: 'CI red share', today: '25%', week: '18%' }, - { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }, - { key: 'medianPullMergeMs', label: 'Median PR merge', today: '3h', week: '25h' }, - ] - const all = walk(DeliveryMetrics({ rows, daily })) - const rowNodes = all.filter(item => item.props?.className === 'bt-del-dm-row') - assert.equal(rowNodes.length, 7) - assert.equal(all.filter(item => item.type === 'svg').length, 5, 'the five countable metrics carry a line') - // The two median rows keep their numbers and get no line, no placeholder cell and no Σ. - const median = rowNodes[5] - assert.equal(median.children.length, 3, 'label, today, 7 days — no chart cell at all') - assert.equal(median.children.some(child => child.props.className === 'bt-del-dm-chart'), false) - assert.equal(median.children[2].children.join(''), '2h', 'a median is not summed') - assert.equal(rowNodes[6].children[2].children.join(''), '25h') - // One primary number per row: today is bold ink, the total is muted and prefixed. - assert.equal(rowNodes[0].children[2].props.className, 'bt-del-dm-value bt-del-dm-today') - assert.equal(rowNodes[0].children[3].props.className, 'bt-del-dm-value bt-del-dm-week') - assert.equal(rowNodes[0].children[3].children.join(''), 'Σ 21') - // A day the host could not read makes the total incomplete: it wears the star. - const gapped = walk(DeliveryMetrics({ rows, daily: daily.map((item, i) => (i === 2 ? { ...item, newIssues: null } : item)) })) - const gappedRows = gapped.filter(item => item.props?.className === 'bt-del-dm-row') - assert.equal(gappedRows[0].children[3].children.join(''), 'Σ 21*') - const foot = gapped.find(item => item.props?.['data-flybot'] === 'delivery-chart-foot') - assert.ok(foot, 'the star is explained') - assert.match(textOf(foot), /\* [0-9]+ of [0-9]+ days|天中/) - // Headers: the two value columns say today and the 7-day total; today names itself as partial. - const head = all.find(item => item.props?.['data-flybot'] === 'delivery-metrics-head') - const labels = head.children.map(child => (child.children ?? []).join('')) - // The partial day is named once, on the as-of line; the shared header keeps one word. - assert.equal(labels[2], 'Today') - assert.equal(labels[3], '7 days', 'no Σ in the shared header') - assert.match(DELIVERY_CHART_CSS, /max-width:899\.98px/, 'the line moves under its row below 900px') - assert.match(DELIVERY_CHART_CSS, /\.bt-del-dm-chart\{order:5;flex:0 0 100%/, 'the line takes its own line there') - assert.match(DELIVERY_CHART_CSS, /min-height:44px/, 'the row grows to 44px there') - assert.match(DELIVERY_CHART_CSS, /flex:0 0 168px/, 'the line does not shrink') - assert.match(DELIVERY_CHART_CSS, /stroke-width:1\.5/) - assert.match(DELIVERY_CHART_CSS, /fill:var\(--bt-accent\)/) - assert.equal(/#[0-9a-fA-F]{3,8}\b/.test(DELIVERY_CHART_CSS), false, 'no hard-coded colour') -}) - -test('#1212: a row finds its series by key, not by its position', () => { - const rows = [ - { key: 'ci', label: 'CI red share', today: '25%', week: '18%' }, - { key: 'newIssues', label: 'New issues', today: '5', week: '21' }, - { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }, - ] - const all = walk(DeliveryMetrics({ rows, daily })) - const rowNodes = all.filter(item => item.props?.className === 'bt-del-dm-row') - assert.deepEqual(rowNodes.map(node => node.props['data-metric']), ['ci', 'newIssues', undefined]) - assert.equal(rowNodes[0].children.length, 4, 'the countable row keeps its line') - assert.equal(rowNodes[0].children[2].children.join(''), '25%') - // A ratio is not a sum: the CI row keeps its number plain, while a count wears the star. - assert.equal(rowNodes[0].children[3].children.join(''), '18%', 'the CI share is a ratio, never summed') - assert.equal(rowNodes[1].children[3].children.join(''), 'Σ 21', 'the count keeps its Σ') - assert.equal(rowNodes[2].children.length, 3, 'a median row stays without a line, wherever it sits') - const svgs = all.filter(item => item.type === 'svg') - assert.equal(svgs.length, 2) -}) diff --git a/core/test/delivery-repo.test.js b/core/test/delivery-repo.test.js deleted file mode 100644 index 6f6c5a904..000000000 --- a/core/test/delivery-repo.test.js +++ /dev/null @@ -1,220 +0,0 @@ -// Which repository the delivery board reads, and where that name came from (#1250): the instance -// setting, then the workspace's git remote, then the package's own repository. The host and -// `core/scripts/delivery-probe.mjs` both call this one resolver, so the two cannot drift. The knife -// for each level is one test here, so a level that silently stops being tried turns its own test red. -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { gitConfigPath, gitOriginRepo, instanceRepoSetting, instanceRepoSettings, NO_REPO_MESSAGE, originUrlIn, parseRepoName, resolveRepo, resolveRepos } from '../src/host/delivery-repo.js' - -const scratch = () => mkdtempSync(join(tmpdir(), 'delivery-repo-')) -const cleanup = dir => rmSync(dir, { recursive: true, force: true }) - -// A folder that is a git checkout with one `origin` url. -function workspaceWith(url, text = `[remote "origin"]\n\turl = ${url}\n`) { - const dir = scratch() - mkdirSync(join(dir, '.git')) - writeFileSync(join(dir, '.git', 'config'), text) - return dir -} - -test('delivery.repo in the instance setting answers first, and says so (#1250)', () => { - const dir = workspaceWith('https://github.com/remote/other.git') - try { - // The remote disagrees on purpose: an explicit setting beats anything inferred. - assert.deepEqual( - resolveRepo({ setting: 'acme/widget', workspace: dir, packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'acme/widget', source: 'config' }) - // A URL copied from the page, or a name with the `.git` left on, is the same setting. - assert.deepEqual(resolveRepo({ setting: 'https://github.com/acme/widget.git', workspace: dir, packageSlug: 'x/y' }), - { repo: 'acme/widget', source: 'config' }) - } finally { cleanup(dir) } -}) - -test('the workspace origin answers when there is no setting (#1250)', () => { - const dir = workspaceWith('https://github.com/acme/widget.git') - try { - assert.deepEqual( - resolveRepo({ workspace: dir, packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'acme/widget', source: 'remote' }) - } finally { cleanup(dir) } -}) - -test('an origin written as a URL, an scp path or a name with no .git reads the same (#1250)', () => { - for (const [url, expected] of [ - ['https://github.com/acme/widget.git', 'acme/widget'], - ['git@github.com:acme/widget.git', 'acme/widget'], - ['https://github.com/acme/widget', 'acme/widget'], - ['ssh://git@github.com/acme/widget.git', 'acme/widget'], - ]) { - const dir = workspaceWith(url) - try { - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: expected, source: 'remote' }, url) - } finally { cleanup(dir) } - } -}) - -test('a submodule keeps its config in the gitdir the `.git` file names (#1250)', () => { - // A submodule's `.git` is a pointer file and its own gitdir really does hold `config`. - const root = scratch() - try { - const store = join(root, 'super', '.git', 'modules', 'sub') - mkdirSync(store, { recursive: true }) - writeFileSync(join(store, 'config'), '[remote "origin"]\n\turl = git@github.com:acme/submodule.git\n') - const dir = join(root, 'super', 'sub') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), `gitdir: ${join('..', '.git', 'modules', 'sub')}\n`) - assert.equal(gitConfigPath(dir), join(store, 'config')) - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: 'acme/submodule', source: 'remote' }) - } finally { cleanup(root) } -}) - -test('a linked worktree follows its `commondir` to the config the shared directory holds (#1258)', () => { - // `git worktree add` writes `.git` -> `
/.git/worktrees/`, and that directory holds no - // config of its own: only `commondir`, naming the shared directory where git keeps it. Reading - // `config` beside the pointer finds nothing, so a worktree workspace fell to the package default. - const root = scratch() - try { - const shared = join(root, 'main', '.git') - mkdirSync(join(shared, 'worktrees', 'wt'), { recursive: true }) - writeFileSync(join(shared, 'config'), '[remote "origin"]\n\turl = git@github.com:acme/worktree.git\n') - writeFileSync(join(shared, 'worktrees', 'wt', 'commondir'), '../..\n') - const dir = join(root, 'checkout') - mkdirSync(dir) - writeFileSync(join(dir, '.git'), `gitdir: ${join('..', 'main', '.git', 'worktrees', 'wt')}\n`) - assert.equal(gitConfigPath(dir), join(shared, 'config')) - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: 'acme/worktree', source: 'remote' }) - // A pointer with neither a config nor a commondir answers nothing rather than a wrong path. - const bare = join(root, 'bare') - mkdirSync(join(bare, 'gitdir'), { recursive: true }) - mkdirSync(join(root, 'elsewhere')) - writeFileSync(join(root, 'elsewhere', '.git'), 'gitdir: ../bare/gitdir\n') - assert.equal(gitConfigPath(join(root, 'elsewhere')), null) - assert.deepEqual(resolveRepo({ workspace: join(root, 'elsewhere') }), { repo: null, source: null }) - } finally { cleanup(root) } -}) - -test('only `origin` counts: another remote is not a union of guesses (#1250)', () => { - const both = workspaceWith('https://github.com/acme/widget.git', - '[remote "upstream"]\n\turl = https://github.com/other/place.git\n\n[remote "origin"]\n\turl = https://github.com/acme/widget.git\n') - try { assert.deepEqual(resolveRepo({ workspace: both }), { repo: 'acme/widget', source: 'remote' }) } finally { cleanup(both) } - const upstreamOnly = workspaceWith('https://github.com/other/place.git', '[remote "upstream"]\n\turl = https://github.com/other/place.git\n') - try { - assert.deepEqual(resolveRepo({ workspace: upstreamOnly }), { repo: null, source: null }) - assert.equal(gitOriginRepo(upstreamOnly), null) - } finally { cleanup(upstreamOnly) } - // A folder that is not a git checkout at all answers null instead of throwing. - assert.equal(gitOriginRepo('/nonexistent/folder/for/this/test'), null) - assert.equal(gitOriginRepo(null), null) -}) - -test('the package repository answers last, and is labelled a default (#1250)', () => { - // A real package's `repository.url` arrives with the `git+` prefix and the `.git` suffix. - assert.deepEqual(resolveRepo({ packageSlug: 'git+https://github.com/ThinkFlowLab/FlyBot.git' }), - { repo: 'ThinkFlowLab/FlyBot', source: 'package' }) - assert.deepEqual(resolveRepo({ packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'ThinkFlowLab/FlyBot', source: 'package' }) -}) - -test('nothing answering is null, never a name invented from nothing (#1250)', () => { - assert.deepEqual(resolveRepo({}), { repo: null, source: null }) - assert.deepEqual(resolveRepo(), { repo: null, source: null }) - assert.deepEqual(resolveRepo({ setting: '', workspace: '', packageSlug: '' }), { repo: null, source: null }) - // The failure message is one constant, so the view, the host and the CLI cannot disagree on it. - assert.equal(NO_REPO_MESSAGE, 'no repository — set delivery.repo') -}) - -test('a value that is not a GitHub repository is refused, not mined for its last two segments (#1250)', () => { - for (const value of ['not a repo', 'https://gitlab.com/acme/widget.git', 'https://github.com/acme', 'acme/widget/extra', 12]) { - assert.equal(parseRepoName(value), null, String(value)) - } - assert.equal(originUrlIn('[remote "origin"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'), null) -}) - -test('a path step is not a repository name, so `.` and `..` segments are refused (#1258)', () => { - // `OWNER_NAME` allowed a dot anywhere, so `../..` answered a "repository": the board then built - // `/repos/../../issues`, which normalises to the API root. Same host, so no token left, but every - // request it made was meaningless and the "this is a repository name" invariant was gone. - for (const value of ['../..', './widget', '.a/b', 'acme/.b', 'acme/..']) { - assert.equal(parseRepoName(value), null, value) - } - // A dot inside a segment is a real repository name and stays allowed. - for (const value of ['acme/widget.js', 'a.b/c-d', 'a1-2_3/b.4']) { - assert.equal(parseRepoName(value), value, value) - } -}) - -test('the instance setting is read in all three spellings a profile can write it (#1258)', () => { - // The host passes the plugin entry through verbatim and expands no dotted key, so the hint's - // `delivery.repo` reaches the board as one of three shapes. Reading only the flat one ignored - // what a user had just written and fell through to the labelled package default. - assert.equal(instanceRepoSetting({ delivery: { repo: 'acme/widget' } }), 'acme/widget') - assert.equal(instanceRepoSetting({ 'delivery.repo': 'acme/widget' }), 'acme/widget') - assert.equal(instanceRepoSetting({ deliveryRepo: 'acme/widget' }), 'acme/widget') - // The documented spelling wins over the runtime shape when a profile happens to carry both. - assert.equal(instanceRepoSetting({ delivery: { repo: 'acme/nested' }, deliveryRepo: 'acme/flat' }), 'acme/nested') - // Nothing written, or an empty value, is "no setting", not the empty string as a repository. - assert.equal(instanceRepoSetting({}), null) - assert.equal(instanceRepoSetting({ delivery: {} }), null) - assert.equal(instanceRepoSetting({ deliveryRepo: '' }), null) - assert.equal(instanceRepoSetting(null), null) - assert.equal(instanceRepoSetting(undefined), null) - // A nested setting is a real setting end to end, not only in the helper. - assert.deepEqual(resolveRepo({ setting: instanceRepoSetting({ delivery: { repo: 'acme/widget' } }), packageSlug: 'x/y' }), - { repo: 'acme/widget', source: 'config' }) -}) - -test('the probe is a shell over this resolver, and refuses before it asks (#1250, #1265)', () => { - const source = readFileSync(new URL('../scripts/delivery-probe.mjs', import.meta.url), 'utf8') - // One resolver, imported — a second copy in the CLI is how the two drift. - assert.match(source, /import \{ NO_REPO_MESSAGE, resolveRepos \} from '\.\.\/src\/host\/delivery-repo\.js'/) - assert.match(source, /resolveRepos\(\{/) - // The same refusal the host gives, with a non-zero exit and no request before it. - assert.match(source, /if \(repos\.length === 0\) \{[\s\S]{0,240}process\.exit\(2\)/) - assert.match(source, /NO_REPO_MESSAGE/) - // Its order is --repo (repeatable), then --workspace, then the package: no third source. - assert.match(source, /setting: allOf\('--repo'\)/) - assert.match(source, /workspace: argOf\('--workspace', process\.cwd\(\)\)/) - assert.match(source, /packageSlug: repoSlug\(\)/) -}) - -test('delivery.repos is a list, and the older single spelling is a list of one (#1265)', () => { - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['acme/one', 'acme/two'] } }), ['acme/one', 'acme/two']) - assert.deepEqual(instanceRepoSettings({ 'delivery.repos': ['acme/one'] }), ['acme/one']) - assert.deepEqual(instanceRepoSettings({ deliveryRepos: ['acme/one'] }), ['acme/one']) - // Every spelling a profile written before #1265 carries still names one repository. - assert.deepEqual(instanceRepoSettings({ delivery: { repo: 'acme/widget' } }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({ 'delivery.repo': 'acme/widget' }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({ deliveryRepo: 'acme/widget' }), ['acme/widget']) - // The list is the documented spelling, so it wins when a profile carries both. - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['acme/one'], repo: 'z/z' } }), ['acme/one']) - // An empty list or a blank entry is "nothing written": the next level still answers. - assert.deepEqual(instanceRepoSettings({ delivery: { repos: [] } }), []) - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['', ' '] } }), []) - assert.deepEqual(instanceRepoSettings({ delivery: { repos: [' '] }, deliveryRepo: 'acme/widget' }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({}), []) - assert.deepEqual(instanceRepoSettings(null), []) -}) - -test('every configured repository keeps its own source, and a repeat is one repository (#1265)', () => { - const dir = workspaceWith('https://github.com/remote/other.git') - try { - assert.deepEqual(resolveRepos({ setting: ['acme/one', 'acme/two'], workspace: dir, packageSlug: 'x/y' }), - [{ repo: 'acme/one', source: 'config' }, { repo: 'acme/two', source: 'config' }]) - // The same name written twice — as a URL and as `owner/name` — is one repository, not two boards - // of the same thing. - assert.deepEqual(resolveRepos({ setting: ['acme/one', 'https://github.com/acme/one.git'] }), - [{ repo: 'acme/one', source: 'config' }]) - // A refused entry does not take the good one with it, and a list with nothing valid falls through - // as a whole: the levels answer once, they are not unioned. - assert.deepEqual(resolveRepos({ setting: ['not a repo', 'acme/one'] }), [{ repo: 'acme/one', source: 'config' }]) - assert.deepEqual(resolveRepos({ setting: ['not a repo'], workspace: dir }), [{ repo: 'remote/other', source: 'remote' }]) - assert.deepEqual(resolveRepos({ setting: ['not a repo'], workspace: '/nonexistent/folder', packageSlug: 'x/y' }), - [{ repo: 'x/y', source: 'package' }]) - assert.deepEqual(resolveRepos({}), []) - // The single-answer call the probe used to make still answers the first of the list. - assert.deepEqual(resolveRepo({ setting: ['acme/one', 'acme/two'] }), { repo: 'acme/one', source: 'config' }) - } finally { cleanup(dir) } -}) diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js deleted file mode 100644 index 6926a01ab..000000000 --- a/core/test/delivery-view.test.js +++ /dev/null @@ -1,862 +0,0 @@ -// The delivery board view (#1173): five columns from one snapshot, a read-only card, and the -// header numbers with the CI counted caveat. #1211 added the four states: loading with a counter, -// failed with a reason and a retry, stale with the old board and the reason beside it, partial with -// the part that was not read named. #1227 made the countdown move and the retry hints name their -// condition. The host module is tested in delivery-board.test.js. -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { runInNewContext } from 'node:vm' -import { build } from 'esbuild' - -// A clock the new tests can move. `wall.t === null` means "use the real one", which is how every -// test written before #1227 keeps reading real time. -const wall = { t: null } -class FakeDate extends Date { - static now() { return wall.t ?? Date.now() } -} - -// The timers the component may start. Nothing but a component under test asks for one, and only the -// new countdown test moves them. -const timers = { - seq: 0, pending: new Map(), - setInterval(fn, ms) { const id = ++timers.seq; timers.pending.set(id, { fn, ms }); return id }, - clearInterval(id) { timers.pending.delete(id) }, - tick(ms) { wall.t = (wall.t ?? Date.now()) + ms; for (const timer of [...timers.pending.values()]) timer.fn() }, -} - -// A tiny React with real hook slots, so a re-render can be observed (#1227). A component called -// directly, as the older tests do, still gets the old no-op setter: nothing changes for them. -const live = { frame: null, frames: new Map(), frameIndex: 0, effects: [], dirty: false } -const reactStub = { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type !== 'function') return { type, props: props ?? {}, children: kids } - const key = live.frameIndex++ - let frame = live.frames.get(key) - if (!frame) { frame = { hooks: [], cleanups: [] }; live.frames.set(key, frame) } - const outer = live.frame - live.frame = frame - frame.i = 0 - try { return type({ ...(props ?? {}), children: kids }) } finally { live.frame = outer } - }, - useState(initial) { - const frame = live.frame - const value = typeof initial === 'function' ? initial() : initial - if (!frame) return [value, () => {}] - const index = frame.i++ - if (!(index in frame.hooks)) frame.hooks[index] = { value } - const slot = frame.hooks[index] - return [slot.value, next => { slot.value = typeof next === 'function' ? next(slot.value) : next; live.dirty = true }] - }, - useEffect(fn, deps) { - const frame = live.frame - if (!frame) return - const index = frame.i++ - const previous = frame.hooks[index] - const same = previous && deps && previous.deps && deps.length === previous.deps.length - && deps.every((dep, at) => Object.is(dep, previous.deps[at])) - if (same) return - frame.hooks[index] = { deps } - live.effects.push(() => { - if (typeof frame.cleanups[index] === 'function') frame.cleanups[index]() - const cleanup = fn() - frame.cleanups[index] = typeof cleanup === 'function' ? cleanup : null - }) - }, -} - -const bundle = await build({ - entryPoints: [fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url))], - bundle: true, format: 'cjs', platform: 'node', write: false, - external: ['react'], -}) -const module = { exports: {} } -runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, - setInterval: timers.setInterval, clearInterval: timers.clearInterval, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return reactStub - }, -}) - -// Mount a component under that little React: render, run the effects, and render again while a -// setter fired during the pass marked the tree dirty. -const mount = (Component, props) => { - live.frames = new Map() - const draw = () => { - let out = null - for (let pass = 0; pass < 5; pass += 1) { - live.dirty = false - live.frameIndex = 0 - live.effects = [] - out = reactStub.createElement(Component, props) - for (const effect of live.effects) effect() - if (!live.dirty) break - } - return out - } - draw() - return { draw, rerender: draw } -} -const { - createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, - loadingText, staleText, hintFor, missingText, probeText, stagesOf, -} = module.exports - -const text = (node) => { - if (node === null || node === undefined || typeof node !== 'object') return String(node ?? '') - return [node.props?.title ?? '', ...(node.children ?? []).map(text)].join(' ') -} -const walk = (node, out = []) => { - if (node === null || typeof node !== 'object') return out - out.push(node) - for (const child of node.children ?? []) walk(child, out) - return out -} -const buttons = (node) => walk(node).filter(item => item.type === 'button') - -const snapshot = { - asOf: '2026-10-25T04:00:00Z', - columns: { - raw: [{ kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }], - ready: [{ kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }], - working: [{ kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }], - review: [{ kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1' }], - 'done-today': [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40' }], - }, - metrics: { - dayStart: '2026-10-24T16:00:00.000Z', - today: { - newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, - medianIssueCloseMs: 3_600_000, medianPullMergeMs: 90_000_000, - ci: { failed: 2, total: 1200, counted: 1000, superseded: 3 }, - }, - week: { - newIssues: 30, closedIssues: 20, newPullRequests: 40, mergedPullRequests: 25, - medianIssueCloseMs: 7_200_000, medianPullMergeMs: 180_000_000, - ci: { failed: 10, total: 10, counted: 10, superseded: 0 }, - }, - open: { issues: 54, pullRequests: 6, needsHuman: 4, medianIssueAgeMs: 259_200_000 }, - }, -} - -// #1326: the host draws the definition's three lanes; `review` and `done-today` are stages a card -// carries, not lanes of their own. -const threeLane = { - ...snapshot, - columns: { - 'not-started': [ - { kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }, - { kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }, - ], - 'in-progress': [ - { kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }, - { kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1', stage: 'review' }, - ], - done: [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40', stage: 'done-today' }], - }, -} - -test('three delivery columns, localized labels, badge wording (#1173, #1326)', () => { - assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['not-started', 'in-progress', 'done']) - assert.equal(badgeLabel('inc'), 'INC · rework') - assert.equal(badgeLabel('pass-enqueue'), 'PASS · waiting for enqueue') - assert.equal(badgeLabel('pass-merge'), 'PASS · waiting for merge') - assert.equal(badgeLabel('ci-red'), 'CI red') - assert.equal(badgeLabel('no-verdict'), 'No verdict') - // Unreadable checks are their own badge, not "no verdict" (#1211). - assert.equal(badgeLabel('checks-unknown'), 'Checks unknown') - assert.equal(duration(45_000), '1m') - assert.equal(duration(3_600_000), '1h') - assert.equal(duration(90_000_000), '25h') - assert.equal(duration(200_000_000), '2d') - assert.equal(duration(null), '—') -}) - -test('the header carries today, the 7-day comparison, the CI caveat and open ages (#1173)', () => { - const rows = metricRows(snapshot.metrics) - // #1212 adds the CI red share as a seventh row: it draws a line like the counts, but as a ratio - // it never carries a Σ total. - assert.equal(rows.rows.length, 7) - // #1212: every row carries the metric key the chart looks its series up by, so the expectation - // names all six — the shape change is deliberate, not something to hide from the assertion. - assert.deepEqual({ ...rows.rows[0] }, { key: 'newIssues', label: 'New issues', today: '4', week: '30' }) - assert.deepEqual({ ...rows.rows[4] }, { key: 'ci', label: 'CI red share', today: '0%', week: '100%' }) - assert.deepEqual({ ...rows.rows[5] }, { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }) - assert.deepEqual([...rows.rows.map(row => row.key)], - ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci', 'medianIssueCloseMs', 'medianPullMergeMs']) - assert.equal(rows.ci.failed, 2) - assert.equal(rows.ci.total, 1200) - assert.equal(rows.ci.caveat, true) - assert.equal(rows.ci.superseded, 3) - assert.equal(rows.open.needsHuman, 4) -}) - -test('the CI red share reaches the page as a line, and the medians draw none (#1212)', () => { - // A key the table has no row for would draw no line: the CI share has to be a row, not a key only - // the chart knows about. - const daily = [ - { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, - { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, - { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, - { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, - { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, - { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, - ] - const board = DeliveryBoard({ snapshot: { ...snapshot, metrics: { ...snapshot.metrics, daily } }, message: '' }) - const charts = walk(board).filter(node => node.props?.className === 'bt-del-chart') - assert.deepEqual([...charts.map(node => node.props['data-metric'])], - ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci']) - const rows = walk(board).filter(node => node.props?.className === 'bt-del-dm-row') - const ciRow = rows.find(node => node.props['data-metric'] === 'ci') - assert.ok(ciRow, 'the CI share is a row of the table, not only a chart key') - // A share of the week, never a sum. - assert.match(text(ciRow), /100%/) - assert.equal(text(ciRow).includes('Σ'), false) - // Medians cannot be added up and get no chart at all. - for (const key of ['medianIssueCloseMs', 'medianPullMergeMs']) { - const row = rows.find(node => node.props['data-metric'] === key) - assert.equal(walk(row).some(node => node.props?.className === 'bt-del-chart'), false) - } -}) - -test('a day the host could only count by total reads the same share in the row as on the line (#1244)', () => { - // The header row required `counted`, so a day with `counted: 0, total: 5` printed '—' while the - // 7-day line beside it drew a dot from `total`: one day, two answers. The row now uses the same - // denominator the chart draws. - const countedByTotal = { date: '2026-10-25', partial: true, newIssues: 3, closedIssues: 1, newPullRequests: 0, mergedPullRequests: 0, ci: { failed: 1, counted: 0, total: 5 } } - const metrics = { - ...snapshot.metrics, - today: { ...snapshot.metrics.today, ci: countedByTotal.ci }, - daily: [countedByTotal], - } - const rows = metricRows(metrics) - assert.equal(rows.rows[4].today, '20%', 'one red run of five is 20%, not unknown') - // The denominator the chart uses for that same day (delivery-chart.test.js pins the number). - assert.equal(countedByTotal.ci.failed / countedByTotal.ci.total, 0.2) - // A window with no runs at all still has no share: unknown is not 0%, and it is not a dot either. - assert.equal(metricRows({ ...metrics, today: { ...snapshot.metrics.today, ci: { failed: 0, counted: 0, total: 0 } } }).rows[4].today, '—') -}) - -test('a partial day prints its time once, in the task zone, instead of twice (#1244)', async () => { - // #1244: the paragraph printed `as of {timeOf}` and then appended a hint carrying its own - // `clockOf` of the same instant — the time twice, and from two different renderings. The hint now - // follows the single time, and that time is the hint's own zone rendering. - const daily = [ - { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, - { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, - { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, - { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, - { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, - { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, - ] - const snapshotWith = dailySeries => ({ ...snapshot, metrics: { ...snapshot.metrics, daily: dailySeries } }) - const asOfText = (Component, input) => { - const paragraph = walk(Component({ snapshot: input })).find(node => node.props?.className === 'bt-del-asof') - assert.ok(paragraph, 'the header carries an as-of line') - return text(paragraph).replace(/\s+/g, ' ').trim() - } - const times = words => [...words.matchAll(/\d{1,2}:\d{2}/g)].length - - const english = asOfText(DeliveryHeader, snapshotWith(daily)) - assert.equal(times(english), 1, `one time string, got: ${english}`) - assert.match(english, /today is partial/) - // The same paragraph in Chinese, through the same bundled module: the duplicate 截至 is gone too. - const { DeliveryHeader: DeliveryHeaderZh, installLocale } = await build({ - stdin: { - contents: `export { DeliveryHeader } from ${JSON.stringify(fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url)))}\nexport { installLocale } from ${JSON.stringify(fileURLToPath(new URL('../src/client/i18n.js', import.meta.url)))}`, - resolveDir: fileURLToPath(new URL('../src/client/', import.meta.url)), - loader: 'js', - }, - bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react'], - }).then(async out => { - const module = { exports: {} } - runInNewContext(out.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, - setInterval: timers.setInterval, clearInterval: timers.clearInterval, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return reactStub - }, - }) - return module.exports - }) - const dictionaries = new Map() - const off = installLocale({ - register: (namespace, locale, dict) => { dictionaries.set(`${namespace}:${locale}`, dict); return () => dictionaries.delete(`${namespace}:${locale}`) }, - bind: namespace => (key, params) => { - const words = dictionaries.get(`${namespace}:zh`)?.[key] ?? key - return params ? words.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : words - }, - getLocale: () => ({ active: 'zh' }), - }) - try { - const chinese = asOfText(DeliveryHeaderZh, snapshotWith(daily)) - assert.equal(times(chinese), 1, `one time string, got: ${chinese}`) - assert.match(chinese, /今天为部分日/) - assert.equal((chinese.match(/截至/g) ?? []).length, 1, 'the hint no longer repeats 截至') - } finally { - off() - } - // A finished day keeps the local-clock rendering and gains no hint at all. - const finished = asOfText(DeliveryHeader, snapshotWith(daily.map(day => ({ ...day, partial: false })))) - assert.equal(times(finished), 1) - assert.equal(finished.includes('today is partial'), false) -}) - -test('the board renders all five columns and a card with labels, owner and link (#1173)', () => { - const board = DeliveryBoard({ snapshot: { ...snapshot, repo: 'o/r', columns: snapshot.columns }, message: '' }) - const sections = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.deepEqual([...sections.map(node => node.props['data-column'])], ['raw', 'ready', 'working', 'review', 'done-today']) - const review = sections.find(node => node.props['data-column'] === 'review') - assert.equal(review.children[0].children[1].children[0], '1') - const card = walk(review).find(node => node.props?.['data-badge'] === 'inc') - // The card names its source and its repository-scoped identity (#1265 step 1): one issue is one - // card, keyed `owner/name#number`, whichever row of the snapshot it arrived in. - assert.equal(card.props['data-source'], 'github') - assert.equal(card.props['data-key'], 'o/r#41') - const words = text(card) - assert.match(words, /Fix/) - assert.match(words, /INC · rework/) - assert.match(words, /abcdef1/) - const workingCard = walk(sections.find(node => node.props['data-column'] === 'working')).find(node => node.type === 'article') - assert.match(text(workingCard), /linked to #41/) - assert.ok(walk(workingCard).some(node => node.type === 'a' && node.props.href === 'https://x/12')) - // A person is rendered as a person, not as a Bot. - const owner = walk(workingCard).concat(walk(sections.find(node => node.props['data-column'] === 'ready'))).find(node => node.props?.className === 'bt-del-owner') - assert.equal(owner.props['data-kind'], 'human') -}) - -test('an honest message replaces the board when there is no GitHub connection (#1173)', () => { - const board = DeliveryBoard({ snapshot: null, message: 'No GitHub connection yet; the board fills in once one is added.' }) - assert.match(text(board), /No GitHub connection yet/) - assert.equal(walk(board).some(node => node.type === 'section'), false) -}) - -test('"no snapshot yet" belongs to a host that has never tried (#1211)', () => { - const idle = DeliveryBoard({ view: { state: 'idle' } }) - assert.match(text(idle), /No snapshot yet\./) - assert.equal(idle.props['data-state'], 'idle') - // Every other state has its own words; none of them says "no snapshot". - for (const state of ['loading', 'failed', 'stale', 'partial']) { - const view = state === 'loading' ? { state, elapsedMs: 12_000 } - : state === 'failed' ? { state, error: { message: 'GitHub answered 404 for /repos/o/r/issues', status: 404, kind: 'not-found', scope: 'issues' } } - : state === 'stale' ? { ...snapshot, state, error: { message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' } } - : { ...snapshot, state, missing: [{ part: 'ci', message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' }] } - assert.doesNotMatch(text(DeliveryBoard({ view })), /No snapshot yet/, `${state} must not reuse it`) - } -}) - -test('loading counts the seconds and promises the columns (#1211)', () => { - const board = DeliveryBoard({ view: { state: 'loading', elapsedMs: 12_000 } }) - assert.equal(loadingText(12_000), 'First load in progress, 12 s elapsed') - assert.match(text(board), /First load in progress, 12 s elapsed/) - assert.equal(walk(board).some(node => node.type === 'section'), false, 'no half-built columns') - assert.equal(pollDelay('loading'), 2_000) - assert.equal(pollDelay('idle'), 2_000) - // A failed read is scheduled by the host's `retryAt`, not by a fixed minute (#1211): the old - // assertion here pinned 60_000 for `failed`, which is what made the 10 s / 30 s levels dead code. - const now = Date.parse('2026-10-25T04:00:00Z') - assert.equal(pollDelay('failed', { retryAt: now + 10_000 }, now), 10_000) - assert.equal(pollDelay('failed', { retryAt: now + 120_000 }, now), 120_000, 'the 2 min level is not flattened to a minute') - assert.equal(pollDelay('stale', { retryAt: now + 30_000 }, now), 30_000, 'a stale board waits for the same retry') - assert.equal(pollDelay('failed', { retryAt: now - 5_000 }, now), 1_000, 'a past retryAt does not spin') - for (const state of ['fresh', 'stale', 'partial', 'failed']) assert.equal(pollDelay(state, { retryAt: null }, now), 60_000, `${state} polls slowly`) -}) - -test('a failed round names the endpoint, the advice and a retry button (#1211)', () => { - const error = { message: 'GitHub answered 404 for /repos/o/r/actions/runs', endpoint: '/repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions', at: '2026-10-25T04:00:00Z' } - let retried = 0 - const board = DeliveryBoard({ view: { state: 'failed', error }, repo: 'o/r', onRetry: () => { retried += 1 } }) - const words = text(board) - assert.match(words, /GitHub answered 404 for \/repos\/o\/r\/actions\/runs/) - assert.match(words, /Give the token Actions: Read\./) - const retry = buttons(board).find(button => text(button).trim() === 'Retry') - assert.ok(retry, 'there is a retry button') - retry.props.onClick() - assert.equal(retried, 1) - assert.equal(board.props['data-state'], 'failed') -}) - -test('the five failures do not borrow each other\'s words (#1211)', () => { - const hint = (error) => hintFor(error, { repo: 'o/r' }) - assert.match(hint({ kind: 'no-token' }), /GITHUB_TOKEN/) - assert.match(hint({ kind: 'no-token' }), /o\/r/) - assert.match(hint({ kind: 'not-found', scope: 'repo' }), /Check the repository name, and that the token can see o\/r/) - assert.match(hint({ kind: 'not-found', scope: 'actions' }), /Actions: Read/) - assert.match(hint({ kind: 'not-found', scope: 'checks' }), /Checks: Read/) - assert.match(hint({ kind: 'not-found', scope: 'comments' }), /Issues: Read/) - assert.match(hint({ kind: 'timeout' }), /did not answer in time/) - // A wait is printed only when GitHub gave one: no invented "60 seconds". - assert.match(hint({ kind: 'rate-limit', retryAfterMs: null }), /10 s → 30 s → 2 min/) - assert.doesNotMatch(hint({ kind: 'rate-limit', retryAfterMs: null }), /\b60\b/) - assert.match(hint({ kind: 'rate-limit', retryAfterMs: 30_000 }), /in 30 s/) -}) - -test('a stale board keeps the columns and hangs the reason beside them (#1211)', () => { - const error = { message: 'GitHub answered 404 for /repos/o/r/actions/runs', endpoint: '/repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' } - const view = { ...snapshot, state: 'stale', error, stale: true } - const board = DeliveryBoard({ view, repo: 'o/r', onRetry: () => {} }) - assert.equal(walk(board).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5, 'the old board is still rendered') - const words = text(board) - assert.match(words, /Showing the snapshot from/) - assert.match(words, /refresh failed: GitHub answered 404 for \/repos\/o\/r\/actions\/runs/) - assert.match(staleText('2026-10-25T04:00:00Z', error), /Showing the snapshot from /) -}) - -test('a partial board names the part that was not read, and CI says unknown not zero (#1211)', () => { - const missing = [{ part: 'ci', message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' }] - const metrics = { ...snapshot.metrics, today: { ...snapshot.metrics.today, ci: null } } - const view = { ...snapshot, metrics, state: 'partial', missing, partial: true } - const board = DeliveryBoard({ view }) - const words = text(board) - assert.match(words, /CI data was not fetched this time/) - assert.match(words, /CI not available this time/) - assert.doesNotMatch(words, /CI failed 0 of 0/) - assert.equal(walk(board).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - // The rows whose numbers were read still show; the CI one is the only unknown. - const rows = metricRows(metrics) - assert.equal(rows.ci, null) - assert.equal(rows.rows[0].today, '4') - assert.match(missingText(missing[0]), /\/repos\/o\/r\/actions\/runs/) -}) - -test('the diagnostic report prints the five lines the CLI prints (#1211)', () => { - const report = { - repo: 'o/r', at: '2026-10-25T04:00:00Z', - rows: [ - { key: 'repo', ok: true, status: 200, endpoint: '/repos/o/r' }, - { key: 'actions', ok: false, status: 404, endpoint: '/repos/o/r/actions/runs', kind: 'not-found' }, - ], - } - const output = probeText(report) - assert.match(output, /ok 200 {2}\/repos\/o\/r/) - assert.match(output, /FAIL 404 {2}\/repos\/o\/r\/actions\/runs {2}not-found/) -}) - -// A fake clock and a fake timer queue: the cadence is read off the timers the poller asks for, not -// off the wall clock, so the test is about the schedule and nothing else (#1211). -function clock(start = Date.parse('2026-10-25T04:00:00Z')) { - let at = start - let seq = 0 - const timers = new Map() - return { - now: () => at, - setTimer: (fn, ms) => { const id = ++seq; timers.set(id, { at: at + ms, fn }); return id }, - clearTimer: id => { timers.delete(id) }, - pending: () => [...timers.values()].map(timer => timer.at - at), - // Advance to the earliest due timer, run it, and report how far time moved. - advance: async () => { - const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0] - if (!next) return null - const [id, timer] = next - timers.delete(id) - at = Math.max(at, timer.at) - timer.fn() - await new Promise(resolve => setImmediate(resolve)) - return at - start - }, - } -} - -test('a failed round retries on the host backoff, not on a fixed minute (#1211)', async () => { - const c = clock() - // A host that refuses for three rounds — 10 s, then 30 s, then two minutes — and counts the - // rounds it actually started. The client may read more often; only the rounds matter. - const backoff = [10_000, 30_000, 120_000] - let rounds = 0 - let nextAt = 0 - const started = [] - const load = async () => { - const now = c.now() - if (now < nextAt) return { state: 'failed', error: { message: 'still cooling down', kind: 'network', endpoint: null, status: null, at: null }, retryAt: nextAt } - started.push(now - Date.parse('2026-10-25T04:00:00Z')) - nextAt = now + (backoff[Math.min(rounds, backoff.length - 1)]) - rounds += 1 - return { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: nextAt } - } - const views = [] - const poller = createPoller({ load, onView: view => views.push(view.state), now: c.now, setTimer: c.setTimer, clearTimer: c.clearTimer }) - poller.start() - await new Promise(resolve => setImmediate(resolve)) - // Three advances is exactly the three backoff levels: 10 s, 30 s, then the two-minute one. - for (let i = 0; i < 3; i += 1) await c.advance() - - // The rounds the host really started: now, 10 s, 40 s (30 s later), 160 s (2 min later). - assert.deepEqual(started, [0, 10_000, 40_000, 160_000], 'each backoff level produces its own request') - assert.equal(rounds, 4) - // The screen was told about every read, and a failed read always renders as failed, never as fresh. - assert.ok(views.length >= 4) - assert.ok(views.every(state => state === 'failed')) - poller.stop() -}) - -test('Retry paints loading at once and forces the read (#1211)', async () => { - const c = clock() - const calls = [] - let release = null - const load = async (force) => { - calls.push(force) - if (!force) return { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: c.now() + 120_000 } - await new Promise(resolve => { release = resolve }) - return { state: 'fresh', columns: {}, metrics: null } - } - const views = [] - const poller = createPoller({ load, onView: view => views.push(view.state), now: c.now, setTimer: c.setTimer, clearTimer: c.clearTimer }) - poller.start() - await new Promise(resolve => setImmediate(resolve)) - assert.deepEqual(views, ['failed']) - assert.deepEqual(calls, [false]) - - poller.retry({ state: 'failed', error: { message: 'GitHub answered 503' } }) - // The screen changes before the request comes back: that is what the button looked dead without. - assert.equal(views.at(-1), 'loading') - assert.deepEqual(calls, [false, true]) - release() - await new Promise(resolve => setImmediate(resolve)) - assert.equal(views.at(-1), 'fresh') - poller.stop() -}) - -test('the countdown is the host retryAt, and it says so when there is none (#1211)', () => { - const now = Date.parse('2026-10-25T04:00:00Z') - assert.equal(retryCountdown(now + 12_400, now), 12) - assert.equal(retryCountdown(now - 1_000, now), 0) - assert.equal(retryCountdown(null, now), null) - // The component counts from the real clock, so the fixture's retryAt is relative to it too. - const board = DeliveryBoard({ - view: { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: Date.now() + 30_000 }, - onRetry: () => {}, - }) - assert.match(text(board), /Retrying in 30 s/) - const withoutAt = DeliveryBoard({ view: { state: 'failed', error: { message: 'offline', kind: 'network' } }, onRetry: () => {} }) - assert.match(text(withoutAt), /retries on its own/) - assert.doesNotMatch(text(withoutAt), /Retrying in/) -}) - -test('the failure countdown moves every second, and only while a retry is scheduled (#1227)', () => { - wall.t = Date.parse('2026-10-25T04:00:00Z') - timers.pending.clear() - const failed = { - state: 'failed', - error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, - retryAt: wall.t + 120_000, - } - const board = mount(DeliveryBoard, { view: failed, onRetry: () => {} }) - assert.match(text(board.draw()), /Retrying in 120 s/, 'the host gave two minutes, so the screen says 120') - timers.tick(1_000) - assert.match(text(board.draw()), /Retrying in 119 s/, 'the number moves when the second passes') - // One timer, one second: the countdown is a promise about the screen, not about the request. - assert.equal(timers.pending.size, 1, 'exactly one timer while a retry is scheduled') - assert.equal([...timers.pending.values()][0].ms, 1_000, 'the countdown timer is a one-second one') - - timers.tick(118_000) - assert.match(text(board.draw()), /Retrying in 1 s/, 'and it keeps moving down to the last second') - - // A screen with nothing scheduled gets no timer at all, so a fresh board does not tick. - timers.pending.clear() - const fresh = mount(DeliveryBoard, { view: { ...snapshot, state: 'fresh' } }) - fresh.draw() - assert.equal(timers.pending.size, 0, 'a fresh board does not tick') - wall.t = null -}) - -test('the rate-limit and timeout hints name the page as the condition (#1227)', () => { - const rateLimitNoNumber = hintFor({ kind: 'rate-limit', retryAfterMs: null }) - assert.match(rateLimitNoNumber, /while this page is open it retries on its own/) - const rateLimitNumber = hintFor({ kind: 'rate-limit', retryAfterMs: 30_000 }) - assert.match(rateLimitNumber, /while this page is open it retries on its own in 30 s/) - const timeout = hintFor({ kind: 'timeout' }) - assert.match(timeout, /the next attempt happens while this page is open/) - - // Every "retries on its own" the client can print says who is doing the retrying: the open page. - // Nobody else does — the host installs no timer (#1173). - for (const file of ['src/client/delivery-view.js', 'src/client/i18n.js']) { - const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') - for (const line of source.split('\n')) { - if (!/retries on its own|next attempt/.test(line)) continue - if (/^\s*\/\//.test(line)) continue - assert.match(line, /this page is open|页面开着时|页面开着/, `${file}: ${line.trim()}`) - } - } -}) - -test('the header says whose repository this is, and where the name came from (#1250)', () => { - const lineOf = payload => walk(DeliveryBoard({ snapshot: { ...snapshot, ...payload }, message: '' })) - .find(node => node.props?.className === 'bt-del-repo') - const configured = lineOf({ repo: 'acme/widget', repoSource: 'config' }) - assert.equal(configured.props['data-repo'], 'acme/widget') - assert.equal(configured.props['data-repo-source'], 'config') - assert.match(text(configured), /acme\/widget/) - assert.match(text(configured), /config/) - // The remote is named as where the host looked, not as something the person set. - const remote = lineOf({ repo: 'acme/widget', repoSource: 'remote' }) - assert.equal(remote.props['data-repo-source'], 'remote') - assert.match(text(remote), /remote/) - // A package default reaches the page labelled as one: that is the whole difference between an - // instance showing its own numbers and showing ours. - const packaged = lineOf({ repo: 'ThinkFlowLab/FlyBot', repoSource: 'package' }) - assert.equal(packaged.props['data-repo-source'], 'package') - assert.match(text(packaged), /default/) - // The Diagnose block names the same repository and source the header does. - assert.match(probeText({ repo: 'acme/widget', repoSource: 'config', at: '2026-10-26T00:00:00Z', rows: [] }), - /acme\/widget · config/) -}) - -test('an unknown open count is —, a real zero is 0 (#1297 INC)', () => { - const zero = { - ...snapshot, - metrics: { ...snapshot.metrics, open: { issues: 0, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: null } }, - } - assert.match(text(DeliveryHeader({ view: zero })), /Open: 0 issues/) - // A partial board answers `metrics: null` (#1297 INC): no repository could be counted, so the - // header prints '—' rather than a zero it never read. Same rule as an unreadable CI number. - const unknown = { - ...snapshot, - state: 'partial', - partial: true, - metrics: null, - repos: [ - { ...snapshot, repo: 'acme/one', repoSource: 'config' }, - { ok: false, state: 'failed', repo: 'acme/two', repoSource: 'config' }, - ], - } - const words = text(DeliveryHeader({ view: unknown })) - assert.match(words, /2 repositories/) - assert.match(words, /Open: — issues/) - assert.doesNotMatch(words, /Open: 0 issues/) -}) - -test('a host that cannot name a repository says so, and does not send the person to a token (#1250)', () => { - const error = { message: 'no repository — set delivery.repo', kind: 'no-repo', scope: 'repo', endpoint: null, status: null, at: null } - assert.equal(hintFor(error, { repo: null }), - 'Set delivery.repo in this instance\'s settings to the repository this instance works in.') - const board = DeliveryBoard({ view: { state: 'failed', repo: null, repoSource: null, error, retryAt: null }, repo: null }) - const words = text(board) - assert.match(words, /no repository — set delivery\.repo/) - assert.match(words, /Set delivery\.repo/) - // Missing settings is not a missing credential; pointing at GITHUB_TOKEN would be the wrong fix. - assert.doesNotMatch(words, /GITHUB_TOKEN/) -}) - -// #1265 step 4: one board with more than one repository. A repository keeps its own block, its own -// columns and its own state, and one that cannot be read no longer empties the page. -const repoSample = (repo, extra = {}) => ({ - repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', - columns: { - 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], - 'in-progress': [], - 'done': [], - }, - metrics: { - dayStart: '2026-10-24T16:00:00.000Z', - today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: null, medianPullMergeMs: null, ci: { failed: 0, total: 2, counted: 2, superseded: 0 } }, - week: { newIssues: 2, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: null, medianPullMergeMs: null, ci: { failed: 0, total: 2, counted: 2, superseded: 0 } }, - daily: null, - open: { issues: 1, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: null }, - }, - ...extra, -}) - -test('the visible columns come from the payload through one entry point (#1265, #1326)', () => { - // The payload names the columns it has; the default is the definition's three lanes. - assert.equal(stagesOf({ columns: threeLane.columns }).map(column => column.id).join('|'), 'not-started|in-progress|done') - // An id this build has no label for still renders under its own id rather than disappearing. - assert.equal(stagesOf({ columns: { review: [], triage: [] } }).map(column => column.id).join('|'), 'review|triage') - assert.equal(stagesOf({}).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) -}) - -test('#1326: the stages that draw no lane are filters, and a filter narrows without reordering', () => { - const board = mount(DeliveryBoard, { snapshot: threeLane, message: '' }) - // The definition ships three lanes; review and done-today are stages, so no fourth or fifth lane. - const lanes = (tree) => walk(tree).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => node.props['data-column']) - assert.deepEqual(lanes(board.draw()), ['not-started', 'in-progress', 'done']) - const bar = walk(board.draw()).find(node => node.props?.className === 'bt-del-filters') - assert.ok(bar, 'the board draws one filter bar') - const filterButtons = walk(bar).filter(node => node.type === 'button') - assert.deepEqual(filterButtons.map(node => node.props['data-filter']), ['all', 'review', 'done-today']) - assert.deepEqual(filterButtons.map(node => node.props['aria-pressed']), ['true', 'false', 'false']) - // A finished card keeps the stage it came with, so a stage assertion needs no fifth lane. - const done = walk(board.draw()).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') - assert.equal(walk(done).some(node => node.props?.['data-stage'] === 'done-today'), true) - // Pressing a filter narrows the cards and leaves the columns in their order. - filterButtons[1].props.onClick() - const reviewOnly = board.draw() - assert.deepEqual(lanes(reviewOnly), ['not-started', 'in-progress', 'done']) - const pressed = walk(reviewOnly).find(node => node.props?.className === 'bt-del-filters') - assert.deepEqual(walk(pressed).filter(node => node.type === 'button').map(node => node.props['aria-pressed']), ['false', 'true', 'false']) - const shown = walk(reviewOnly).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => walk(node).filter(item => item.props?.['data-number']).map(item => item.props['data-number'])) - assert.deepEqual(shown, [[], [41], []]) -}) - -test('#1326: a task card is the shared card, and a drop only asks the board to move it', () => { - const moved = [] - const board = mount(DeliveryBoard, { - view: { ...threeLane, state: 'fresh' }, - tasks: [{ id: 'task-abc123456', title: 'Draft the note', status: 'working', ownerBotId: 's', column: 'in-progress', priority: 'high' }], - roster: { byId: { s: { name: 'Writer' } } }, - dragging: 'task-abc123456', - onMove: (id, column) => moved.push([id, column]), - onDrag: (id) => moved.push(['drag', id]), - }) - const tree = board.draw() - const card = walk(tree).find(node => node.props?.['data-task'] === 'task-abc123456') - assert.equal(card.props['data-source'], 'task') - assert.equal(card.props['data-key'], 'task:task-abc123456') - assert.match(text(card), /Draft the note/) - assert.match(text(card), /Writer/, 'the card names the Bot that has the task') - assert.match(text(card), /High/) - // Dragging reports the card, and the drop reports the column it landed on: the host decides, the - // view never writes (#1326). - card.props.onDragStart({ dataTransfer: { setData: () => {} } }) - const done = walk(tree).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') - done.props.onDrop({ preventDefault: () => {}, dataTransfer: { getData: () => 'task-abc123456' } }) - assert.deepEqual(moved, [['drag', 'task-abc123456'], ['task-abc123456', 'done'], ['drag', null]]) -}) - -test('more than one repository draws one block each, one repository draws none (#1265)', () => { - // One repository keeps today's shape: the header names it and no block wrapper is rendered. - const single = DeliveryBoard({ snapshot: { ...snapshot, repo: 'acme/one' }, repo: 'acme/one', message: '' }) - assert.equal(walk(single).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(single).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - // The host always sends repos[] now, one entry included (#1265 step 4): that shape is still - // today's single board, with the repository named in the header rather than counted. - const singleView = repoSample('acme/one') - const one = DeliveryBoard({ view: { ...singleView, repos: [singleView] } }) - assert.equal(walk(one).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 3, 'single-repo through repos[] uses workflow columns') - const oneHead = walk(one).find(node => node.props?.className === 'bt-del-repo') - assert.equal(oneHead.props['data-repo'], 'acme/one') - assert.equal(oneHead.props['data-repos'], undefined) - assert.doesNotMatch(text(oneHead), /1 repositories/) - - const board = DeliveryBoard({ - view: { state: 'fresh', repo: null, repoSource: null, asOf: '2026-10-25T04:00:00Z', repos: [repoSample('acme/one'), repoSample('acme/two')] }, - }) - // #1326: Successful repos merge into one shared column set, no per-repo blocks - const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 0, 'no repo blocks for successful repos — all cards in one shared column set') - const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.equal(columns.length, 3, 'one shared column set with 3 workflow columns') - const cards = walk(board).filter(node => node.type === 'article') - // Both repos' cards appear in the shared columns; each card keeps its repo identity - const keys = cards.map(card => card.props['data-key']).sort() - assert.ok(keys.includes('acme/one#10'), 'acme/one card present') - assert.ok(keys.includes('acme/two#10'), 'acme/two card present') - // The shared header counts repositories - const head = walk(board).find(node => node.props?.className === 'bt-del-repo') - assert.equal(head.props['data-repos'], '2') - assert.equal(head.props['data-repo'], undefined) - assert.match(text(head), /2 repositories/) -}) - -test('a failed repository keeps its block, and the readable repository still draws (#1265)', () => { - const broken = repoSample('acme/two', { - state: 'failed', columns: undefined, - error: { message: 'GitHub answered 404 for /repos/acme/two/issues', kind: 'not-found', scope: 'issues', status: 404, endpoint: '/repos/acme/two/issues' }, - }) - const board = DeliveryBoard({ - view: { - state: 'partial', partial: true, repo: null, repoSource: null, - repos: [repoSample('acme/one'), broken], - missing: [{ part: 'issues', repo: 'acme/two', message: 'GitHub answered 404 for /repos/acme/two/issues', status: 404, kind: 'not-found', scope: 'issues' }], - asOf: '2026-10-25T04:00:00Z', - }, - }) - // #1326: Successful repos merge into shared columns; failed repos shown as error blocks - const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 1, 'only the failed repo has a block') - assert.equal(blocks[0].props['data-state'], 'failed') - assert.equal(blocks[0].props['data-repo'], 'acme/two') - assert.equal(walk(blocks[0]).some(node => node.type === 'section' && node.props?.['data-column']), false) - assert.match(text(blocks[0]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) - // The successful repo (acme/one) draws in shared columns outside the block - const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.equal(columns.length, 3, 'shared columns for successful repo') - // The partial line names whose part it was, so one repository's failure is not read as another's. - assert.match(text(board), /acme\/two · issues was not fetched this time/) -}) - -test('multi-repo with tasks: each task appears exactly once (#1326)', () => { - const task1 = { id: 'task-abc123', title: 'First task', status: 'open', ownerBotId: 'bot-one' } - const task2 = { id: 'task-def456', title: 'Second task', status: 'working', ownerBotId: 'bot-one' } - const roster = { byId: { 'bot-one': { id: 'bot-one', name: 'TestBot' } } } - const board = DeliveryBoard({ - view: { - state: 'fresh', - repo: null, - repoSource: null, - asOf: '2026-10-27T05:00:00Z', - repos: [repoSample('acme/one'), repoSample('acme/two')] - }, - tasks: [task1, task2], - roster, - }) - // Each task should appear exactly once, not duplicated per repo - const taskCards = walk(board).filter(node => node.props?.['data-task']) - assert.equal(taskCards.length, 2, 'exactly 2 task cards, not duplicated') - const taskIds = taskCards.map(card => card.props['data-task']) - assert.deepEqual(taskIds.sort(), ['task-abc123', 'task-def456'].sort()) - // GitHub cards from both repos should also be present - const githubCards = walk(board).filter(node => node.props?.['data-kind']) - assert.ok(githubCards.length > 0, 'GitHub cards from repos are present') -}) - -test('no token names the GitHub side, never an empty board (#1211, #1265)', () => { - const error = { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null } - const board = DeliveryBoard({ view: { state: 'failed', repo: null, repos: [], error, retryAt: Date.now() + 10_000 }, onRetry: () => {} }) - const words = text(board) - assert.match(words, /Add a GITHUB_TOKEN/) - assert.match(words, /no GITHUB_TOKEN/) - // A failed read is a sentence with a next step; it is not a board with nothing on it. - assert.doesNotMatch(words, /Nothing here\./) - assert.equal(board.props['data-state'], 'failed') - // More than one repository with every read failed still says one failure, not an empty page. - const all = DeliveryBoard({ - view: { - state: 'failed', repo: null, - error, - repos: [repoSample('acme/one', { state: 'failed', columns: undefined, error }), repoSample('acme/two', { state: 'failed', columns: undefined, error })], - }, - }) - 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/home-lock.test.js b/core/test/home-lock.test.js index cd6545795..b59a845fe 100644 --- a/core/test/home-lock.test.js +++ b/core/test/home-lock.test.js @@ -297,27 +297,21 @@ test('read-only host (#14): FlyBot queries answer, plugin reads answer, writes a assert.deepEqual(fb.remote.list(), []) }) -test('#1225: a reading host answers the delivery board and its probe, and still refuses a write', async (t) => { +test('#1363: the delivery board endpoints are gone, and a reading host still refuses a write', async (t) => { const home = tempHome(t) writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'other' })) const call = start(t, home) - // The probe asks GitHub five times; answer them here so the test reads the gate, not the network. - call.rt.fetch = async (url) => new Response( - String(url).includes('/pulls') ? JSON.stringify([{ number: 7, head: { sha: 'abc1234' } }]) : '{}', - { status: 200, headers: { 'content-type': 'application/json' } }, - ) assert.match((await call('state', {})).value.readOnly, /open in another dsh/) - // Both read: the window that only reads the team still answers them instead of `bot/read-only`. - const board = await call('delivery-board', {}) - assert.equal(board.error, undefined, `delivery-board was refused: ${JSON.stringify(board.error)}`) - assert.notEqual(board.value, undefined) - const probe = await call('delivery-probe', {}) - assert.equal(probe.error, undefined, `delivery-probe was refused: ${JSON.stringify(probe.error)}`) - assert.equal(probe.value.ok, true) + // Neither name is a read any more: a window that only reads the team refuses them, and + // neither answer is a snapshot. + for (const endpoint of ['delivery-board', 'delivery-probe']) { + const answer = await call(endpoint, {}) + assert.equal(answer.value, undefined, `${endpoint} still answered`) + assert.equal(answer.error?.code, 'bot/read-only') + } - // The list stays a list of reads: the same window still refuses a change. assert.equal((await call('set-prefs', { theme: 'dark' })).error.code, 'bot/read-only') assert.equal((await call('secret-set', { name: 'MINE', value: 'mine-value-1' })).error.code, 'bot/read-only') }) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index 20bb087ee..d3d75028c 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -175,36 +175,22 @@ test('a group column nests subtasks under the overall task', () => { assert.match(textOf(notStarted), /Theirs/) }) -test('one board: no tablist, heading says Board, shows both task and delivery cards (#1326)', () => { - const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => ({ ok: false, reason: 'no-github' }) } +test('the task dialog has no delivery metrics panel (#1363)', () => { + const actions = { closeBoard: () => {}, boardClose: () => {} } states.length = 0 const boardView = expand(KanbanDialog({ roster, actions })) - // No tablist — the Tasks/Delivery split is gone (#1326). - const tabs = all(boardView).filter(node => node.props?.role === 'tab') - assert.equal(tabs.length, 0, 'no tabs — one board shows both task and delivery cards') - const tablist = all(boardView).find(node => node.props?.role === 'tablist') - assert.equal(tablist, undefined, 'no tablist element') - // Heading says "Board", not "Tasks" or "Delivery". + assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) + assert.equal(all(boardView).find(node => node.props?.role === 'tablist'), undefined) const heading = all(boardView).find(node => node.props?.id === 'bt-board-heading') assert.match(textOf(heading), /Board/) - assert.doesNotMatch(textOf(heading), /Tasks/) - assert.doesNotMatch(textOf(heading), /Delivery/) - // The rail is per-Bot; task cards need it, delivery cards do not. assert.equal(byClass(boardView, 'bt-board-rail').length, 1) - // With no GitHub token, task cards still render — the board is never empty (#1211, #1265). - assert.equal(all(boardView).some(node => node.props?.className === 'bt-del-body'), true) + assert.equal(byClass(boardView, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + assert.equal(all(boardView).some(node => String(node.props?.className ?? '').includes('bt-del')), false) + assert.doesNotMatch(textOf(boardView), /from the package|Diagnose|New issues|CI red share/) }) -test('a delivery read with no token still shows task cards on the one board (#1211, #1265, #1326)', () => { - // The two halves meet in one dialog: the delivery board can only say "the GitHub side is not - // available" (core/test/delivery-view.test.js holds that sentence), and it must not take the task - // board with it — the cards are still rendered on the same board. - const failed = { - ok: false, state: 'failed', repo: null, repos: [], - error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null }, - retryAt: null, - } - const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => failed } +test('the task dialog shows task cards without a GitHub read (#1363)', () => { + const actions = { closeBoard: () => {}, boardClose: () => {} } const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) // scope, dragging, error, draft, then the list the board would have read from the host. states.length = 0 @@ -212,19 +198,31 @@ test('a delivery read with no token still shows task cards on the one board (#12 const boardView = expand(KanbanDialog({ roster, actions })) assert.match(textOf(boardView), /Draft the note/) assert.equal(byClass(boardView, 'bt-board-card').length, 1) - // No tabs — one board shows both (#1326). assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) -test('Start and Complete fire from the unified board path (#1326)', () => { +test('the dialog nests a group subtask under its overall task (#1358)', () => { + const overall = task('task-aaaaaa111111', 'open', { title: 'Ship it', kind: 'group', ownerBotId: 'a', priority: 'high', updatedAt: 5 }) + const child = task('task-bbbbbb222222', 'open', { title: 'Write it', ownerBotId: 's', parentId: overall.id, parentTitle: 'Ship it', priority: 'urgent', updatedAt: 6 }) + const actions = { closeBoard: () => {}, boardClose: () => {} } + // scope, dragging, error, draft, own, groupTasks, archive + states.length = 0 + states.push({ kind: 'group', roomId: 'war' }, null, '', '', [], [overall, child], []) + const boardView = expand(KanbanDialog({ roster, actions })) + const notStarted = byClass(boardView, 'bt-board-column').find(node => node.props['data-column'] === 'not-started') + assert.match(textOf(notStarted), /Ship it/) + assert.match(textOf(notStarted), /Write it/) + assert.equal(all(notStarted).some(node => node.props?.['data-parent'] === overall.id), true) +}) + +test('Start and Complete fire from the task board', () => { const moved = [] const openTask = task('task-open123', 'open', { title: 'Start me', ownerBotId: 's' }) const workingTask = task('task-working456', 'working', { title: 'Complete me', ownerBotId: 's' }) - const actions = { - closeBoard: () => {}, + const actions = { + closeBoard: () => {}, boardClose: () => {}, taskUpdate: async ({ id, column }) => { moved.push([id, column]); return { ok: true } }, - deliveryBoard: async () => ({ ok: false, reason: 'no-github' }), } states.length = 0 // KanbanDialog states: scope, dragging, error, draft diff --git a/core/test/no-delivery-board.test.js b/core/test/no-delivery-board.test.js new file mode 100644 index 000000000..42a8fab9f --- /dev/null +++ b/core/test/no-delivery-board.test.js @@ -0,0 +1,41 @@ +// The GitHub delivery board is gone (#1363): the metrics panel, its host endpoints and the +// probe script. These assertions are red while that surface is still in the tree, and red +// again if a later change puts the panel or either endpoint back. +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { test } from 'node:test' + +const url = (path) => new URL(`../${path}`, import.meta.url) +const read = (path) => readFileSync(url(path), 'utf8') + +const gone = [ + 'src/host/delivery-board.js', + 'src/host/delivery-repo.js', + 'src/client/delivery-view.js', + 'src/client/delivery-chart.js', + 'src/shared/workflow-board.js', + 'scripts/delivery-probe.mjs', +] + +test('#1363: the delivery board files are gone', () => { + for (const path of gone) assert.equal(existsSync(url(path)), false, path) +}) + +test('#1363: the host does not serve delivery-board or delivery-probe', () => { + const channel = read('src/host/channel.js') + const authz = read('src/host/authz.js') + const index = read('index.js') + for (const source of [channel, authz, index]) { + assert.doesNotMatch(source, /delivery-board/) + assert.doesNotMatch(source, /delivery-probe/) + assert.doesNotMatch(source, /deliveryBoard/) + } +}) + +test('#1363: the task board does not mount the delivery view', () => { + const kanban = read('src/client/kanban.js') + const client = read('src/client/index.js') + assert.doesNotMatch(kanban, /delivery-view|DeliveryView|deliveryBoard|DELIVERY_CSS/) + assert.doesNotMatch(client, /delivery-board|delivery-probe|deliveryBoard|deliveryProbe/) + assert.match(kanban, /h\(BoardView/) +}) diff --git a/core/test/workflow-board.test.js b/core/test/workflow-board.test.js deleted file mode 100644 index 4d99d204e..000000000 --- a/core/test/workflow-board.test.js +++ /dev/null @@ -1,100 +0,0 @@ -// 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/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index d76e5e143..6399bbb60 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,11 +38,9 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`. | -| `core/src/client/delivery-view.js` | The GitHub half of the one board (#1173, #1211, #1326): the columns the snapshot names through `stagesOf()` — three for `repo-dev` (`not-started`, `in-progress`, `done`) — with the stages a definition draws no lane for reaching the reader as filters instead (`div.bt-del-filters`, `button[data-filter=all|review|done-today]` with `aria-pressed`), which narrow the cards and never reorder or add a column; a delivery card carries `data-stage`, cards that link out, the verdict/CI badge, and the header numbers. A read is one of four states and each reads differently, never as a blank board: `loading` counts its own seconds (“首次加载中,已 N 秒”) and polls every 2 s, `fresh`/`stale` poll every 60 s and a stale board keeps its columns with “显示 03:12 的快照 · 刷新失败:…” beside the header, and `failed` names the endpoint, the advice and a **Retry** button whose countdown really counts: the screen redraws once a second while a retry is scheduled and not otherwise, and the rate-limit and timeout advice says `while this page is open it retries on its own`, because the host installs no timer (#1227). “还没有快照。” is reachable only from `idle`, a host that never tried. Since #1250 the header opens with whose repository this is — `acme/widget · from config` / `from remote` / `from the package (default)`, and `no repository — set delivery.repo` when the host has nothing — so a package default never passes for a fact; a `no-repo` failure adds `Set delivery.repo in this instance's settings…` and never points at the token. Since #1244 the as-of line prints one time, not two: while today is still running it is the task zone's clock (`clockOf`) and the hint beside it is the bare `today is partial` (zh `今天为部分日`), so a partial day never shows a local-clock and a zone rendering of the same instant side by side. The CI row's share divides by `counted` when the day has one and by `total` otherwise — the one denominator `dayValue` draws that day's point from — so the row and the line beside it cannot read one day two ways; a day with no runs at all still reads `—`. The header's **Diagnose** button prints the five `probeGitHub()` lines into a copyable block, so the user reads the same statuses `core/scripts/delivery-probe.mjs` prints. Since #1265 step 4 one board may hold several repositories: more than one is a `section.bt-del-repo-block[data-repo][data-state]` per repository with a sticky `h3.bt-del-repo-head` (name plus its own source) and its own columns, and exactly one renders no wrapper at all — today's single-repository DOM. A repository that cannot be read keeps its block and says why while the others still draw, and the body says `partial`, never an empty board. Which columns exist comes from the payload through `stagesOf()` (the snapshot's own `columns`; `DELIVERY_COLUMNS` only orders and labels them), so a definition with three columns draws three and the renderer has no fixed five. The GitHub cards are read-only — nothing here writes to GitHub — while a task card only asks the dialog to move it (`onMove`), which owns the one write path (`actions.taskUpdate`). Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | -| `core/src/client/delivery-chart.js` | The 7-day line each delivery metric draws (#1212): `chartGeometry` is pure — seven x positions, a y per known day (`span = max(known)`, baseline 0), the path split at every day with no number, so a gap can never read as zero. A day without a number draws no dot at all; the `full` / `partial` split is a geometry state, not an attribute for the missing day. `DeliveryChart` renders one 168×28 SVG per metric with a 1.5px stroke, `r=2` dots (`r=2.5` for the one hollow dot) and a `title` on every point (`2026-10-26 · 12`, `2026-10-26 · 未取到`); each drawn dot carries `data-state` `full` or `partial`, a real `0` is a filled dot on the baseline, and today with `partial: true` is hollow (`fill:none`, `stroke-width:1.5`) with a `2 2` dashed stretch. `DeliveryMetrics` is the whole block the view calls once — one row per metric, `label | line | Today | 7 days`, five lines (the four counts plus the CI share), with `Σ` only on the counting rows (a ratio and a median are not sums) and a star plus a one-line note, naming the window length, when some days were not read. The CI share's denominator — `counted`, else `total` — is the one the header row now reads too (#1244), so the number on the row and the point on the line come from the same day. A snapshot with no `metrics.daily` draws no SVG at all and says `7 天数据未取到` in one line. Colours come from `--bt-accent` / `--bt-ink-*` only. Tests: `core/test/delivery-chart.test.js`. | -| `core/src/client/board-card.js` | One card component behind two boards (#1265 step 1): `BoardCard` draws the identity line, the title, what the card wears, who has it and what it is doing, from a card model that says `source: 'github'` or `source: 'task'`. Each board passes its own `skin` (`DELIVERY_SKIN` / `TASK_SKIN`), so the delivery board and the task board keep their stylesheets while sharing the markup, and every card carries `data-source` and `data-key`. The two callers are `delivery-view.js`'s `DeliveryCard` and `kanban.js`'s `BoardCard`. Tests: `core/test/board-card.test.js`, `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | -| `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` — the same key for an issue and the pull request that closes it, which is the identity step 3 hangs a FlyBot record under — and `taskCardKey(id)` is `task:`. `githubCard(row, repo)` and `taskCard(task)` build the model the component draws, `sameCard(a, b)` is the one comparison both sides dedupe by, and a GitHub row with no repository falls back to `github::` rather than imply an identity across repositories. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog is one board, not two tabs (#1326): `UnifiedBoard` keeps `boardRail` on the left and draws task cards and GitHub cards in the same columns through `DeliveryView`, so the rail only picks the task scope — the GitHub side stays repository-wide. A task card on that board is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | +| `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | @@ -176,7 +174,6 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - 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 3ce5a21fe..12660ec75 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,29 +38,26 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | -| `core/src/host/delivery-board.js` | The delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull requests and CI runs, laid out by delivery stage. Every request is a `GET`; the token is read host-side (`rt.secretValue`, falling back to none) and never leaves the payload, which carries no credential. `read({ force })` caches per URL with the ETag and revalidates with `If-None-Match`, serving the old snapshot on a 304 and while a refresh runs; there is no timer — the client asks. Issues are placed by workflow stage, not by a fixed five-column list (#1326): `columnOfDelivery` puts an issue with no work label in `not-started`, an `agent-working`/`agent-pr` issue and every open pull request in `in-progress`, and a merge or a close since the day start in `done` — a pull request closed without merging counts, so finished work never sits in no column. The finer stage travels on the card itself (`stage: 'review'` on an open pull request, `stage: 'done-today'` on today's finished work), which is what lets the view filter by review or by done-today without drawing a column for either. A PR card carries the verdict badge of a recent `Verdict:` on its own tip (an old-tip verdict does not count; CI decides) — every comment page is read, so a verdict past the first hundred comments still decides — and an issue is linked to a pull request only when a closing keyword (`close`/`fix`/`resolve` with `#n`) names it, never because a body merely mentions the number (#1189), and the header reports the Asia/Shanghai day, the 7-day window and CI as `counted` of `total`. The payload also carries `metrics.daily` (#1222): the last seven Asia/Shanghai days, oldest first and today last with `partial: true`, each with the issues and pull requests opened, closed and merged and one `{ failed, counted, total }` CI block per day — a cancelled run is left out of the denominator. A day whose list or runs were truncated, or whose CI could not be read, is `null` and never `0`; a week that could not be read makes the whole series `null`, so the view says it has no data instead of drawing seven zeros. The series costs no request: it is assembled from the week and today windows the round already reads. Endpoint `delivery-board`, allowed to any signed-in member because it reads one repository rather than an owner's Bots. #1211: a read never reports a blank board for a problem it can name. The first read answers `{ state: 'loading', since, attempt }` at once and the round runs in the background on the same `inflight`, because a request parked on a whole round is cut by the reverse proxy before it answers. Then `fresh`, `stale` (an older snapshot kept, with `error: { message, endpoint, status, at }` carried into the next read rather than dropped to `null`) and `partial` (a round past `BUILD_BUDGET_MS` — 90 s — lands with the columns it has and the metrics it read). Degradation comes before retry: an unreadable `/actions/runs` makes CI `null` and the row says `unknown`, never `0`; an unreadable check-runs writes `checks-unknown`; unreadable comments leave that card's owner blank; only the two open tables (`pulls`, `issues`) fail a round, because without them there are no columns. A failed round backs off `10 s → 30 s → 2 min` (`BACKOFF_MS`) unless the client forces a read, and a 403 names `retry-after`, else `max(0, x-ratelimit-reset - now)`, and no number at all when neither is present. `probeGitHub()` walks five endpoints and returns status plus the setting to fix; `probe()` and the `delivery-probe` endpoint expose it, and `core/scripts/delivery-probe.mjs` is a thin shell over the same function. Since #1250 the repository is not baked in, and since #1265 step 4 the setting may name several: every read resolves a list through `delivery-repo.js` — `delivery.repos` (a list), or the older `delivery.repo` read as a list of one (nested, dotted or flat, since the host expands no dotted name), then the workspace's `.git/config` `[remote "origin"] url`, then the package's own `repository.url` as a labelled default — and every name keeps its own `source` (`config` / `remote` / `package`), so two repositories never share one source line. The payload carries one view per repository in `repos[]` (each with its own `repo`, `repoSource`, `state`, `columns`, `metrics`, `error` and `retryAt`); with one repository the top-level fields are that repository's own and the screen is unchanged. With several, the body state is the worst of them — a repository that cannot be read is `failed` and leaves the readable ones on a `partial` board, never an empty one — the top-level `columns` is the union (each card still keyed by its repository) and `metrics` is the sum of the additive numbers only while every repository read — one repository that did not answer leaves `metrics: null` so the header prints `—` instead of the readable half — with medians unknown because a median across repositories is not the sum of their medians. One board is kept per repository, so a changed list starts only its new name at `loading`; nothing resolving answers a failed read with `no repository — set delivery.repo` before any board exists, so no request goes to `/repos/null`. Tests: `core/test/delivery-board.test.js`, `core/test/delivery-repo.test.js`. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). `rt.reach` also reads the room a send already belongs to when the caller names none (#948): `sharedRoom` applies only to the `roomOnly` identity — the Bot holds no grant at all and came into that **face-to-face** room by its number (`isCodeJoined`) — and looks for the one such room where the **exact** target is still seated; a Bot that holds a grant, or a room of this home, is judged as it always was and keeps asking for an explicit `roomId`, so a Bot that joined a room by number — a `roomOnly` identity with no person-grant — may line that seat, which is the same allowance `reach()` already applies to an explicit `roomId`; the room itself is named outbound by the transport, never assumed here. A `user-mention` with no named room is also judged under `mentionRoom` (#1126): one live local or inbound group where that Bot is a member and the exact target is still a remote seat, including a seat a group invite admitted (`admittedBy: 'ticket'`). A plain line, `message_bot`, a seat that has left, and a stranger outside every such room stay on the grant rule. Face-to-face stays on `sharedRoom`. Tests: `core/test/reach-policy.test.js`, `core/test/flybot.test.js`. | | `core/src/host/schedules.js` | Scheduled tasks through DSH's schedule service: list, create, edit, copy, pause/resume, retry and delete for Settings → Scheduled tasks, each task's last run, and moving a Bot's tasks to its fresh part, which rewrites the stored row in place so the task keeps its id, its delivery history and the instant it was already due (#354, #473, #1245, #1287, see **Scheduled tasks: edit, pause, missed runs**, **Scheduled tasks: copy** and **Scheduled tasks: last run and Retry**). Also the `bot_schedules` tool: the Bot-wide list DSH's own `schedule_list` cannot give, group chats' own Sessions included (#628, see **Scheduled tasks: the Bot-wide list**). Tests: `core/test/schedules.test.js`. Tests for team quiet hours, its refusals and the settings write: `core/test/quiet-hours.test.js`; the rule itself is in `core/src/shared/quiet-hours.js` (#1166). | | `core/src/host/automation-default.js` | Automation tasks on by default (#378): once per profile, `offerBundle` asks DSH's plugin manager to switch on `@deepseek-ai/dsh-experimental-schedule-bundle` and writes the offer to `dsh-defaults.json` next to the team. Tests: `core/test/automation-default.test.js`. | -| `core/src/host/delivery-repo.js` | Which repositories a delivery read talks to, and where each name came from (#1250, #1265). `resolveRepos({ setting, workspace, packageSlug })` answers first-wins as a list: the instance setting (`instanceRepoSettings` reads `delivery.repos` as a list and the older `delivery.repo` as a list of one, and a duplicate name written twice is one repository), then the team workspace's `origin` read straight out of `.git/config` (a `.git` file is followed through its `gitdir:`; a linked worktree then follows its `commondir` to the shared config, and no `git` process is spawned), then the package's `repository.url`. A name is normalised to `owner/name` from `owner/name`, a GitHub URL, `git@github.com:owner/name.git` or an `ssh://` URL; anything on another host is refused rather than mined, and a `.` or `..` segment is refused because it is a path step rather than a repository name (#1258). Only `origin` counts — a union of disagreeing remotes would be a guess. A level that answers yields its whole list, so a setting replaces the inferred answer instead of adding to it; `resolveRepo` is the first of that list for a caller that asks about one repository. `NO_REPO_MESSAGE` is the one wording the host, the view and `core/scripts/delivery-probe.mjs` use. Tests: `core/test/delivery-repo.test.js` (the knife for each level). | | `core/src/host/notify.js` | Out-of-app delivery, first half (#355): a transport-agnostic adapter with Slack's `chat.postMessage` shape, one key per reminder so a retried send cannot duplicate, exponential backoff that honours `Retry-After`, three attempts at most, a structural failure not retried, and zero transport calls while the channel is off. Ships `fakeSlack` for tests; nothing calls it until the real Slack transport lands (see **Out-of-app delivery, first half**). Tests: `core/test/notify.test.js`. | | `core/src/host/harness.js` | Declarative harness (#874 Option B). Step 0 (#903): `default` names today's prompt / tools / model / step·retry / verifiers (`rt.harness`, `rt.resolveHarness`); behavior-neutral. Step 1: `lite` / `medium` / `heavy` load through the same resolver with distinct declared budgets; `rt.routeHarness` reads one triage label (`harness:lite`, `harness:medium`, `harness:heavy`). `applied` stays false, so the named modules are not rewritten. `harnessPro` checks a config offline against CI, `core/dev/eval`, and `rsi-baseline` and does not call a model. Step 2: `rt.harnessExperience` reads the experience library and does not attach it to a tier. Tree prompt exception: `@flybot/tree` keeps its marked prompt under `plugins/tree` (outside the three mirrors); listed on the harness, injection stays outside this file. Installed in `MODULES` right after `store`. Tests: `core/test/harness.test.js`, `core/test/experience.test.js`. | | `core/src/host/experience.js` | Experience library read API (#874 Step 2). `lookupExperience({ repo, techStack, query, root })` reads `core/dev/experience/`. Data and `check.mjs` stay under `core/dev/` (see [ds-bot-core](ds-bot-core.md)). | | `core/src/host/usage.js` | Usage ledger at `$DSH_HOME/bot/usage.sqlite` (`calls`). Ledger v3 (#903): every row carries `harness`, `issue_pr`, `tool_calls`, `wall_ms`, plus existing token columns (`withUsageFields` / `missingUsageFields`). Session turns are metered via `foldCalls` over turn events (`tool/call` count, wall time from attempt start, `issuePr` from turn text or `rt.noteUsageIssuePr(sessionId, ref)`), not from llm/stream alone. A triage tier is stamped with `rt.noteUsageHarness(sessionId, idOrLabels)` and lands on the row's `harness` field; the resume cursor (`marks.fold`) stays provider / model / retry. Sample row fixture: `core/test/fixtures/usage-sample.sqlite`. Tests: `core/test/usage-fields.test.js`, `core/test/usage.test.js`. | | `core/src/host/tool-schema.js` | The tool definitions the model sees: its `defineTool` wraps DSH's and completes the compiled schema with `explicitObjectShape`, which fills `properties: {}` and `required: []` on every object node (recursing through `properties`, `items`, `oneOf`/`anyOf`/`allOf`, `$defs`, …). A strict OpenAI-compatible gateway reads an absent member as null and rejects the whole request (`null is not of type "array"`), so one argument-less tool would break every turn; argument validation keeps using DSH's schema unchanged. Every host module registers tools through this wrapper. Tests: `core/test/tool-schema.test.js`. | | `core/src/shared/artifact.js` | The typed facts a workflow node takes in and hands out (#1272): `parseArtifactSpec` reads `check:tests-ok` / `issue.ready` / `text:topic` into `{ kind, name }` and refuses a spelling the vocabulary does not have; `artifact(spec, revision, { value })` is one instance, and `REVISION_RULES` says what the revision of each kind is (a verdict its SHA, a check the SHA it ran on, a PR its number and `headRefOid`). `freshness(input, currentRevision)` answers present-and-current separately from present, and a fact nobody could read is `unreadable`, never a match. Tests: `core/test/workflow.test.js`. | -| `core/src/shared/board-card.js` | Card identity for the one board (#1265 step 1): `githubCardKey(repo, number)` is `owner/name#number` and `taskCardKey(id)` is `task:`, the two spellings the client's `BoardCard` puts on every card. The GitHub key is not keyed by kind — an issue and the pull request that closes it are one card — which is the identity step 3 hangs a FlyBot task record under; a row with no repository falls back to `github::` rather than imply a cross-repository identity. `githubCard(row, repo)` / `taskCard(task)` build the model, `sameCard(a, b)` is the one comparison. Tests: `core/test/board-card.test.js`. | +| `core/src/shared/board-card.js` | Task-card identity for the per-Bot board (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model the client's `BoardCard` draws (`source: 'task'`, `data-key`). Tests: `core/test/board-card.test.js`. | | `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/shared/workflow-run.js` | The running layer's pure half (#1316, #1272 step 4). `planJob({ definition, job, cards, facts, hold })` answers which node cards a job may create now: every decision is `enterNode`'s, so a node whose input is missing, stale or unreadable opens nothing and its refusal is that function's own sentence byte for byte, and a node that already has a card is in neither list — a card parented to another job is not this job's card, so `cardsOfJob` is what makes `(job, node)` one card across a replayed tick or a restarted host, even when a caller hands in the whole board. An open entry is the card the board already places (`parentId` the job card, `workflow`, `node`) plus the fields the pull queue reads (`role`, `queueRole`, `assignee: null`), so the hand-off is the claim and never an `@`. A held job opens nothing on any node, in the definition's own sentence: the planner renders that definition's `no-hold` guard (`pr` equal to `none`, refused for `hold`) through `renderRefusal`, which is the merge gate's wording byte for byte, and a definition guarding no held pull request gets `refusal: null` and the holder's name instead of a sentence this layer wrote. `jobDone` answers pin 6 — a job is done when its terminal node's outputs are present and current, never because a card moved — and `completions` is step (c)'s pure half: a done card whose declared outputs are current, append-only, an entry already in `history` never returned again. No fs, no clock, no timer, no network, no host import: `task-drive.js` `driveOnce` stays the only beat. Tests: `core/test/workflow-run.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-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()` (the presets plus the workspace files) 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. | @@ -111,12 +108,9 @@ 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. +definition produces identical bytes. The per-Bot task board does not draw a column per workflow node (#1363). A definition still +names `column` on each node, and the running layer opens a task card for a node that may start. +That card sits on the task board by its task status (Not started / In progress / Done). **Step 4 is the running layer** (#1316). `workflow-run.js` is the one place a job becomes node cards, and it decides nothing: `enterNode` says whether a node may open on the facts just read, and a @@ -129,9 +123,8 @@ stale one refuses with both revisions. A held job opens nothing on any node and definition's own sentence — the `no-hold` guard, rendered with the fact it expects (`pr: hold`), which is the merge gate's wording and not a second copy of it; a definition that guards no held pull request gets the holder's name and no sentence, because this layer writes none. A hold names its holder or it is no hold at all (#1337): a blank string — the shape “the pull-request query answered nothing” arrives in — and a hold no name can be read from plan exactly like no hold, so a job is never refused whole with no sentence to show, while a named holder still refuses every card-less node in the definition's own sentence. A job is done when its terminal -node's outputs are current, never because a card was moved or a merge was claimed. The host writer -belongs to #1265 step 3, so that the parent is the real issue card; until it lands the planner is -driven by fixture cards. +node's outputs are current, never because a card was moved or a merge was claimed. Hanging that card under a GitHub issue was the delivery board (#1265) and is not +built (#1363). The planner still answers which node cards may open. ### Default harness and usage ledger (#903) @@ -278,7 +271,7 @@ Each Bot's work sits on the same `state.tasks` records. A column is read from th A group job is one overall task plus a subtask per Bot (#1099). The overall task is `kind: 'group'`, owned by the Bot that records it, and tagged with `origin.roomId`. A group keeps one open overall task; recording another updates that one. A subtask sets `parentId` to that task. One Bot divides the board (#1182): `room.taskLead` when that Bot is still a member, otherwise the group admin. A face-to-face room has no board, so it has no task lead. Another Main Bot does not plan. The user, a Main Bot, or the group's admin sets the lead with `update_group` (`task_lead`, `none` clears it) or `update-room` (`taskLead`); group settings shows **Make task lead** / **Use the admin** on a group that lives here. Assigning a subtask to another member claims it (`open` → `claimed`) so the assignee starts without a confirmation, and wakes that assignee in its own chat (#1205); the assignment does not open a group round. The assignee then sets the column to in-progress with `track_tasks`; a chat line does not assign, claim, or record progress. The group board shows that above the columns. The requester stays that Bot's owner, so the lead still cannot move the card through `task-update`. A member updates the card assigned to it, records its own room task, or links its own subtask to the overall task. Only the lead records the overall task or assigns a subtask to someone else. When every child has ended, the overall card completes through the same transition a person's move uses: `accepted` with `progress` 100 when each child ended accepted or cancelled, `failed` when any child ended failed, so a failed part is never hidden behind a green overall card; an overall card already blocked or waiting on a result is left to its owner (#1252). A child also ends when its Bot is deleted (`failed`) or when the owner rejects the send it was waiting on (`cancelled`); both doors settle the overall card the same way, so the card a person sees is closed rather than left working. -A lead can put work in a pull queue instead of naming an assignee (#1247). `track_tasks { tasks: [{ queue: true, role: 'Dev', priority, title }] }` records one item for that role: `queueRole` names it, `queueBy` is the lead, and `ownerBotId` stays the enqueuer until a claim, so an unclaimed item wakes nobody — `task-drive` skips it. Only the room's lead enqueues, and an item whose role no live member of that room carries is refused (`no-member-with-role`) instead of sitting for ever for a role nobody has. `track_tasks { claim: true }` takes the highest-priority item of the caller's own role, oldest first on a tie (`compareForQueue`, not `compareForWork`, whose tiebreak is newest first), and `id` claims a named item; a member of another role is refused by name (`not-in-role`), never silently. One member holds one card in progress, so a member already holding one gets nothing — a blocked card waits on a person and does not count. The claim is compare-and-set: the item is read again after the pick yields, and only one still open is taken, so two claims leave exactly one owner. `track_tasks { release: { task, reason, retryInSec } }` returns a claimed item (`blocked → open` included) with `retryAt`, and it is not offered again before then. A claim sets `ownerBotId` and `claimedBy` to the member and keeps `queueRole`, so the card the board, the delivery view and room presence read is the same card: claiming is a board write, not a second row. Tests: `core/test/task-queue.test.js`. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. `ask` walks the task to working and then to the existing input wait (`waitingFor: 'input'`); the reply tells the Bot to call `ask_user` for the user, and to post one short question in the group only when a person there has to decide. `answer` resumes that wait. The question is the same record the inbox already shows for a blocked task. +A lead can put work in a pull queue instead of naming an assignee (#1247). `track_tasks { tasks: [{ queue: true, role: 'Dev', priority, title }] }` records one item for that role: `queueRole` names it, `queueBy` is the lead, and `ownerBotId` stays the enqueuer until a claim, so an unclaimed item wakes nobody — `task-drive` skips it. Only the room's lead enqueues, and an item whose role no live member of that room carries is refused (`no-member-with-role`) instead of sitting for ever for a role nobody has. `track_tasks { claim: true }` takes the highest-priority item of the caller's own role, oldest first on a tie (`compareForQueue`, not `compareForWork`, whose tiebreak is newest first), and `id` claims a named item; a member of another role is refused by name (`not-in-role`), never silently. One member holds one card in progress, so a member already holding one gets nothing — a blocked card waits on a person and does not count. The claim is compare-and-set: the item is read again after the pick yields, and only one still open is taken, so two claims leave exactly one owner. `track_tasks { release: { task, reason, retryInSec } }` returns a claimed item (`blocked → open` included) with `retryAt`, and it is not offered again before then. A claim sets `ownerBotId` and `claimedBy` to the member and keeps `queueRole`, so the card the board and room presence read is the same card: claiming is a board write, not a second row. Tests: `core/test/task-queue.test.js`. Reading the list orders unfinished work by priority (urgent, high, normal, low) and prefers an in-progress task when the priority is the same. `ask` walks the task to working and then to the existing input wait (`waitingFor: 'input'`); the reply tells the Bot to call `ask_user` for the user, and to post one short question in the group only when a person there has to decide. `answer` resumes that wait. The question is the same record the inbox already shows for a blocked task. The claim is public (#1253). When a card in a room moves out of `open` into `claimed` or `working` — a member claims it from the queue, or the lead assigns it — `groups.js` `postClaimLine` appends one short line to the room log in that member's voice, the one the room notice asks for: `接了 #1253,第一步是 `. The reference is the issue number the title carries (the title itself when it has none), the first step is the card's own note and never an invented one, and a card with no note still names itself. The line is appended to the log and **not** posted into the room's Session: no followup, no model turn, no round — the member's own work still arrives as its own-chat wake (#1205), and the two never merge. An identical line for the same card inside `CLAIM_CUE_REPEAT_MS` (30 minutes) is not posted again, so a re-claim stays quiet, and progress writes on a card the member already holds never reach the room: the claim is the beacon, the card is the progress. The record keeps the last cue as `claimCue` (`{ seq, at, text }`), which `view` leaves out. Tests: `core/test/claim-cue.test.js`. @@ -288,12 +281,6 @@ Deciding such a task leaves one comment for its Bot to post. `task-accept-result `tasks` without a filter returns tasks the actor requested or whose Bot they own, including a descendant (`createdBy`) they do not themselves own. `tasks { botId }` is that Bot's list under the same rule. `tasks { roomId }` is the group view: a current human admin, or the owner of `room.admin`, sees tasks member Bots tagged with that group, and nobody else does. A room that never grew `participants` is read through `deriveParticipants`, so its `members` still count. A plain list does not include another Bot's group tasks. Names come back as `ownerBotName`, and a subtask also carries `parentTitle`. Tests: `core/test/bot-tasks.test.js`. -### Delivery board (#1173) - -The dialog's **Delivery** view is one snapshot of this repository's GitHub work, not a Bot's task board. `src/host/delivery-board.js` reads issues, pulls and workflow runs, lays them into `raw`, `ready`, `working`, `review` and `done-today`, and caches each URL with its ETag; a stale snapshot is still served while a refresh runs, and a `304` reuses the cached body. It installs no timer: the browser asks (`delivery-board`, optional `force`) and the host answers. Since #1211 a read is a state, not a board or nothing: the first one returns `loading` at once while the round runs in the background, so no request is parked on a whole round behind the reverse proxy; a round that cannot finish writes `failed` with the endpoint, the advice and the settings link, an older snapshot that could not be refreshed is `stale` and keeps its columns with the reason beside them, and a round past the 90 s budget lands as `partial` with what it read. A cell that could not be read degrades by itself — CI `unknown` rather than `0` — and a failed round waits 10 s, then 30 s, then two minutes before the next automatic attempt. - -The module is read-only by construction: every request is a `GET`, and the payload carries no token. Issues are placed by state label (`agent-working` → working, `agent-ready` → ready, otherwise raw), pull requests in review, and pull requests merged, and issues or pull requests closed, since the Asia/Shanghai day start in done today (a closed pull request counts whether or not it merged). A pull request card shows the newest `Verdict:` on its own tip — a verdict that names an older tip is ignored and CI speaks instead — plus the tip sha. The header reports today and the 7-day window, medians for issue close and pull merge, the open counts, and CI as `counted` of `total` (the list is capped, and the payload says so rather than pretending the tail does not exist). That header also carries `metrics.daily` (#1222): the last seven Asia/Shanghai days, oldest first and today last as `partial`, with a per-day CI block whose denominator leaves out cancelled runs. A day that could not be read is `null`, never `0`, and a week that could not be read leaves the series `null`. `authz.js` allows the endpoint to any signed-in member: it reads one repository, not an owner's Bots. Channel `READS` carries `delivery-board` and `delivery-probe` (#1225) for the same reason: a window that only reads the team — another dsh holds `state.lock` — answers the board and its probe instead of refusing them with `bot/read-only`, while every write in that window stays refused. `core/test/home-lock.test.js` holds both halves: the two reads answer and `set-prefs` / `secret-set` are still `bot/read-only`. - ### Task-driven turns (#1109) Unfinished conversation tasks wake the owning Bot. `src/host/task-drive.js` runs after a board write and after a turn ends. For each owner, the next task is the one `compareForWork` would name: `open`, `claimed`, or `working`, not `waitingFor: 'input'`, and not an overall group card (`kind: 'group'`). A decision whose comment is still `pending` counts as work even when the task is already `accepted`, and the wake says where to post it (#1154). That card is the plan; the subtasks are the work. One drive pass wakes every such owner, so a group assignment starts each member's own subtask in that same pass. An unclaimed queue item is offered, not driven (#1247): one idle member of its role that does not already hold a card in progress is woken in that member's own chat, one item per room and role per pass, one Session gets at most one task wake in a pass, and the cue carries the item's id, priority and title — not its note, which is the body. A claimed item is then a normal card above. One Bot still receives only its own next task. A task with no group wakes the Bot's own chat. The wake is a follow-up whose header is `[Continue task]` (`delivery.js`, role `task`). It names the task id, priority, and title. It is not a group post, so the reply is not posted back and does not start a round. A group subtask wakes that same own chat (#1205). It does not open the Bot's group Session and it does not start a round. The cue says to set the card's column and progress with `track_tasks`, not to post a status in the group, and not to wait for a confirmation. A question a person has to decide is one short post in the group; the wake's own reply stays in the chat and is not posted into the room. Completing or updating the card is what wakes the next assignee. The wake text also carries that room's compact board snapshot (#1203), the same one a round turn shows, so the Bot reads progress from the board. A Bot @ in the group whose open card already shows that line is a pass (`boardCoversCue` in `room-context.js`, applied in the round before a member turn): the round records it as passed and does not start a turn. A question, a line the card does not already say, and a person's @ still get a turn. diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index a27c6126d..74b5d492e 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -38,7 +38,6 @@ Tooling for setup checks, the architecture-docs gate and the container image (#3 | `core/scripts/install-doctor.mjs` | Install doctor: Node floor, the packages (`core`, `plugins/social`, `plugins/tree`, `plugins/workflow`, `plugins/timeline`) and the install commands (core, social, tree, workflow; `@flybot/timeline` is opt-in and not in the default commands, #281). | | `core/scripts/local-ci.sh` | Local run of CI's steps, including each plugin's suite. | | `core/scripts/replay.mjs` | Dev-only state-journal replay (#511): rebuilds `state.json` up to an entry from `/journal/state.jsonl`; no network or process calls, output is a new 0600 file or stdout, a bad journal is refused whole. See [core-host](core-host.md). | -| `core/scripts/delivery-probe.mjs` | Which GitHub endpoint the delivery board's token cannot read (#1211): calls the host's own `probeGitHub()` — the function behind the header's **Diagnose** button — and prints one line per endpoint (`/repos/{repo}`, `/actions/runs`, `/commits/{sha}/check-runs`, `/issues/{n}/comments`, `/pulls`), status plus the setting to fix. `--repo` (repeatable) and `--token-env` (default `GITHUB_TOKEN`); since #1250/#1265 it resolves its repositories through the host's own `resolveRepos()` (`core/src/host/delivery-repo.js`): every `--repo`, else the `[remote "origin"]` of `--workspace ` (default the current folder), else the package's `repository.url` — it probes each one the board would read and prints that repository and its source next to the name, so the CLI and the page cannot disagree. A thin shell, never a second implementation. | | `Dockerfile` | Packs `core` and the plugins (social, tree, workflow, timeline) with `check-pack.mjs` (the release tarballs), then a runtime with DSH (pinned like the README), pnpm and tini, as user `node`, one volume `/data`. | | `compose.yaml` | One instance: port on the host's `127.0.0.1`, volume `data`, `DEEPSEEK_API_KEY` passed through only when set. | | `docker/entrypoint.mjs`, `docker/profile.mjs` | Each start links the image's packages into `/data/profiles/web` (other plugins and `cordis.patch.yml` kept), `pnpm install`, then `dsh web` in `/data/workspace`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 700b64e22..20d0c03ea 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1366,92 +1366,12 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js 'The admin must be a member of the group': '管理员必须是群成员', 'Unknown group chat': '找不到这个群聊', 'Hand the admin role to another member instead of leaving the group without one': '请把管理员交给其他成员,不要让群聊没有管理员', -@@ -905,6 +905,88 @@ const HOST_ZH = { +@@ -905,6 +905,8 @@ const HOST_ZH = { 'Write the entry to save.': '请写下要保存的内容。', 'This DeepSeek Harness has no folder chooser': '这个 DeepSeek Harness 没有文件夹选择窗口', 'This DeepSeek Harness cannot list folders': '这个 DeepSeek Harness 不能列出文件夹', + 'No FlyBot core update is available': '没有可用的 FlyBot 核心更新', + 'saved by a newer FlyBot; update FlyBot': '由更新的 FlyBot 保存;请更新 FlyBot', -+ // #1173: the delivery board — one repository-wide snapshot beside the per-Bot task board. -+ 'Delivery': '交付', -+ 'Board view': '看板视图', -+ 'Original issue': '原始单', -+ 'Ready to start': '待开工', -+ 'In review': '审查中', -+ 'Done today': '今天完成', -+ 'INC · rework': '打回 · 返工', -+ 'PASS · waiting for enqueue': '通过 · 待入队', -+ 'PASS · waiting for merge': '通过 · 待合入', -+ 'CI red': 'CI 红', -+ 'CI running': 'CI 跑着', -+ 'No verdict': '还没结论', -+ 'New issues': '新单', -+ 'Closed issues': '关单', -+ 'New pull requests': '新 PR', -+ 'Merged pull requests': '合入 PR', -+ 'Median issue close': '关单中位', -+ 'Median PR merge': '合入中位', -+ 'Today': '今天', -+ 'No GitHub connection yet; the board fills in once one is added.': '还没接 GitHub,接上后这里就有数了。', -+ 'The delivery board is not ready yet.': '交付看板还没准备好。', -+ 'Showing the last snapshot from {time}: {error}': '显示 {time} 的快照:{error}', -+ 'linked to #{number}': '关联 #{number}', -+ 'open on GitHub': '在 GitHub 上打开', -+ 'CI failed {failed} of {total}': 'CI 红 {failed} / 共 {total}', -+ 'counted {counted} of {total}': '已统计 {counted} / 共 {total}', -+ '{count} superseded': '{count} 条被取代', -+ 'Open: {issues} issues ({needs} need a human), {prs} pull requests': '开着:{issues} 张单({needs} 张等人),{prs} 个 PR', -+ 'median open age {age}': '开着的单中位 {age}', -+ 'as of {time}': '截至 {time}', -+ 'refreshing…': '刷新中…', -+ // #1212: the 7-day lines. A day the host could not read, and a snapshot with no series at all. -+ 'not available': '未取到', -+ 'No 7-day data yet.': '7 天数据未取到', -+ '{label} over the last 7 days': '{label}近 7 天', -+ 'Last 7 days': '近 7 天', -+ 'so far': '截至现在', -+ 'today is partial': '今天为部分日', -+ '* {count} of 7 days had data': '* 7 天中 {count} 天有数', -+ '* {count} of {days} days had data': '* {days} 天中 {count} 天有数', -+ 'CI red share': 'CI 红占比', -+ 'No snapshot yet.': '还没有快照。', -+ 'Nothing here.': '这里还没有。', -+ 'First load in progress, {seconds} s elapsed': '首次加载中,已 {seconds} 秒', -+ 'Showing the snapshot from {time} · refresh failed: {message}': '显示 {time} 的快照 · 刷新失败:{message}', -+ 'The first round reads the repository once; the columns appear as soon as it lands.': '首轮要把这个仓读一遍,列会在拿到的同时出现。', -+ 'While this page is open it retries on its own; press Retry to try again now.': '页面开着时自动重试,也可点重试立即再试。', -+ 'Retrying in {seconds} s · press Retry to try again now': '{seconds} 秒后自动重试,也可点重试立即再试。', -+ 'Checks unknown': '检查结果未取到', -+ 'CI not available this time': '本次未取到 CI 数据', -+ '{count} day(s) not read': '{count} 天没取到', -+ 'Diagnose': '诊断', -+ 'Checking…': '检查中…', -+ Copy: '复制', -+ Close: '关闭', -+ 'this repository': '这个仓', -+ // #1250: whose repository the board read, and where that name came from — a package default -+ // shown as a fact is what put one instance's numbers on another. -+ 'no repository — set delivery.repo': '没有仓库 —— 请设置 delivery.repo', -+ '{count} repositories': '{count} 个仓库', -+ 'from config': '来自 config', -+ 'from remote': '来自 remote', -+ 'from the package (default)': '来自安装包(默认值)', -+ 'Set delivery.repo in this instance\'s settings to the repository this instance works in.': '在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。', -+ 'Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.': '在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。', -+ 'GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).': 'GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。', -+ 'GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.': 'GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。', -+ 'GitHub did not answer in time; the next attempt happens while this page is open.': 'GitHub 没在时限内回应,页面开着时下一次重试会自动进行。', -+ 'Give the token Actions: Read.': '去设置里给 token 勾 Actions: Read。', -+ 'Give the token Checks: Read.': '去设置里给 token 勾 Checks: Read。', -+ 'Give the token Issues: Read.': '去设置里给 token 勾 Issues: Read。', -+ 'Give the token Pull requests: Read.': '去设置里给 token 勾 Pull requests: Read。', -+ 'Check the repository name, and that the token can see {repo}.': '检查仓库名对不对、token 有没有 {repo} 的访问权。', -+ 'GitHub is not answering this instance right now; retry in a moment.': 'GitHub 现在没回应这个实例,稍后重试。', -+ 'The next attempt happens while this page is open; the reason above names what failed.': '页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。', -+ '{part} was not fetched this time: {message}': '{part}这次没取到:{message}', -+ 'CI data': 'CI 数据', -+ 'The rest of the board': '看板剩下的部分', -+ 'Part of the board': '看板的一部分', } Object.assign(ZH, HOST_ZH) @@ -8472,7 +8392,7 @@ diff --git a/test/home-lock.test.js b/test/home-lock.test.js return call } -@@ -185,3 +185,135 @@ test('a reading host follows the other host\'s keys, never writes them, and keep +@@ -185,3 +185,129 @@ test('a reading host follows the other host\'s keys, never writes them, and keep const saved = JSON.parse(readFileSync(secretsPath, 'utf8')).secrets.map(secret => secret.name).sort() assert.deepEqual(saved, ['FIRST_KEY', 'MINE', 'SECOND_KEY', 'THIRD_KEY']) }) @@ -8584,27 +8504,21 @@ diff --git a/test/home-lock.test.js b/test/home-lock.test.js + assert.deepEqual(fb.remote.list(), []) +}) + -+test('#1225: a reading host answers the delivery board and its probe, and still refuses a write', async (t) => { ++test('#1363: the delivery board endpoints are gone, and a reading host still refuses a write', async (t) => { + const home = tempHome(t) + writeFileSync(join(home, 'state.json'), JSON.stringify(TEAM)) + writeFileSync(join(home, 'state.lock'), JSON.stringify({ pid: process.ppid, host: hostname(), token: 'other' })) + const call = start(t, home) -+ // The probe asks GitHub five times; answer them here so the test reads the gate, not the network. -+ call.rt.fetch = async (url) => new Response( -+ String(url).includes('/pulls') ? JSON.stringify([{ number: 7, head: { sha: 'abc1234' } }]) : '{}', -+ { status: 200, headers: { 'content-type': 'application/json' } }, -+ ) + assert.match((await call('state', {})).value.readOnly, /open in another dsh/) + -+ // Both read: the window that only reads the team still answers them instead of `bot/read-only`. -+ const board = await call('delivery-board', {}) -+ assert.equal(board.error, undefined, `delivery-board was refused: ${JSON.stringify(board.error)}`) -+ assert.notEqual(board.value, undefined) -+ const probe = await call('delivery-probe', {}) -+ assert.equal(probe.error, undefined, `delivery-probe was refused: ${JSON.stringify(probe.error)}`) -+ assert.equal(probe.value.ok, true) ++ // Neither name is a read any more: a window that only reads the team refuses them, and ++ // neither answer is a snapshot. ++ for (const endpoint of ['delivery-board', 'delivery-probe']) { ++ const answer = await call(endpoint, {}) ++ assert.equal(answer.value, undefined, `${endpoint} still answered`) ++ assert.equal(answer.error?.code, 'bot/read-only') ++ } + -+ // The list stays a list of reads: the same window still refuses a change. + assert.equal((await call('set-prefs', { theme: 'dark' })).error.code, 'bot/read-only') + assert.equal((await call('secret-set', { name: 'MINE', value: 'mine-value-1' })).error.code, 'bot/read-only') +}) diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index 3ce1a61b3..4cda8f523 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,17 @@ import * as images from './src/host/images.js' +@@ -24,11 +24,16 @@ 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' @@ -38,7 +38,6 @@ diff --git a/index.js b/index.js 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' @@ -48,7 +47,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, workflowStore, 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, 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 55d30e250..36c967ab8 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1476,7 +1476,7 @@ diff --git a/src/client/index.js b/src/client/index.js stopMemberWork: (roomId, botId) => after(call('stop-member-work', { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: payload => after(call('secret-set', payload)), -@@ -539,15 +539,35 @@ export function apply(ctx) { +@@ -539,15 +539,33 @@ export function apply(ctx) { secretCancel: requestId => after(call('secret-cancel', { requestId })), secretUpdate: (name, scope) => after(call('secret-update', { name, scope })), secretDelete: name => after(call('secret-delete', { name })), @@ -1500,8 +1500,6 @@ diff --git a/src/client/index.js b/src/client/index.js + setBotConnector: (botId, connector, enabled) => after(call('bot-connectors', { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call('bot-capability', { botId, name, attached })), + tasks: (payload) => call('tasks', payload ?? {}), -+ deliveryBoard: (payload) => call('delivery-board', payload ?? {}), -+ deliveryProbe: (payload) => call('delivery-probe', payload ?? {}), + taskUpdate: (payload) => call('task-update', payload), + taskCreate: (payload) => call('task-create', payload), schedules: () => call('schedules'), diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 204e1d754..f44bb5377 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -454,7 +454,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js defaultModel: rt.cachedDefault() ?? null, permissions: rt.permissionCatalog?.() ?? null, modelsKey: modelsKey(), -@@ -65,22 +65,51 @@ export function install(rt) { +@@ -65,22 +65,49 @@ export function install(rt) { memory: rt.memoryEnabled !== false, // Set while another dsh host holds the team (store.js): why this one cannot change it. readOnly: rt.readOnly() ?? null, @@ -494,9 +494,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js // Updates change the dsh profile, not the team, so a reading host may run them too. - const READS = new Set(['state', 'models', 'efforts', 'earlier', 'room-progress', '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 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', 'workflows']) ++ 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', '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). @@ -828,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,126 @@ export function install(rt) { +@@ -319,32 +319,120 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -845,12 +843,6 @@ diff --git a/src/host/channel.js b/src/host/channel.js + case 'task-answer-input': + await rt.load() + return ok(await rt.taskEndpoint(endpoint, body, actor)) -+ case 'delivery-board': -+ await rt.load() -+ return ok(await rt.deliveryBoard(body)) -+ 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). diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e3a361516..7873f1567 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -797,94 +797,14 @@ diff --git a/client.js b/client.js "The admin must be a member of the group": "管理员必须是群成员", "Unknown group chat": "找不到这个群聊", "Hand the admin role to another member instead of leaving the group without one": "请把管理员交给其他成员,不要让群聊没有管理员", -@@ -951,7 +951,89 @@ var HOST_ZH = { +@@ -951,7 +951,9 @@ var HOST_ZH = { "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", - "This DeepSeek Harness cannot list folders": "这个 DeepSeek Harness 不能列出文件夹" + "This DeepSeek Harness cannot list folders": "这个 DeepSeek Harness 不能列出文件夹", + "No FlyBot core update is available": "没有可用的 FlyBot 核心更新", -+ "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot", -+ // #1173: the delivery board — one repository-wide snapshot beside the per-Bot task board. -+ "Delivery": "交付", -+ "Board view": "看板视图", -+ "Original issue": "原始单", -+ "Ready to start": "待开工", -+ "In review": "审查中", -+ "Done today": "今天完成", -+ "INC · rework": "打回 · 返工", -+ "PASS · waiting for enqueue": "通过 · 待入队", -+ "PASS · waiting for merge": "通过 · 待合入", -+ "CI red": "CI 红", -+ "CI running": "CI 跑着", -+ "No verdict": "还没结论", -+ "New issues": "新单", -+ "Closed issues": "关单", -+ "New pull requests": "新 PR", -+ "Merged pull requests": "合入 PR", -+ "Median issue close": "关单中位", -+ "Median PR merge": "合入中位", -+ "Today": "今天", -+ "No GitHub connection yet; the board fills in once one is added.": "还没接 GitHub,接上后这里就有数了。", -+ "The delivery board is not ready yet.": "交付看板还没准备好。", -+ "Showing the last snapshot from {time}: {error}": "显示 {time} 的快照:{error}", -+ "linked to #{number}": "关联 #{number}", -+ "open on GitHub": "在 GitHub 上打开", -+ "CI failed {failed} of {total}": "CI 红 {failed} / 共 {total}", -+ "counted {counted} of {total}": "已统计 {counted} / 共 {total}", -+ "{count} superseded": "{count} 条被取代", -+ "Open: {issues} issues ({needs} need a human), {prs} pull requests": "开着:{issues} 张单({needs} 张等人),{prs} 个 PR", -+ "median open age {age}": "开着的单中位 {age}", -+ "as of {time}": "截至 {time}", -+ "refreshing…": "刷新中…", -+ // #1212: the 7-day lines. A day the host could not read, and a snapshot with no series at all. -+ "not available": "未取到", -+ "No 7-day data yet.": "7 天数据未取到", -+ "{label} over the last 7 days": "{label}近 7 天", -+ "Last 7 days": "近 7 天", -+ "so far": "截至现在", -+ "today is partial": "今天为部分日", -+ "* {count} of 7 days had data": "* 7 天中 {count} 天有数", -+ "* {count} of {days} days had data": "* {days} 天中 {count} 天有数", -+ "CI red share": "CI 红占比", -+ "No snapshot yet.": "还没有快照。", -+ "Nothing here.": "这里还没有。", -+ "First load in progress, {seconds} s elapsed": "首次加载中,已 {seconds} 秒", -+ "Showing the snapshot from {time} · refresh failed: {message}": "显示 {time} 的快照 · 刷新失败:{message}", -+ "The first round reads the repository once; the columns appear as soon as it lands.": "首轮要把这个仓读一遍,列会在拿到的同时出现。", -+ "While this page is open it retries on its own; press Retry to try again now.": "页面开着时自动重试,也可点重试立即再试。", -+ "Retrying in {seconds} s · press Retry to try again now": "{seconds} 秒后自动重试,也可点重试立即再试。", -+ "Checks unknown": "检查结果未取到", -+ "CI not available this time": "本次未取到 CI 数据", -+ "{count} day(s) not read": "{count} 天没取到", -+ "Diagnose": "诊断", -+ "Checking…": "检查中…", -+ Copy: "复制", -+ Close: "关闭", -+ "this repository": "这个仓", -+ // #1250: whose repository the board read, and where that name came from — a package default -+ // shown as a fact is what put one instance's numbers on another. -+ "no repository — set delivery.repo": "没有仓库 —— 请设置 delivery.repo", -+ "{count} repositories": "{count} 个仓库", -+ "from config": "来自 config", -+ "from remote": "来自 remote", -+ "from the package (default)": "来自安装包(默认值)", -+ "Set delivery.repo in this instance's settings to the repository this instance works in.": "在这个实例的设置里把 delivery.repo 指向本实例实际工作的仓库。", -+ "Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.": "在这个实例的设置里填一个 GITHUB_TOKEN,要能读 {repo}。", -+ "GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).": "GitHub 在限流这个实例,页面开着时自动重试(10 秒 → 30 秒 → 2 分钟)。", -+ "GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.": "GitHub 在限流这个实例,页面开着时 {seconds} 秒后自动重试。", -+ "GitHub did not answer in time; the next attempt happens while this page is open.": "GitHub 没在时限内回应,页面开着时下一次重试会自动进行。", -+ "Give the token Actions: Read.": "去设置里给 token 勾 Actions: Read。", -+ "Give the token Checks: Read.": "去设置里给 token 勾 Checks: Read。", -+ "Give the token Issues: Read.": "去设置里给 token 勾 Issues: Read。", -+ "Give the token Pull requests: Read.": "去设置里给 token 勾 Pull requests: Read。", -+ "Check the repository name, and that the token can see {repo}.": "检查仓库名对不对、token 有没有 {repo} 的访问权。", -+ "GitHub is not answering this instance right now; retry in a moment.": "GitHub 现在没回应这个实例,稍后重试。", -+ "The next attempt happens while this page is open; the reason above names what failed.": "页面开着时下一次重试会自动进行,上面那句话说明是哪一步失败了。", -+ "{part} was not fetched this time: {message}": "{part}这次没取到:{message}", -+ "CI data": "CI 数据", -+ "The rest of the board": "看板剩下的部分", -+ "Part of the board": "看板的一部分" ++ "saved by a newer FlyBot; update FlyBot": "由更新的 FlyBot 保存;请更新 FlyBot" }; Object.assign(ZH, HOST_ZH); var fill = (text, params) => params ? text.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match) : text; @@ -1441,12 +1361,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, h49, tilt]) => ``; -+var happyEyeMarkup = ([cx, cy, w, h49, tilt]) => ``; ++var eyeMarkup = ([cx, cy, w, h47, tilt]) => ``; ++var happyEyeMarkup = ([cx, cy, w, h47, 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,220 +1242,96 @@ function maskOf(shape) { +@@ -1242,162 +1242,16 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1469,16 +1389,7 @@ 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) { @@ -1493,87 +1404,24 @@ 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 = { @@ -1676,80 +1524,59 @@ 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; + } - --// 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; ++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; + let out = ""; + let cursor = 0; +- for (const span of mentionSpans(text, bots)) { + 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; - } - return cursor === 0 ? text : out + text.slice(cursor); - } + 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; @@ -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; @@ -1866,7 +1693,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,7183 @@ function usageOf(list, ids) { +@@ -1624,772 +1624,1724 @@ 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 }); } @@ -2127,11 +1954,27 @@ 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) { @@ -2140,8 +1983,108 @@ 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"]; @@ -2160,14 +2103,11 @@ 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"); @@ -2181,9 +2121,7 @@ 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)); @@ -2263,22 +2201,17 @@ 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)(() => { -- if (value) { -- setKept(value); ++ (0, import_react.useEffect)(() => { + const was = prev.current; + prev.current = state; + if (state !== "idle") { + setSettled(false); - return void 0; - } -- const timer = setTimeout(() => setKept(value), reducedMotion() ? 0 : ms); ++ return void 0; ++ } + if (was === "idle" || was === "alert") return void 0; + setSettled(true); + const timer = setTimeout(() => setSettled(false), 1800); - return () => clearTimeout(timer); -- }, [value]); -- return [value || kept, !value && Boolean(kept)]; ++ return () => clearTimeout(timer); + }, [state]); + return settled; +} @@ -2371,109 +2304,8 @@ diff --git a/client.js b/client.js + return (0, import_react.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + }) + ); - } ++} --// 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} @@ -2549,29 +2381,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: h49, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { -+ if (!preview || typeof h49 !== "function") return null; ++function RoomInvitePreview({ h: h47, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h47 !== "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 h49( ++ return h47( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, -+ 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( ++ h47("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h47("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h47("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h47("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h47( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, -+ showClear && onClear ? h49("button", { ++ showClear && onClear ? h47("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, -+ onJoin ? h49("button", { ++ onJoin ? h47("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), @@ -2800,28 +2632,6 @@ 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); @@ -3080,6 +2890,28 @@ 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} @@ -3114,35 +2946,7 @@ 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} @@ -3193,17 +2997,7 @@ 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} @@ -3263,36 +3057,7 @@ 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} @@ -3307,7 +3072,35 @@ 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} @@ -3316,19 +3109,129 @@ 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/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" } -+ }, ++// 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) } : {} ++ })); ++}; + +-.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} +-.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px} +-.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear} +-.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8} +-.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite} +-.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite} +-@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}} +-@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}} +-.bt-voice-orb>.bt-mark{position:relative} +-.bt-voice-name{font-size:16px;line-height:22px;font-weight:500} +-.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px} +-.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden} +-.bt-voice-note{font-size:12px;color:#e02135} +-.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease} +-.bt-voice-setup:hover{background:var(--bt-active)} +-.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px} +-.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease} +-.bt-voice-btn:hover{background:var(--bt-active)} +-.bt-voice-btn:active{transform:scale(.94)} +-.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)} +-.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)} +-.bt-voice-end{background:#ff3e51;color:#fff} +-.bt-voice-end:hover{background:#e02135} +-@keyframes bt-fade{from{opacity:0}to{opacity:1}} +-@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}} +-@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}} +-.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))} +-/* The bubble's row: reactions hang below it, so the hover toolbar (and a group +- sender's avatar) line up with the bubble alone. */ +-.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%} +-.bt-msg-line>.bt-bubble{max-width:none;min-width:0} +-/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */ +-.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))} +-.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s} +-.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1} +-.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible} +-.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease} +-.bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} +-.bt-tool:active{background:var(--bt-active)} +-.bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} +-.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} +-.bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} +-.bt-react-strip button:nth-child(2){animation-delay:20ms} +-.bt-react-strip button:nth-child(3){animation-delay:40ms} +-.bt-react-strip button:nth-child(4){animation-delay:60ms} +-.bt-react-strip button:nth-child(n+5){animation-delay:80ms} +-.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)} +-.bt-react-strip button:active{transform:scale(.92)} +-.bt-reacts{display:flex;gap:4px;margin-top:4px} +-.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-.bt-react:hover{background:var(--bt-hover)} +-.bt-react:active{scale:.92} +-@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}} +-.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none} +-.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease} +-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 })}`) ++ )) ++ ); ++} ++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} +-.bt-menu button:hover{background:var(--bt-hover)} +-.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} +-.bt-menu .bt-danger{color:#c21d2e} +-.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px} +-.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in} +-.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} +-.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +-.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} ++// 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" }, @@ -3420,66 +3323,7 @@ diff --git a/client.js b/client.js + }; +} +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} --.bt-voice-orb{position:relative;width:200px;height:200px;display:grid;place-items:center;margin-bottom:8px} --.bt-voice-ring{position:absolute;inset:34px;border-radius:50%;background:var(--bt-hover);transform:scale(calc(1 + var(--bt-level,0) * .55));transition:transform .08s linear} --.bt-voice-ring-2{inset:14px;background:none;box-shadow:inset 0 0 0 1px var(--bt-line-2);transform:scale(calc(.94 + var(--bt-level,0) * .3));opacity:.8} --.bt-voice-stage[data-phase=thinking] .bt-voice-ring{animation:bt-voice-breathe 1.6s ease-in-out infinite} --.bt-voice-stage[data-phase=speaking] .bt-voice-ring{animation:bt-voice-speak .9s ease-in-out infinite} --@keyframes bt-voice-breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}} --@keyframes bt-voice-speak{0%,100%{transform:scale(1.04)}30%{transform:scale(1.2)}60%{transform:scale(1.1)}} --.bt-voice-orb>.bt-mark{position:relative} --.bt-voice-name{font-size:16px;line-height:22px;font-weight:500} --.bt-voice-status{font-size:13px;line-height:18px;color:var(--bt-ink-2);min-height:18px} --.bt-voice-caption{min-height:44px;font-size:15px;line-height:22px;color:var(--bt-ink);animation:bt-blur-in .25s ease-out;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden} --.bt-voice-note{font-size:12px;color:#e02135} --.bt-voice-setup{height:32px;padding:0 14px;border-radius:16px;border:0;background:var(--bt-hover);color:var(--bt-ink);font:inherit;font-size:13px;cursor:pointer;transition:background-color .15s ease} --.bt-voice-setup:hover{background:var(--bt-active)} --.bt-voice-bar{display:flex;gap:16px;padding:0 0 32px} --.bt-voice-btn{position:relative;width:52px;height:52px;border-radius:50%;border:0;background:var(--bt-hover);color:var(--bt-ink);display:grid;place-items:center;cursor:pointer;transition:background-color .15s ease,transform .12s ease} --.bt-voice-btn:hover{background:var(--bt-active)} --.bt-voice-btn:active{transform:scale(.94)} --.bt-voice-btn[aria-pressed=true]{background:var(--bt-user);color:var(--bt-user-ink)} --.bt-voice-slash{position:absolute;width:24px;height:2px;border-radius:1px;background:currentColor;transform:rotate(-45deg)} --.bt-voice-end{background:#ff3e51;color:#fff} --.bt-voice-end:hover{background:#e02135} --@keyframes bt-fade{from{opacity:0}to{opacity:1}} --@keyframes bt-blur-in{from{opacity:0;filter:blur(2px)}to{opacity:1;filter:blur(0)}} --@media (prefers-reduced-motion:reduce){.bt-voice-pane,.bt-voice-caption,.bt-voice-ring{animation:none!important}} --.bt-msg{display:flex;flex-direction:column;align-items:flex-start;max-width:min(80%,560px,calc(100% - 82px))} --/* The bubble's row: reactions hang below it, so the hover toolbar (and a group -- sender's avatar) line up with the bubble alone. */ --.bt-msg-line{position:relative;display:flex;align-items:flex-end;gap:8px;max-width:100%} --.bt-msg-line>.bt-bubble{max-width:none;min-width:0} --/* The same bubble width as beside an avatar column: 80% of (100% - 30px), plus the 30px. */ --.bt-astack[data-lead]>.bt-msg{max-width:min(calc(80% + 6px),590px,calc(100% - 52px))} --.bt-tools{position:absolute;top:50%;left:100%;margin-left:6px;transform:translateY(-50%);display:flex;gap:2px;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s} --.bt-tools::after{content:"";position:absolute;top:-8px;bottom:-8px;right:100%;width:128px;z-index:-1} --.bt-msg:hover>.bt-msg-line>.bt-tools,.bt-msg:focus-within>.bt-msg-line>.bt-tools,.bt-tools[data-open]{opacity:1;visibility:visible} --.bt-tool{width:24px;height:24px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:background-color .12s ease,color .12s ease} --.bt-tool:hover,.bt-tool[aria-expanded=true]{background:var(--bt-hover);color:var(--bt-ink)} --.bt-tool:active{background:var(--bt-active)} --.bt-tool:focus-visible{outline:2px solid var(--bt-accent);outline-offset:-2px} --.bt-react-strip{position:fixed;z-index:60;pointer-events:auto;display:flex;gap:2px;padding:4px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 10px 20px -3px rgba(0,0,0,.08),0 4px 6px -4px rgba(0,0,0,.06);transform:translate(-50%,-100%);transform-origin:bottom center;animation:bt-pop-lift ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .14s ease-out} --.bt-react-strip button{width:32px;height:32px;border:0;border-radius:50%;background:none;font-size:18px;line-height:1;cursor:pointer;transition:background-color .12s ease,${spring(SPRING_POP, "transform")};animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing} backwards} --.bt-react-strip button:nth-child(2){animation-delay:20ms} --.bt-react-strip button:nth-child(3){animation-delay:40ms} --.bt-react-strip button:nth-child(4){animation-delay:60ms} --.bt-react-strip button:nth-child(n+5){animation-delay:80ms} --.bt-react-strip button:hover{background:var(--bt-hover);transform:scale(1.15)} --.bt-react-strip button:active{transform:scale(.92)} --.bt-reacts{display:flex;gap:4px;margin-top:4px} --.bt-react{height:22px;min-width:30px;padding:0 6px;border-radius:999px;border:.5px solid var(--bt-line-2);background:var(--bt-main);font-size:13px;line-height:20px;cursor:pointer;animation:bt-react-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing};transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --.bt-react:hover{background:var(--bt-hover)} --.bt-react:active{scale:.92} --@keyframes bt-react-pop{from{transform:scale(.6);opacity:0}} --.bt-bubble a[href^="${MENTION_ORIGIN}"] svg{display:none} --.bt-you{width:36px;height:36px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,var(--bt-sidebar));color:var(--bt-ink-2);box-shadow:inset 0 0 0 1px var(--bt-line);transition:background-color .12s ease} --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/mention-style.js +function chipKey(bot) { + if (/\s/.test(bot?.name)) return null; @@ -3506,114 +3350,7 @@ diff --git a/client.js b/client.js + return `${anchor}{color:${inkText(look.color)}}${chipBase}${mark}`; + }).join("\n"); +} - --.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} --.bt-menu button:hover{background:var(--bt-hover)} --.bt-menu button svg{flex:none;width:16px;height:16px;margin:0 1px} --.bt-menu .bt-danger{color:#c21d2e} --.bt-menu-sep{height:.5px;flex:none;background:var(--bt-line-2);margin:4px 8px} --.bt-menu-up{--bt-origin:bottom left;animation-name:bt-pop-lift,bt-veil-in} --.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} --.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} --.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} --.bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} --.bt-palette-layer[data-leaving]{pointer-events:none} --.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in} --.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}} --.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)} --.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px} --.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} --.bt-palette-head input::placeholder{color:var(--bt-ink-3)} --.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-palette-clear:hover{color:var(--bt-ink)} --.bt-palette-clear svg{width:12px;height:12px} --.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box} --.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)} --.bt-palette-list>.bt-thumb{left:8px;right:8px} --:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent} --:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1} --.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none} --.bt-palette-section:first-of-type{padding-top:0} --.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%} --.bt-palette-row[aria-selected=true]{background:var(--bt-active)} --.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center} --.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} --.bt-palette-cmd svg{width:14px;height:14px} --.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)} --.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} --.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)} --.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out} --.bt-palette-empty-icon{color:var(--bt-line-2)} --.bt-palette-empty-icon svg{width:32px;height:32px} --.bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} --.bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} --@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} --.bt-account-menu{width:224px} --.bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} --.bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} --/* Popover keyframes move translate and scale, which compose with a transform the popover -- already has. */ --@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}} --@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}} --@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}} --@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}} --@keyframes bt-check-pop{from{opacity:0;scale:.4}} --@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}} --.bt-brand:hover{background:var(--bt-hover)} --.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px} --.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px} --.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0} --.bt-pick:hover{background:var(--bt-hover)} --.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)} --.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1} --.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px} --.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px} --.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px} --.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box} --.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/motion.js +var import_react4 = require("react"); +var reducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -3648,12 +3385,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 h49 = dt / steps; ++ const h47 = 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) * h49; -+ value.x += value.v * h49; ++ value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h47; ++ value.x += value.v * h47; + } + if (Math.abs(value.x - value.to) < 0.05 && Math.abs(value.v) < 1) { + value.x = value.to; @@ -3817,92 +3554,7 @@ diff --git a/client.js b/client.js + }, [value]); + return [value || kept, !value && Boolean(kept)]; +} - --/* 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/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)} @@ -4012,27 +3664,7 @@ diff --git a/client.js b/client.js +.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-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}} ++ +.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} @@ -4528,105 +4160,108 @@ diff --git a/client.js b/client.js +.bt-menu[data-leaving]{pointer-events:none;animation:bt-pop-out .12s cubic-bezier(.4,0,1,1) forwards} +.bt-menu-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.bt-menu-hint{flex:none;color:var(--bt-ink-3);font-size:12px;font-variant-numeric:tabular-nums} -+.bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} -+.bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} -+.bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} -+.bt-palette-layer[data-leaving]{pointer-events:none} -+.bt-palette-layer[data-leaving] .bt-backdrop{opacity:0;transition:opacity .16s ease-in} -+.bt-palette-layer[data-leaving] .bt-palette{animation:bt-dialog-sink .16s cubic-bezier(.4,0,1,1) forwards} -+@keyframes bt-palette-in{from{translate:0 -10px;scale:.96}} -+.bt-palette-head{display:flex;align-items:center;gap:4px;padding:14px 10px 14px 14px;border-bottom:.5px solid var(--bt-line-2)} -+.bt-palette-glyph{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center;color:var(--bt-ink-3);margin-right:4px} -+.bt-palette-head input{flex:1;min-width:0;border:0;outline:none;background:none;color:var(--bt-ink);font:inherit;font-size:15px;line-height:22px;padding:0} -+.bt-palette-head input::placeholder{color:var(--bt-ink-3)} -+.bt-palette-clear{width:18px;height:18px;border:0;padding:0;border-radius:6px;background:none;color:var(--bt-ink-3);display:inline-flex;align-items:center;justify-content:center;cursor:pointer} -+.bt-palette-clear:hover{color:var(--bt-ink)} -+.bt-palette-clear svg{width:12px;height:12px} -+.bt-palette-list{position:relative;height:402px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:2px;box-sizing:border-box} -+.bt-palette-list>.bt-thumb,.bt-drop-list>.bt-thumb{left:0;right:0;border-radius:8px;background:var(--bt-active)} -+.bt-palette-list>.bt-thumb{left:8px;right:8px} -+:is(.bt-palette-list,.bt-drop-list)[data-glide] [aria-selected=true]{background:transparent} -+:is(.bt-palette-row,.bt-palette-section,.bt-drop-list>.bt-option){position:relative;z-index:1} -+.bt-palette-section{padding:8px 8px 4px;font-size:12px;line-height:16px;color:var(--bt-ink-3);flex:none} -+.bt-palette-section:first-of-type{padding-top:0} -+.bt-palette-row{flex:none;display:flex;align-items:center;gap:8px;height:49px;padding:6px 10px 6px 8px;border:0;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;box-sizing:border-box;width:100%} -+.bt-palette-row[aria-selected=true]{background:var(--bt-active)} -+.bt-palette-lead{flex:none;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center} -+.bt-palette-cmd{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} -+.bt-palette-cmd svg{width:14px;height:14px} -+.bt-palette-agent{width:24px;height:24px;border-radius:50%;background:var(--bt-hover);display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink)} -+.bt-palette-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} -+.bt-palette-title{font-size:14px;line-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-palette-sub{font-size:12px;line-height:16px;color:var(--bt-ink-3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-palette-badge{flex:none;font-size:11px;line-height:16px;padding:0 6px;border-radius:4px;background:var(--bt-tag-bg);color:var(--bt-tag-ink)} -+.bt-palette-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:0 16px;text-align:center;animation:bt-veil-in .22s ease-out} -+.bt-palette-empty-icon{color:var(--bt-line-2)} -+.bt-palette-empty-icon svg{width:32px;height:32px} -+.bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} -+.bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} -+@media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} + .bt-palette-layer{position:fixed;inset:var(--dsh-frame-chrome-top,0px) 0 0;z-index:70;pointer-events:auto;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-backdrop{position:absolute;inset:0;background:rgba(0,0,0,.46);animation:bt-fade .22s ease-out} + .bt-palette{position:absolute;top:50%;left:50%;width:min(560px,92vw);transform:translate(-50%,-50%);display:flex;flex-direction:column;border-radius:12px;background:var(--bt-main);border:1px solid var(--bt-line-2);box-shadow:0 24px 48px -12px rgba(0,0,0,.25);overflow:hidden;color:var(--bt-ink);animation:bt-palette-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing},bt-veil-in .2s ease-out} +@@ -2427,7 +2427,8 @@ button:hover .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-side + .bt-palette-empty-label{font-size:14px;font-weight:500;color:var(--bt-ink-2)} + .bt-palette-empty-hint{font-size:13px;line-height:16px;color:var(--bt-ink-3)} + @media (prefers-reduced-motion:reduce){.bt-palette{animation:bt-fade .1s ease-out}} +-.bt-account-menu{width:224px} +.bt-account-menu{min-width:224px;width:max-content;max-width:calc(100vw - 16px)} +.bt-flybot-account-header:not(:empty){padding:6px 0 2px;margin-bottom:4px;border-bottom:.5px solid var(--bt-line-2)} -+.bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} -+.bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} -+/* Popover keyframes move translate and scale, which compose with a transform the popover -+ already has. */ -+@keyframes bt-pop-drop{from{translate:0 -4px;scale:.96}} -+@keyframes bt-pop-lift{from{translate:0 4px;scale:.96}} -+@keyframes bt-pop-out{to{opacity:0;scale:.97;filter:blur(2px)}} -+@keyframes bt-veil-in{from{opacity:0;filter:blur(6px)}} -+@keyframes bt-check-pop{from{opacity:0;scale:.4}} -+@media (prefers-reduced-motion:reduce){.bt-menu,.bt-avedit,.bt-react-strip{animation:bt-fade .1s ease-out}.bt-react-strip button,.bt-q-check,.bt-color[aria-pressed=true] span{animation:none}} -+.bt-brand:hover{background:var(--bt-hover)} -+.bt-drop-list{position:relative;display:flex;flex-direction:column;gap:2px} -+.bt-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px} -+.bt-pick{display:flex;flex-direction:column;align-items:center;gap:4px;padding:7px 2px 6px;border-radius:10px;border:1px solid transparent;background:none;color:var(--bt-ink);cursor:pointer;font:inherit;font-size:11px;line-height:14px;min-width:0} -+.bt-pick:hover{background:var(--bt-hover)} -+.bt-pick[aria-pressed=true]{border-color:var(--bt-line-2);background:var(--bt-hover)} -+.bt-pick>span:last-child:not(.bt-mark){max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -+.bt-pick:hover .bt-mark[data-state=idle] .bt-eye{animation:bt-blink .5s linear 1} -+.bt-select{width:100%;height:34px;box-sizing:border-box;border-radius:8px;border:1px solid var(--bt-line);background:var(--bt-card);color:var(--bt-ink);padding:0 8px;font:inherit;font-size:13px} -+.bt-note{font-size:12px;line-height:16px;color:var(--bt-ink-2);margin-top:6px} -+.bt-swatch-sep{width:1px;height:22px;background:var(--bt-line-2);margin:0 2px} -+.bt-swatch-custom{position:relative;overflow:hidden;background:conic-gradient(#ff3e51,#ffaf38,#00c972,#1cc3b0,#2a92fe,#a97efe,#ff5eb1,#ff3e51);box-sizing:border-box} -+.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}} -+ + .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} + .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} + /* Popover keyframes move translate and scale, which compose with a transform the popover +@@ -2488,2292 +2488,4398 @@ 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)} @@ -5284,7 +4919,108 @@ diff --git a/client.js b/client.js + (0, import_react5.createElement)("rect", { className: "bt-flybot-body", x: 248, y: 104, width: 16, height: 34 }) + ); +} -+ + +-.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/questions.js +var import_react6 = require("react"); +var keyLetter = (index) => String.fromCharCode(65 + index); @@ -5490,21 +5226,143 @@ diff --git a/client.js b/client.js + { className: inline ? "bt-q-inline" : "bt-q-dock" }, + (0, import_react6.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) + ); -+} -+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_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+} -+function AnsweredCell({ node }) { -+ const data = node.data ?? {}; -+ const questions = Array.isArray(data.questions) ? data.questions : []; -+ if (questions.length === 0) { -+ return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; -+ } -+ const answers = Array.isArray(data.answers) ? data.answers : []; + } +-@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} +-@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} +-[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} +-[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} +-[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} +-[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} +-[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} +-[data-composer-card]>[class*="_row"]{display:contents!important} +-[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} +-[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} +-[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} +-[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} +-[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} +-[data-composer-placeholder]{font-size:0!important;line-height:0!important} +-[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} +-/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ +-[data-composer-card] button[class*="_add"]{display:none!important} +-.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-plus:active{scale:.9} +-.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} +-.bt-plus[aria-expanded=true] svg{rotate:45deg} +-.bt-plus-menu{min-width:180px} +-[data-composer-card] [class*="_tools"]{gap:0!important} +-[data-composer-card] [class*="_modes"]:empty{display:none!important} +-[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} +-[data-composer-card] [class*="_trailing"]{gap:8px} +-[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} +-[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} +-[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} +-@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} +-[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} +-[data-composer-card] button[class*="_primary"]:disabled{display:none!important} +-[data-composer-dock]{display:none!important} +-.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} +-.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} +-.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} +-.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} +-@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} +-.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} +-.bt-voice:hover{opacity:.9} +-.bt-mic:active,.bt-voice:active{scale:.9} +-[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} +-body[data-bt-room] .bt-voice{display:none!important} +-body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} +-.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} +-[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} +-.bt-q{order:1} +-div:has(> nav [class*="_marks"]){display:none!important} +-/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) +- takes its surface and its text from the one --bt-user pair: a skin that repaints +- the host's bubble surface alone would otherwise leave the text unreadable. */ +-[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} +-[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} +-/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ +-[class*="_userRow"] [class*="_actions"]{display:none!important} +-[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} +-[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} +-[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} +-[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} +-[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} +-[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} +-[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} +-.bt-q-inline{display:flex;margin:4px 0 6px} +-[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} +-[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} +-.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} +-.bt-q-dock{width:100%;display:flex;justify-content:center} +-.bt-q-dock .bt-q{margin:0 auto 8px} +-[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} +-[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} +-[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} +-[class*="_toBottomSlot"]>button:active{scale:.9} +-[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} +-[class*="_toBottomSlot"]>button svg{display:none} +-[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} +-@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} +-[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} +-.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} +-@keyframes bt-news-in{from{translate:0 8px;scale:.9}} +-.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} +-@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} +-.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} +-.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} +-.bt-news-ico svg{width:16px;height:16px} +-.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} +-.bt-news-x svg{width:12px;height:12px} +-.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} +-@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} +-.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} +-.bt-open-new{display:flex;flex-direction:column} +-.bt-open-new .bt-new-sep{margin:18px 0 0} +-.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} +-.bt-earlier{display:flex;flex-direction:column;gap:6px} +-.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} +-.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} +-.bt-urow{display:flex;justify-content:flex-end} +-.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} +-.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} +-.bt-ububble p{margin:0;line-height:22px} +-.bt-uimages{display:block;font-size:12px;opacity:.7} +-.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 installStyles(text, tag) { +- const style = document.createElement("style"); +- style.dataset.dshBot = tag; +- style.textContent = text; +- document.head.appendChild(style); +- return style; ++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_react6.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); + } +-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); ++function AnsweredCell({ node }) { ++ const data = node.data ?? {}; ++ const questions = Array.isArray(data.questions) ? data.questions : []; ++ if (questions.length === 0) { ++ return data.text ? (0, import_react6.createElement)("div", { className: "bt-urow" }, (0, import_react6.createElement)("div", { className: "bt-ububble" }, data.text)) : null; ++ } ++ const answers = Array.isArray(data.answers) ? data.answers : []; + return (0, import_react6.createElement)("div", { className: "bt-q-inline" }, questions.map((question) => (0, import_react6.createElement)(SettledQuestion, { + key: question.id, + question, @@ -5564,7 +5422,7 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + onEnter?.(); -+ } + } + }); +} +function ScopeSwitch({ value, onChange, bot }) { @@ -5609,7 +5467,10 @@ diff --git a/client.js b/client.js + setBusy(true); + setError(""); + return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); -+ }; + }; +- try { +- visit(sheet.cssRules); +- } catch { + const cancel = () => run(actions.secretCancel(request.id)); + const long = value.trim().length >= MIN_VALUE; + const save = () => { @@ -5686,9 +5547,20 @@ diff --git a/client.js b/client.js + if (request) return (0, import_react7.createElement)("div", { className: "bt-q-inline" }, (0, import_react7.createElement)(RequestView, { bot, request, roster, actions })); + if (done && /already saved/.test(contentText(content))) { + 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 names; + return 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 SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { + const roster = useRoster((value) => value); + const shown = useCards?.((state) => state[sessionId]) ?? NONE; @@ -5699,7 +5571,18 @@ diff --git a/client.js b/client.js + 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 }))); -+} + } +-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); +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)); @@ -5745,13 +5628,33 @@ diff --git a/client.js b/client.js + if (ok) { + setValue(""); + setReplaced(true); -+ } + } +- for (const name of list) names.add(name); +- } +- 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 edit = (next) => { + setValue(next); + setReplaced(false); -+ }; + }; +- 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_react7.createElement)( + "div", + { className: "bt-secret-edit-block" }, @@ -5821,7 +5724,7 @@ diff --git a/client.js b/client.js + onDone(); + } + }); -+ }; + }; + return (0, import_react7.createElement)( + "div", + { className: "bt-set-row bt-set-paste bt-secret-add" }, @@ -5836,7 +5739,42 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + ) + ); -+} + } +- +-// 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); +function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = (0, import_react7.useState)(null); + const [adding, setAdding] = (0, import_react7.useState)(false); @@ -5923,87 +5861,7 @@ diff --git a/client.js b/client.js +.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}} - `; --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/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { @@ -6017,8 +5875,65 @@ diff --git a/client.js b/client.js + person: local ? seatOwnerName(p.ownerName, p.handle) || cleanPeerName(roster.self?.displayName, "") : ownerNameOf(p), + bot: local ? local.name : remoteBotName(p, "Bot"), + mine: Boolean(local) -+ }; -+ }; + }; +- }, [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 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 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 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 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; + const seats = records.filter((p) => p.leftSeq === void 0).map(seat); + for (const id of room2.members ?? []) { + const bot = roster.byId[id]; @@ -6055,14 +5970,40 @@ diff --git a/client.js b/client.js + if (known) { + known.seats.push(seat); + continue; -+ } + } +- if (was === "idle" || was === "alert") return void 0; +- setSettled(true); +- const timer = setTimeout(() => setSettled(false), 1800); +- return () => clearTimeout(timer); +- }, [state]); +- return settled; + 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 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 facePersonLabel(person) { + return person.mine ? t("{name} (you)", { name: person.name }) : person.name; -+} + } +-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]]; + +// src/client/held-reply.js +var import_react8 = require("react"); @@ -6072,7 +6013,21 @@ diff --git a/client.js b/client.js + constructor(props) { + super(props); + this.state = { failed: false }; -+ } + } +- 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 + static getDerivedStateFromError() { + return { failed: true }; + } @@ -6121,8 +6076,25 @@ diff --git a/client.js b/client.js + // different field elsewhere. A failure that can never succeed draws no button on any surface. + failed && canRetry(held) ? (0, import_react8.createElement)("button", { type: "button", className: "bt-held-retry", disabled: busy, onClick: () => decide("retry") }, t("Retry")) : null, + error ? (0, import_react8.createElement)("span", { className: "bt-flybot-approval-error", role: "alert" }, error) : 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)); +- }) + const plain = (0, import_react8.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, @@ -6140,15 +6112,29 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-held-actions", "data-flybot": "held-reply-actions" }, + custom ? (0, import_react8.createElement)(boundary(), { id: custom.id, fallback: plain }, (0, import_react8.createElement)(custom.component, { held, decide, call: call3, actions })) : plain -+ ); -+} -+ + ); + } + +-// 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" })); +// 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"); -+} + } +-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 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(); @@ -6198,8 +6184,10 @@ diff --git a/client.js b/client.js + }, emoji))) : null + ); + }) -+ ); -+} + ); + } +-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 WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text) { @@ -6213,7 +6201,13 @@ diff --git a/client.js b/client.js + if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; -+} + } +-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 useDismiss(open, close, refs) { + (0, import_react9.useEffect)(() => { + if (!open) return void 0; @@ -6289,122 +6283,13 @@ diff --git a/client.js b/client.js + item(t("Copy"), CopyIcon, copy2), + item(t("Download"), DownloadIcon, download2) + ), document.body) : null -+ ); + ); } --@keyframes bt-fade-in{from{opacity:0}to{opacity:1}} --@keyframes bt-fade-out{from{opacity:1}to{opacity:0}} --[data-composer-card]{display:flex!important;flex-direction:row;flex-wrap:wrap;align-items:center;gap:0!important;min-height:44px;border-radius:22px!important;padding:7.5px!important;box-sizing:border-box;background:var(--bt-main)!important;border:.5px solid var(--bt-line-2)!important;box-shadow:0 2px 8px -1px rgba(0,0,0,.05),0 1px 2px rgba(0,0,0,.03),0 0 0 1px rgba(228,228,228,.04)!important;transition:border-color .15s ease,background-color .15s ease,${spring(SPRING_SHAPE, "border-radius")}!important} --[data-composer-card]:hover,[data-composer-card]:focus-within{border-color:color-mix(in srgb,var(--bt-ink) 30%,transparent)!important} --[data-composer-card]:has(> [data-slot="conversation.input.attachments"] *),[data-composer-card]:has([data-composer-input] br + *),[data-composer-card]:has([data-composer-input] > :nth-child(2)){border-radius:18px!important} --[class*="_root"]:has(> [data-composer-card]){padding:0 11px 13px 16px!important} --[class*="_root"]:has(> [data-composer-card])>[data-composer-card]{max-width:min(992px,100%)!important} --[data-composer-card]>[class*="_row"]{display:contents!important} --[data-composer-card] [class*="_tools"]{order:1;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-input-scroll]{order:2;flex:1 1 0;min-width:0;padding:0 8px!important;margin:0!important} --[data-composer-card] [class*="_trailing"]{order:3;flex:none;align-self:flex-end;margin:0} --[data-composer-card]>[data-slot="conversation.input.attachments"]{order:0;flex-basis:100%} --[data-composer-card] [data-composer-input]{min-height:24px;padding:2px 0!important} --[data-composer-card] [data-composer-placeholder]{left:0!important;top:2px!important} --[data-composer-placeholder]{font-size:0!important;line-height:0!important} --[data-composer-placeholder]::after{content:var(--bt-placeholder,"Message");display:block;font-size:14px;line-height:24px;color:color-mix(in srgb,var(--bt-ink) 36%,transparent)} --/* The team's "+" (ComposerPlus) takes the seat of the shell's command-menu button. */ --[data-composer-card] button[class*="_add"]{display:none!important} --.bt-plus{flex:none;width:28px;height:28px;padding:0;border:0;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2);cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-plus:hover,.bt-plus[aria-expanded=true]{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-plus:active{scale:.9} --.bt-plus svg{width:14px;height:14px;transition:${spring(SPRING_POP, "rotate")}} --.bt-plus[aria-expanded=true] svg{rotate:45deg} --.bt-plus-menu{min-width:180px} --[data-composer-card] [class*="_tools"]{gap:0!important} --[data-composer-card] [class*="_modes"]:empty{display:none!important} --[data-composer-card] [class*="_activity"]:not(:has([data-slot] > *)){display:none!important} --[data-composer-card] [class*="_trailing"]{gap:8px} --[data-composer-card] [class*="_standardControls"]{display:flex;align-items:center;gap:8px} --[data-composer-card] button[class*="_primary"]{width:28px!important;height:28px!important;background:var(--bt-accent)!important;color:var(--bt-accent-ink,#fff)!important;border-radius:50%!important;transition:opacity .15s ease,${spring(SPRING_TAP, "scale")};animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --[data-composer-card] button[class*="_primary"]:hover:not(:disabled){opacity:.9} --[data-composer-card] button[class*="_primary"]:active:not(:disabled){scale:.9} --@keyframes bt-icon-swap{from{opacity:0;transform:scale(.5) rotate(-30deg)}} --[data-composer-card] button[class*="_primary"] svg{width:14px;height:14px} --[data-composer-card] button[class*="_primary"]:disabled{display:none!important} --[data-composer-dock]{display:none!important} --.bt-mic,.bt-voice{flex:none;width:28px;height:28px;border-radius:50%;border:0;padding:0;display:inline-grid;place-items:center;cursor:pointer;transition:background-color .15s ease,color .15s ease,${spring(SPRING_TAP, "scale")}} --.bt-mic{background:color-mix(in srgb,var(--bt-ink) 5%,transparent);box-shadow:inset 0 0 0 .5px var(--bt-line);color:var(--bt-ink-2)} --.bt-mic:hover{background:color-mix(in srgb,var(--bt-ink) 10%,transparent);color:var(--bt-ink)} --.bt-mic[aria-pressed=true]{background:#ff3e51;color:#fff;animation:bt-mic-pulse 1.4s ease-in-out infinite} --@keyframes bt-mic-pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,62,81,.35)}50%{box-shadow:0 0 0 5px rgba(255,62,81,0)}} --.bt-voice{display:none;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);animation:bt-icon-swap ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --.bt-voice:hover{opacity:.9} --.bt-mic:active,.bt-voice:active{scale:.9} --[data-composer-card]:has(button[class*="_primary"]:disabled) .bt-voice{display:inline-grid} --body[data-bt-room] .bt-voice{display:none!important} --body[data-bt-room] [data-composer-card]:has(button[class*="_primary"]:disabled) .bt-mic:not([aria-pressed=true]){background:var(--bt-accent);color:var(--bt-accent-ink,#fff)} --.bt-mic-note{position:absolute;right:12px;bottom:calc(100% + 8px);padding:5px 10px;border-radius:8px;background:var(--bt-user);color:var(--bt-user-ink);font-size:12px;line-height:16px;white-space:nowrap;pointer-events:none;animation:bt-rise .16s ease-out} --[data-chain-overlay-fallback="conversation.composer"]:has(~ .bt-q){display:block!important;order:2} --.bt-q{order:1} --div:has(> nav [class*="_marks"]){display:none!important} --/* Every user-side bubble (sent, steering mid-turn, not yet admitted, question replies) -- takes its surface and its text from the one --bt-user pair: a skin that repaints -- the host's bubble surface alone would otherwise leave the text unreadable. */ --[class*="_userRow"] [class*="_bubble"],[data-chat-flow-kind=question-reply] [class*="_bubble"],[data-chat-flow-kind=command-input] [class*="_bubble"]{background:var(--bt-user)!important;color:var(--bt-user-ink)!important;border-radius:18px!important;padding:7px 12px!important;font-size:var(--dsh-content-font-size,14px)!important;line-height:calc(var(--dsh-content-font-size,14px) + 8px)!important} --[class*="_userRow"] [class*="_bubble"] [class*="_markdown"],[class*="_userRow"] [class*="_bubble"] a{color:inherit} --/* Sent messages get the hover toolbar below; steering and unsent bubbles keep none. */ --[class*="_userRow"] [class*="_actions"]{display:none!important} --[data-chat-flow-kind=user] [class*="_userRow"]{flex-direction:row-reverse;align-items:center;justify-content:flex-start} --[data-chat-flow-kind=user] [class*="_actions"]{display:flex!important;height:24px!important;gap:2px!important;opacity:0!important;visibility:hidden;transition:opacity .12s ease,visibility .12s!important;flex:none} --[data-chat-flow-kind=user] [class*="_userRow"]:hover [class*="_actions"],[data-chat-flow-kind=user] [class*="_userRow"]:focus-within [class*="_actions"]{opacity:1!important;visibility:visible} --[data-chat-flow-kind=user] [class*="_actions"] [class*="_timeStart"]{display:none} --[data-chat-flow-kind=user] [class*="_actions"] button{width:24px;height:24px;border-radius:6px;color:var(--bt-ink-2)} --[data-chat-flow-kind=user] [class*="_actions"] button:hover{background:var(--bt-hover);color:var(--bt-ink)} --[data-chat-flow-kind=user] [class*="_actions"] svg{width:15px;height:15px} --.bt-q-inline{display:flex;margin:4px 0 6px} --[data-chat-flow-kind=assistant-step]+[data-step-process]:has(.bt-q-inline){margin-top:0!important} --[data-chat-flow-kind=question-reply]:has(.bt-q-inline),[data-chat-group-key]:has([data-chat-flow-kind=question-reply] .bt-q-inline),[data-chat-group-key]:has(.bt-q-gone){margin-top:0!important} --.bt-q-inline .bt-q{margin:0;max-width:min(550px,86%);order:0} --.bt-q-dock{width:100%;display:flex;justify-content:center} --.bt-q-dock .bt-q{margin:0 auto 8px} --[class*="_toBottomSlot"]{justify-content:center!important;padding-right:0!important} --[data-conversation-scroll] [class*="_toBottomSlot"]{bottom:calc(var(--dsh-composer-height,152px) + 8px)!important} --[class*="_toBottomSlot"]>button{width:36px!important;height:36px!important;margin-top:-36px!important;background:var(--bt-main)!important;color:var(--bt-ink)!important;box-shadow:0 0 0 .5px var(--bt-line-2),0 2px 8px -2px rgba(0,0,0,.12)!important;animation:bt-tobottom-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out;transition:background-color .12s ease,${spring(SPRING_TAP, "scale")}} --[class*="_toBottomSlot"]>button:active{scale:.9} --[class*="_toBottomSlot"]>button:hover{background:color-mix(in srgb,var(--bt-ink) 4%,var(--bt-main))!important} --[class*="_toBottomSlot"]>button svg{display:none} --[class*="_toBottomSlot"]>button::before{content:"";width:20px;height:20px;background:currentColor;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 4v12M5 11l5 5 5-5'/%3E%3C/svg%3E") center/contain no-repeat} --@keyframes bt-tobottom-in{from{translate:0 8px;scale:.8}} --[data-bt-news] [class*="_toBottomSlot"]>button{visibility:hidden!important;pointer-events:none!important} --.bt-news{position:fixed;z-index:30;transform:translateX(-50%);display:inline-flex;align-items:center;overflow:hidden;border-radius:999px;background:#0c64c1;color:#fcfcfc;box-shadow:inset 0 0 0 1px rgba(20,20,20,.05),0 1px 3px 0 rgba(0,0,0,.12);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;animation:bt-news-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing},bt-veil-in .16s ease-out} --@keyframes bt-news-in{from{translate:0 8px;scale:.9}} --.bt-news[data-direction=up]{animation-name:bt-news-in-up,bt-veil-in} --@keyframes bt-news-in-up{from{translate:0 -8px;scale:.9}} --.bt-news-jump{display:inline-flex;align-items:center;gap:2px;margin:0;padding:4px 26px 4px 4px;border:0;border-radius:0;background:transparent;color:inherit;font:inherit;font-size:13px;line-height:18px;white-space:nowrap;cursor:pointer;outline:none} --.bt-news-ico{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px} --.bt-news-ico svg{width:16px;height:16px} --.bt-news-x{position:absolute;right:4px;top:50%;z-index:1;transform:translateY(-50%);width:20px;height:20px;padding:0;border:0;border-radius:999px;background:transparent;color:inherit;opacity:.75;display:inline-flex;align-items:center;justify-content:center;cursor:pointer} --.bt-news-x svg{width:12px;height:12px} --.bt-news-x:hover{opacity:1;background:rgba(20,20,20,.5)} --@media (prefers-reduced-motion:reduce){.bt-news,[class*="_toBottomSlot"]>button{animation:none}} --.bt-new-sep{display:flex;align-items:center;gap:8px;margin:14px 0 8px;padding:2px 0;font-size:11px;line-height:14px;font-weight:500;letter-spacing:.02em;text-transform:uppercase;color:#0c64c1;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-new-sep::before,.bt-new-sep::after{content:"";flex:1;height:1px;background:color-mix(in srgb,#0c64c1 20%,transparent)} --.bt-open-new{display:flex;flex-direction:column} --.bt-open-new .bt-new-sep{margin:18px 0 0} --.bt-open-new .bt-time-sep{height:16px;margin:32px 0 2px} --.bt-earlier{display:flex;flex-direction:column;gap:6px} --.bt-earlier-top{display:flex;justify-content:center;align-items:center;height:34px;flex:none} --.bt-earlier-top .bt-soft{height:28px;font-size:12px;padding:0 12px} --.bt-urow{display:flex;justify-content:flex-end} --.bt-ububble{max-width:min(560px,78%);background:var(--bt-user);color:var(--bt-user-ink);border-radius:18px;padding:7px 12px;font-size:14px;line-height:20px;box-sizing:border-box;overflow-wrap:anywhere} --.bt-ububble [class*="_markdown"],.bt-ububble a{color:inherit!important} --.bt-ububble p{margin:0;line-height:22px} --.bt-uimages{display:block;font-size:12px;opacity:.7} --.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 installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; +-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 ThinkingRow({ text }) { + if (typeof text !== "string" || text.trim() === "") return null; + return (0, import_react9.createElement)( @@ -6412,16 +6297,14 @@ 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) -+ ); + ); } --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); +-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" }) +- ); +function useFileMentions({ node, openFile, fileMentions, useTurnData }) { + const tail = useTurnData?.("turn-tail"); + const location2 = node.location; @@ -6431,13 +6314,31 @@ 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 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 useClosingText(node, useTurnData) { + const tail = useTurnData?.("turn-tail"); + const seq = node.data.finalNode?.seq; + if (!tail?.closing || seq === void 0) return true; + return tail.closing.finalNode?.seq === seq; -+} + } +-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 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 friendLead(bot, texts2) { + if (bot?.remote === void 0 || texts2.length === 0) return void 0; + if (texts2.every((text) => /^Not sent:/.test(text.trim()))) return void 0; @@ -6480,8 +6381,33 @@ 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 -+ ); -+} + ); + } +-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" }) +- ) +function HeldReply({ held, roster, sessionId, actions, closing, children }) { + const bot = roster.byId[sessionId]; + const chat = (roster.bots ?? []).find((item) => item.remote?.handle === held.to); @@ -6491,8 +6417,48 @@ diff --git a/client.js b/client.js + { className: "bt-held-reply", "data-state": held.status, "data-held": isApprovalWaiting(held.status) ? "" : void 0 }, + children, + closing ? (0, import_react9.createElement)(HeldReplyActions, { held: { ...held, toName, from: bot?.name }, actions }) : null -+ ); -+} + ); + } +-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(); +- } +- }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); +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)( @@ -6501,68 +6467,173 @@ diff --git a/client.js b/client.js + (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 EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { -+ if (whole && bots.length === 1) { -+ return (0, import_react9.createElement)( -+ "div", -+ { className: "bt-event" }, -+ (0, import_react9.createElement)( -+ "button", -+ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, -+ (0, import_react9.createElement)("span", null, prefix), -+ (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), -+ (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) -+ ) -+ ); -+ } -+ const parts = []; -+ parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); -+ bots.forEach((bot, index) => { -+ if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); -+ parts.push((0, import_react9.createElement)( -+ "button", -+ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, -+ (0, import_react9.createElement)(BotMark, { bot, size: 16 }), -+ bot.name -+ )); -+ }); -+ return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); -+} -+var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -+var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); -+function isHeldLine(room2, text, bot) { -+ if (room2?.origin !== "face-to-face") return false; -+ const line2 = heldForm(text); -+ if (!line2) return false; -+ return (room2.pendingApprovals ?? []).some((item) => { -+ if (item?.kind !== "face_room_message") return false; -+ if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; -+ const held = heldForm(item.text); -+ return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); -+ }); -+} -+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; -+ const said = String(text ?? "").trim(); -+ const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); -+ if (entry === void 0) return void 0; -+ if (entry.status === "rejected") return "rejected"; -+ if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; -+ if (entry.status !== "pending" && entry.status !== "sending") return void 0; -+ return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; + } +-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(); +- } +- }) : 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 ++function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { ++ if (whole && bots.length === 1) { ++ return (0, import_react9.createElement)( ++ "div", ++ { className: "bt-event" }, ++ (0, import_react9.createElement)( ++ "button", ++ { type: "button", className: "bt-event-whole", title: bots[0].name, onClick: () => onOpen?.(bots[0]) }, ++ (0, import_react9.createElement)("span", null, prefix), ++ (0, import_react9.createElement)(BotMark, { bot: bots[0], size: 16 }), ++ (0, import_react9.createElement)("span", { className: "bt-event-name" }, bots[0].name) ++ ) + ); +- }); +- 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)( ++ const parts = []; ++ parts.push((0, import_react9.createElement)("span", { key: "p" }, prefix)); ++ bots.forEach((bot, index) => { ++ if (index > 0) parts.push((0, import_react9.createElement)("span", { key: `j${index}` }, joiner)); ++ parts.push((0, import_react9.createElement)( ++ "button", ++ { key: bot.id ?? index, type: "button", onClick: () => onOpen?.(bot) }, ++ (0, import_react9.createElement)(BotMark, { bot, size: 16 }), ++ bot.name ++ )); ++ }); ++ return (0, import_react9.createElement)("div", { className: "bt-event" }, parts); ++} ++var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); ++var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); ++function isHeldLine(room2, text, bot) { ++ if (room2?.origin !== "face-to-face") return false; ++ const line2 = heldForm(text); ++ if (!line2) return false; ++ return (room2.pendingApprovals ?? []).some((item) => { ++ if (item?.kind !== "face_room_message") return false; ++ if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; ++ const held = heldForm(item.text); ++ return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); ++ }); ++} ++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; ++ const said = String(text ?? "").trim(); ++ const entry = room2.heldLines.filter((item) => seq !== void 0 && item?.seq !== void 0 ? item.seq === seq : item?.botId === botId && String(item?.text ?? "").trim() === said).at(-1); ++ if (entry === void 0) return void 0; ++ if (entry.status === "rejected") return "rejected"; ++ if (entry.status === "sending" && now - (Number(entry.at) || 0) > HELD_SENDING_MS) return void 0; ++ if (entry.status !== "pending" && entry.status !== "sending") return void 0; ++ return room2.face?.status === "left" || room2.face?.status === "closed" ? "rejected" : "held"; +} +function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi, seq }) { + const held = heldLineState(room2, { seq, botId: bot.id, text }); + if (held === "rejected") { + return (0, import_react9.createElement)( -+ "div", + "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")) + { 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 })) -+ ); -+ } + ); +- } 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(); +- } +- }, +- (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 (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react9.createElement)( + "button", @@ -6580,8 +6651,14 @@ diff --git a/client.js b/client.js + { className: "bt-group-msg" }, + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) -+ ); -+} + ); + } +-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)( +function remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6622,7 +6699,14 @@ diff --git a/client.js b/client.js + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + if (person) { + return (0, import_react9.createElement)( -+ "div", + "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")) +- ) + { className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle }, + // One short header: the person, and the Bot that carried the line only when it is known (#275). + (0, import_react9.createElement)( @@ -6633,7 +6717,22 @@ 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: (0, import_react9.createElement)("span", { className: "bt-lead", title: person }, (0, import_react9.createElement)(PersonMark, { name: person })) }), + files.length ? (0, import_react9.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); + ); +- } +- 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)) +- ); +- })) + } + const mark = (0, import_react9.createElement)(BotMark, { bot, size: 22 }); + const lead = chatId ? (0, import_react9.createElement)("button", { type: "button", className: "bt-lead", title: handle, "aria-label": t("Open {name}'s chat", { name }), onClick: () => actions.openSession(chatId) }, mark) : (0, import_react9.createElement)("span", { className: "bt-lead", title: handle }, mark); @@ -6649,8 +6748,21 @@ 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 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 FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; @@ -6684,10 +6796,9 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); } +- pending.answer({ answers: next }); }; -- try { -- visit(sheet.cssRules); -- } catch { +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -6698,15 +6809,35 @@ diff --git a/client.js b/client.js + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : 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)( +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( -+ "div", + "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 }) + { 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 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 MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -6720,17 +6851,81 @@ diff --git a/client.js b/client.js + { className: "bt-event" }, + (0, import_react9.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react9.createElement)(MemoryIcon), label) + ); -+} -+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); -+} -+var scrollParent = (node) => { -+ for (let at = node.parentElement; at; at = at.parentElement) { -+ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; - } -- return names; + } +-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: [] } +- }))); ++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); + } +- +-// src/client/secret-card.js +-var import_react5 = require("react"); +-var MIN_VALUE = 4; +-var NONE = {}; +-var KeyIcon = () => (0, import_react5.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_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), +- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) +-); +-var LockIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), +- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) +-); +-var WarnIcon = () => (0, import_react5.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, +- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), +- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) +-); +-var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); +-var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); +-var scopeLabel = (scope, roster) => { +- 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 ")); ++var scrollParent = (node) => { ++ for (let at = node.parentElement; at; at = at.parentElement) { ++ if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; ++ } + return null; -+}; + }; +-var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; +-function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { +- return (0, import_react5.createElement)("input", { +- className: "bt-secret-input", +- type: MASK_BY_CSS ? "text" : "password", +- "data-masked": MASK_BY_CSS ? "" : void 0, +- value, +- autoFocus, +- autoComplete: MASK_BY_CSS ? "off" : "new-password", +- autoCorrect: "off", +- autoCapitalize: "off", +- spellCheck: false, +- "data-1p-ignore": "", +- "data-lpignore": "true", +- "data-bwignore": "", +- "data-form-type": "other", +- "aria-label": label, +- placeholder: t("Paste or type the key"), +- onChange: (event) => onChange(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- onEnter?.(); +- } +function EarlierParts({ sessionId, roster, actions }) { + const [view, setView] = (0, import_react9.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); + const top = (0, import_react9.useRef)(null); @@ -6818,16 +7013,7 @@ diff --git a/client.js b/client.js + }), + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) + ); - } --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 TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6842,19 +7028,7 @@ diff --git a/client.js b/client.js + 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(",") : ""; - }); --} --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); ++ }); + const peers = useTurnClock((state) => { + if (!relayed) return ""; + const turns = state[sessionId] ?? {}; @@ -6865,7 +7039,7 @@ diff --git a/client.js b/client.js + 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_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)("div", { className: "bt-open-new" }, (0, import_react9.createElement)(NewDivider), (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, unreadOpening)), body) : body; + function triggerBody() { @@ -6886,8 +7060,7 @@ diff --git a/client.js b/client.js + 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_react9.createElement)(GroupMessage, { bot: poster, text: stripHeader(contentText(data.content)), room: room2, roster, actions, msgKey: `${sessionId}:${data.turn}:post`, useUi }); - } -- for (const name of list) names.add(name); ++ } + if (source.role === "report") { + return (0, import_react9.createElement)(GroupEvent, { prefix: t("Replies from"), room: roster.roomsById[source.roomId], name: t("a group chat"), roster, actions }); + } @@ -6912,28 +7085,7 @@ diff --git a/client.js b/client.js + onOpen: (bot) => actions.openExchange(selfId, bot.id), + whole: true + }); - } -- 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 (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")); @@ -6942,46 +7094,37 @@ diff --git a/client.js b/client.js + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } } -- --// 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]); +-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)) +- ); +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"))); -+} + } +-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 TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react9.useEffect)(() => { @@ -7000,55 +7143,97 @@ diff --git a/client.js b/client.js + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; } --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 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)); - }; -- 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 Nothing() { -+ return 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" }) -- ); -+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([ -+ "list_bots", -+ "message_bot", -+ "stop_bot", -+ "create_bot", -+ "update_bot", -+ "list_models", -+ "bot_schedules", -+ "create_group", +- 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 Nothing() { ++ return null; + } +-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)) +- ) +- ); ++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([ ++ "list_bots", ++ "message_bot", ++ "stop_bot", ++ "create_bot", ++ "update_bot", ++ "list_models", ++ "bot_schedules", ++ "create_group", + "update_group", + "delete_group", + "post_to_group", @@ -7090,8 +7275,8 @@ diff --git a/client.js b/client.js + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } --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 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 }); +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -7103,36 +7288,32 @@ diff --git a/client.js b/client.js + const sameYear = date.getFullYear() === new Date(now).getFullYear(); + return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; } --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 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") }))); +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 null; + return next === void 0 ? void 0 : turns[next].first; } --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 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 openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -7142,18 +7323,38 @@ diff --git a/client.js b/client.js + const earlier = listed.filter((entry) => entry < turn); + return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : 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 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")) +- ) +- ); +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; @@ -7162,7 +7363,35 @@ diff --git a/client.js b/client.js + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; + } + return false; -+} + } +-function ValueEditor({ secret, onSave }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [replaced, setReplaced] = (0, import_react5.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_react5.createElement)( +- "div", +- { className: "bt-secret-edit-block" }, +- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-inline" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), +- (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 +var settledVisit = (visit) => typeof visit?.seen === "number" && visit.seen < visit.entered ? { ...visit, seen: visit.entered } : visit; +function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { + const turn = location2.turn; @@ -7188,13 +7417,48 @@ diff --git a/client.js b/client.js + fresh ? (0, import_react9.createElement)(NewDivider) : 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]]; +-function SecretRow({ secret, roster, actions, run, open, onToggle }) { +- const [confirm, setConfirm] = (0, import_react5.useState)(false); +- (0, import_react5.useEffect)(() => { +- if (!open) setConfirm(false); +- }, [open]); +- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); +- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); +- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, +- (0, import_react5.createElement)( +- "button", +- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, +- (0, import_react5.createElement)( +- "span", +- { className: "bt-set-copy" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), +- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) +- ), +- (0, import_react5.createElement)( +- "span", +- { 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 }))) +- ) +- ), +- open ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-edit" }, +- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), +- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-row-actions" }, +- confirm ? [ +- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), +- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), +- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) +- ] : (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 Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); +} @@ -7254,29 +7518,135 @@ diff --git a/client.js b/client.js + if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; + const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; + return { label: phrase ? t(phrase) : null, state: toolState(name) }; - } -- 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 -- ); +-function AddSecret({ actions, run, onDone }) { +- const [name, setName] = (0, import_react5.useState)(""); +- const [purpose, setPurpose] = (0, import_react5.useState)(""); +- const [value, setValue] = (0, import_react5.useState)(""); +- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; +- const save = () => { +- if (!ready) return; +- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { +- if (ok) { +- setValue(""); +- 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")) +- ) +- ); +function workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); -+} + } +-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"))) +- ]; +function workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; -+} + } +-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"); +function useLiveNote(actions, sessionId, id, entry) { + const key = entry ? JSON.stringify(entry) : ""; + (0, import_react9.useEffect)(() => { @@ -7284,7 +7654,26 @@ diff --git a/client.js b/client.js + actions.noteLive(sessionId, id, JSON.parse(key)); + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); -+} + } +-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)( +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react9.useState)({ replies: [] }); + (0, import_react9.useEffect)(() => { @@ -7316,24 +7705,21 @@ diff --git a/client.js b/client.js + } catch { + replies = []; + } - } -- 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}`); ++ } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.createElement)( -+ "div", + "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)( + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7345,14 +7731,44 @@ diff --git a/client.js b/client.js + return (0, import_react9.createElement)(PersonMessage, { key: `user-${index}`, name: who, texts: [remoteMessageText(reply.text)], roster, selfId: sessionId, actions, sessionId, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + } + return (0, import_react9.createElement)( -+ "div", + "div", +- { className: "bt-msg-line" }, +- lead && last ? lead : null, +- (0, import_react6.createElement)( + { key: `user-${index}`, className: "bt-urow", "data-author": reply.authorId, "data-author-name": who }, + who ? (0, import_react9.createElement)("span", { className: "bt-uauthor" }, who) : null, + (0, import_react9.createElement)( -+ "div", + "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 }) + { className: "bt-ububble" }, + (0, import_react9.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: remoteMessageText(reply.text), labels: mdLabels(), variant: "compact" }) -+ ), + ), +- 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 +- ); +- }) +- ); +-} +-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(); + 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 + ); + } @@ -7390,7 +7806,15 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) + ) + ); -+ } + } +- }; +- window.addEventListener("mousedown", onDown); +- window.addEventListener("keydown", onKey, true); +- return () => { +- window.removeEventListener("mousedown", onDown); +- window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); + if (reply.kind === "stopped") { + return (0, import_react9.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + } @@ -7398,9 +7822,7 @@ diff --git a/client.js b/client.js + if ((reply.kind === "notice" || reply.kind === "left") && leaveNoticeCoveredByClose(room2, reply.text)) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react9.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ } + const remote = reply.remote || reply.handle && !roster.byId[reply.botId]; + if (remote) { + return (0, import_react9.createElement)(RemoteMessage, { @@ -7415,33 +7837,87 @@ 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 }); - }) - ); - } -- --// 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" })); -+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) { -+ const calls = []; -+ const seen = /* @__PURE__ */ new Set(); -+ const visit = (call4) => { -+ const id = call4.callId ?? call4; -+ if (seen.has(id)) return; -+ seen.add(id); -+ calls.push(call4); -+ for (const child of call4.subCalls ?? []) visit(child); -+ }; -+ visit(root); -+ return calls; ++ }) ++ ); } --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 useShownCards(actions, sessionId, callIds) { -+ const key = JSON.stringify(callIds); +-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); ++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) { ++ const calls = []; ++ const seen = /* @__PURE__ */ new Set(); ++ const visit = (call4) => { ++ const id = call4.callId ?? call4; ++ if (seen.has(id)) return; ++ seen.add(id); ++ calls.push(call4); ++ for (const child of call4.subCalls ?? []) visit(child); + }; +- 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 +- ); ++ visit(root); ++ return calls; + } +-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 useShownCards(actions, sessionId, callIds) { ++ const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { + const ids = JSON.parse(key); + if (ids.length === 0 || !actions.noteCards) return void 0; @@ -7449,14 +7925,15 @@ diff --git a/client.js b/client.js + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); } --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" }) -- ); +-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 renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7534,10 +8011,41 @@ diff --git a/client.js b/client.js + return null; + } } --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 AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, useTurnData, useRoster, useUi, useTurnSteps, useTurnContext, actions }) { +function ToolCell({ node, sessionId, useRoster, 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 root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); @@ -7552,13 +8060,32 @@ 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 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 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); + +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; @@ -7567,11 +8094,22 @@ diff --git a/client.js b/client.js + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } --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 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)( +- "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 }) - ); + +// src/client/remote-approval-wait.js @@ -7581,11 +8119,12 @@ diff --git a/client.js b/client.js + if (tool && inputAt <= (tool.at ?? 0)) return null; + return { key: "remote-approval", label: REMOTE_APPROVAL_LABEL, state: "thinking" }; } --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" }) +-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 - ); + +// src/client/activity-row.js @@ -7599,8 +8138,19 @@ diff --git a/client.js b/client.js + const hours = Math.floor(minutes / 60); + return minutes % 60 === 0 ? `${hours}h` : `${hours}h ${minutes % 60}m`; } --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 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)( +- "div", +- { className: "bt-event" }, +- (0, import_react6.createElement)("button", { type: "button", title: result, onClick: () => actions.openMemory(target.id) }, (0, import_react6.createElement)(MemoryIcon), label) +- ); +var RUNNING_HOST = "[data-chat-flow] > [data-chat-running]"; +var TAIL_HOST = "tail"; +var REMOTE_GRACE_MS = 1500; @@ -7615,14 +8165,8 @@ diff --git a/client.js b/client.js + }, [shown, at]); + return shown; } --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 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 findHost(where) { + if (where !== TAIL_HOST) return document.querySelector(where); + const flow = document.querySelector("[data-conversation-scroll] [data-chat-flow]") ?? document.querySelector("[data-chat-flow]"); @@ -7636,15 +8180,7 @@ diff --git a/client.js b/client.js + let top = -1; + for (const node of document.querySelectorAll(`[data-chat-flow-kind${kind ? `="${kind}"` : ""}][data-chat-turn]`)) top = Math.max(top, Number(node.dataset.chatTurn)); + return top; - }; --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 useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react10.useRef)(-1); + const wasSending = (0, import_react10.useRef)(false); @@ -7679,32 +8215,31 @@ diff --git a/client.js b/client.js + }, [remote, sending]); + return replied; } --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 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 useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react10.useState)(null); + (0, import_react10.useEffect)(() => { @@ -7720,51 +8255,137 @@ diff --git a/client.js b/client.js + }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(find); -+ }); + }); +- }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); + observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); + find(); -+ return () => { + return () => { +- alive.current = false; + cancelAnimationFrame(frame); + observer.disconnect(); -+ }; + }; +- }, [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)( +- "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 +- ), +- 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 }) +- ); + }, [active, where]); + return host; } --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 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); +function useRowEntrances(replacing) { + const check = (0, import_react10.useRef)(replacing); + check.current = replacing; @@ -7785,13 +8406,18 @@ diff --git a/client.js b/client.js + observer.disconnect(); + column = next; + if (column) observer.observe(column, { childList: true, subtree: true }); -+ } + } +- 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 (!column) return; + const items = [...column.querySelectorAll(":scope > [data-chat-flow-key]")]; + let last = -1; + items.forEach((item, index) => { + if (seen.has(item)) last = index; -+ }); + }); + const fresh = items.filter((item) => !seen.has(item) && item.offsetHeight > 0); + for (const item of fresh) seen.add(item); + if (last < 0 || Date.now() - started < 800 || fresh.length > 3) return; @@ -7800,11 +8426,8 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); - } - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ } ++ }; + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -7814,20 +8437,7 @@ diff --git a/client.js b/client.js + observer.disconnect(); + }; + }, []); - } --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 ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); + const running = useSessionStatus((map) => map.get(sessionId)?.running === true); @@ -7867,29 +8477,9 @@ diff --git a/client.js b/client.js + make(); + return void 0; } -- 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 === "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")); + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -7903,21 +8493,9 @@ diff --git a/client.js b/client.js + setPhase("in"); + return void 0; } -- }) : 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)( +- 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")); +- } + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -7968,136 +8546,16 @@ diff --git a/client.js b/client.js + "span", + { className: "bt-typing-text" }, + (0, import_react10.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(); -- } -- }, -- (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 -- ); --} --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")) ++ "span", + { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, + (0, import_react10.createElement)(Shimmer, null, `${shown.text}…`), + elapsed ? (0, import_react10.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null - ) -- ); -- } -- 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 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; -- } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ ) + ) + ), host); } --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 }) -- ); +-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/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -8108,12 +8566,23 @@ 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 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 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; + +// src/client/overlay-hooks.js +var import_react11 = require("react"); @@ -8133,18 +8602,8 @@ diff --git a/client.js b/client.js + }; + }, []); } --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: [] } -- }))); +-function Nothing() { +- return null; +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -8153,35 +8612,17 @@ diff --git a/client.js b/client.js + }, []); + return paneLeft; } - --// src/client/secret-card.js --var import_react5 = require("react"); --var MIN_VALUE = 4; --var NONE = {}; --var KeyIcon = () => (0, import_react5.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_react5.createElement)("path", { d: "M7 9.5a3.5 3.5 0 1 1 0 .01z" }), -- (0, import_react5.createElement)("path", { d: "M10.4 10.6L17 10.6M14.5 10.6v2.4M16.6 10.6v1.8" }) --); --var LockIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M6.5 9V7a3.5 3.5 0 0 1 7 0v2" }), -- (0, import_react5.createElement)("rect", { x: 4.5, y: 9, width: 11, height: 8, rx: 2 }) --); --var WarnIcon = () => (0, import_react5.createElement)( -- "svg", -- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react5.createElement)("path", { d: "M10 3.5l7 12.5H3z" }), -- (0, import_react5.createElement)("path", { d: "M10 8.5v3.5M10 14.2h.01" }) --); --var MASK_BY_CSS = typeof CSS !== "undefined" && typeof CSS.supports === "function" && CSS.supports("-webkit-text-security", "disc"); --var insecure = () => typeof location !== "undefined" && location.protocol === "http:" && !/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])$/.test(location.hostname); --var scopeLabel = (scope, roster) => { -- 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 ")); +-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/member-status.js +var import_react12 = require("react"); +var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; @@ -8189,31 +8630,7 @@ diff --git a/client.js b/client.js +var stepIn = (work, botId, roomId, tool) => { + const own = work.bots?.[botId]; + return own?.roomId === roomId && own.tool === tool ? own.step : void 0; - }; --var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; --function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { -- return (0, import_react5.createElement)("input", { -- className: "bt-secret-input", -- type: MASK_BY_CSS ? "text" : "password", -- "data-masked": MASK_BY_CSS ? "" : void 0, -- value, -- autoFocus, -- autoComplete: MASK_BY_CSS ? "off" : "new-password", -- autoCorrect: "off", -- autoCapitalize: "off", -- spellCheck: false, -- "data-1p-ignore": "", -- "data-lpignore": "true", -- "data-bwignore": "", -- "data-form-type": "other", -- "aria-label": label, -- placeholder: t("Paste or type the key"), -- onChange: (event) => onChange(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- onEnter?.(); -- } ++}; +var workIn = (room2, work, id) => { + const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); + if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; @@ -8226,37 +8643,60 @@ 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 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)) -+function busyMembers(room2, work, roster) { +-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 next === void 0 ? void 0 : turns[next].first; ++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 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 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)) }; -+} + } +-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 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 }] : []; -+ }); + }); +- if (!label && !fresh) return null; +- return (0, import_react6.createElement)( +- import_react6.Fragment, +- null, +- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, +- fresh ? (0, import_react6.createElement)(NewDivider) : null +} +function summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); @@ -8274,18 +8714,8 @@ diff --git a/client.js b/client.js + (0, import_react12.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ); } --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 Shimmer({ children }) { +- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); +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)}`; @@ -8296,55 +8726,64 @@ diff --git a/client.js b/client.js + (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 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 }) : "" +-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 TOOL_PHRASES = [ +- [/web_?search|search_?web/i, "Searching online"], +- [/fetch|browse|browser|web/i, "Reading a page"], +- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], +- [/^(read|view|cat)\b|read_/i, "Opening a file"], +- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], +- [/write|edit|patch|apply|replace/i, "Writing changes"], +- [/image|photo|draw/i, "Working on an image"] +-]; +-var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; +-function toolActivity(name, arg, findBot, self) { +- switch (name) { +- case "ask_user": +- return null; +- case "request_secret": +- return null; +- case "list_secrets": +- return { label: t("Checking the keys"), state: "searching" }; +- case "ask_user_question": +- return { label: t("Waiting for your answer"), state: "alert" }; +- case "group_relay": +- return { relay: true, state: "orbit" }; +- case "message_bot": { +- const target = findBot(arg("to")); +- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; +- } +- case "stop_bot": { +- const target = findBot(arg("bot")); +- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +- } +- case "create_bot": +- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; +- case "update_bot": { +- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); +- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; +- } +- case "list_bots": +- return { label: t("Checking the team"), state: "searching" }; +- case "read_group_chat": +- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; +- case "read_own_chat": +- return { label: t("Looking back through the chat"), state: "searching" }; +- case "remember": +- return { label: t("Saving to memory"), state: "working" }; +- case "forget": +- return { label: t("Updating memory"), state: "working" }; +- case "recall": +- return { label: t("Checking memory"), state: "searching" }; +- default: +- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; +- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; +- return { label: phrase ? t(phrase) : null, state: toolState(name) }; +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -8358,26 +8797,10 @@ diff --git a/client.js b/client.js + waiting ? (0, import_react12.createElement)("span", { className: "bt-mw-ask" }, waiting) : null, + waiting && doing ? " · " : null, + doing ? (0, import_react12.createElement)(Shimmer, null, doing) : null - ), -- 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")) -- ) ++ ), + elapsed ? (0, import_react12.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null - ); - } --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)( ++ ); ++} +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -8395,46 +8818,12 @@ diff --git a/client.js b/client.js + const { rows, rest } = splitBusy(busy); + const open = () => actions.showDetails(sessionId); + return (0, import_react12.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 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 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") }))); ++ "div", + { ref, className: "bt-mw-dock-stack" }, + rows.map((entry) => (0, import_react12.createElement)(WorkRow, { key: entry.id, bot: entry.bot, tool: entry.doing.tool, step: entry.doing.step, label: entry.bot.name, at: entry.doing.since, now, onOpen: open })), + rest.length > 0 ? (0, import_react12.createElement)(SummaryLine, { busy: rest, now, onOpen: open }) : null + ); } -- 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_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 }))); + return (0, import_react12.createElement)("div", { ref, className: "bt-mw-dock-stack" }, groups.slice(0, ROWS_MAX).map((item) => (0, import_react12.createElement)(WorkRow, { + key: item.room.id, + bot, @@ -8445,39 +8834,7 @@ diff --git a/client.js b/client.js + now, + onOpen: () => actions.openSession(item.room.id) + }))); - } --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 useFlowColumn(ref, active, key) { + (0, import_react12.useLayoutEffect)(() => { + const node = ref.current; @@ -8494,23 +8851,7 @@ diff --git a/client.js b/client.js + for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); + return () => observer.disconnect(); + }, [active, key]); - } --function ValueEditor({ secret, onSave }) { -- const [value, setValue] = (0, import_react5.useState)(""); -- const [replaced, setReplaced] = (0, import_react5.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); ++} +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -8530,91 +8871,30 @@ diff --git a/client.js b/client.js + const answer = (choice) => { + setBusy(true); + void Promise.resolve(actions.answerApproval(approval.id, choice)).catch((error) => actions.reportError(error, "approval")).finally(() => setBusy(false)); - }; -- return (0, import_react5.createElement)( ++ }; + return (0, import_react12.createElement)( - "div", -- { className: "bt-secret-edit-block" }, -- (0, import_react5.createElement)("div", { className: "bt-secret-edit-label" }, t("Replace the value")), -- (0, import_react5.createElement)( ++ "div", + { 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, + approval.reason ? (0, import_react12.createElement)("div", { className: "bt-mw-reason" }, approval.reason) : null, + 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", -- { className: "bt-secret-inline" }, -- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), -- (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 SecretRow({ secret, roster, actions, run, open, onToggle }) { -- const [confirm, setConfirm] = (0, import_react5.useState)(false); -- (0, import_react5.useEffect)(() => { -- if (!open) setConfirm(false); -- }, [open]); -- const asker = secret.requestedBy ? roster.byId[secret.requestedBy]?.name ?? t("a deleted Bot") : t("you"); -- const allowed = secret.scope === "all" ? [] : secret.scope.map((id) => roster.byId[id]).filter(Boolean); -- const meta = [t("Asked by {name}", { name: asker }), t("added {time}", { time: when(secret.createdAt) }), secret.lastUsedAt ? t("last used {time}", { time: when(secret.lastUsedAt) }) : t("not used yet")].join(" · "); -- return (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, -- (0, import_react5.createElement)( -- "button", -- { type: "button", className: "bt-set-row bt-secret-summary", "aria-expanded": open, onClick: onToggle }, -- (0, import_react5.createElement)( -- "span", -- { className: "bt-set-copy" }, -- (0, import_react5.createElement)("span", { className: "bt-secret-title" }, (0, import_react5.createElement)(KeyIcon), (0, import_react5.createElement)("code", { className: "bt-secret-name" }, secret.name), secret.purpose ? (0, import_react5.createElement)("span", { className: "bt-secret-purpose" }, secret.purpose) : null), -- (0, import_react5.createElement)("span", { className: "bt-set-hint" }, meta) -- ), -- (0, import_react5.createElement)( -- "span", -- { 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 }))) -- ) -- ), -- open ? (0, import_react5.createElement)( ++ "div", + { 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", -- { className: "bt-secret-edit" }, -- (0, import_react5.createElement)(ScopeEditor, { key: JSON.stringify(secret.scope), secret, roster, onSave: (scope) => run(actions.secretUpdate(secret.name, scope)) }), -- (0, import_react5.createElement)(ValueEditor, { secret, onSave: (value) => run(actions.secretSet({ name: secret.name, value })) }), -- (0, import_react5.createElement)( -- "div", -- { className: "bt-secret-row-actions" }, -- confirm ? [ -- (0, import_react5.createElement)("span", { key: "q", className: "bt-set-hint" }, t("Delete {name}? Bots lose it at once.", { name: secret.name })), -- (0, import_react5.createElement)("button", { key: "no", type: "button", className: "bt-soft bt-soft-sm", onClick: () => setConfirm(false) }, t("Keep")), -- (0, import_react5.createElement)("button", { key: "yes", type: "button", className: "bt-soft bt-soft-sm bt-secret-danger", onClick: () => run(actions.secretDelete(secret.name)) }, t("Delete")) -- ] : (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 ++ "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 AddSecret({ actions, run, onDone }) { -- const [name, setName] = (0, import_react5.useState)(""); -- const [purpose, setPurpose] = (0, import_react5.useState)(""); -- const [value, setValue] = (0, import_react5.useState)(""); -- const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; -- const save = () => { -- if (!ready) return; -- void run(actions.secretSet({ name, purpose, value, scope: "all" })).then((ok) => { -- if (ok) { -- setValue(""); -- onDone(); -- } -- }); +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function MemberWork({ entry, bot, room: room2, actions, now }) { + const [stopping, setStopping] = (0, import_react12.useState)(false); + if (entry.state === "ask") { @@ -8629,194 +8909,36 @@ 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)); - }; -- return (0, import_react5.createElement)( ++ }; + return (0, import_react12.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-mw-member" }, + (0, import_react12.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")) ++ "div", + { className: "bt-mw-state bt-mw-work" }, + (0, import_react12.createElement)("span", null, (0, import_react12.createElement)(Shimmer, null, workText(entry.doing.tool, entry.doing.step)), since(now, entry.doing.since)), + entry.here && entry.doing.handed !== true ? (0, import_react12.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : 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 -- ), -- 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 -- ); -- }) -- ); +-function useLiveNote(actions, sessionId, id, entry) { +- const key = entry ? JSON.stringify(entry) : ""; +- (0, import_react6.useEffect)(() => { +- if (!key) return void 0; +- actions.noteLive(sessionId, id, JSON.parse(key)); +- return () => actions.noteLive(sessionId, id, null); +- }, [sessionId, id, key]); +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 }; } --var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; --function useDismiss(open, close, refs) { +-function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { +- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); - (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); +- if (done) return void 0; +- let stop = false; +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} @@ -8897,7 +9019,20 @@ diff --git a/client.js b/client.js + (0, import_react13.useEffect)(() => { + taskListeners.add(setTasks); + let stopped = false; -+ let timer; + let timer; +- let seen = ""; +- const tick = async () => { +- const result = await actions.roomProgress(sessionId); +- if (stop) return; +- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; +- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; +- if (entry && key !== seen) { +- seen = key; +- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); +- } +- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); +- }; +- void tick(); + const load = () => Promise.resolve(actions?.tasks?.({ archive: true })).then((list) => { + if (!stopped && list !== void 0) publishTasks(list); + }).catch(() => { @@ -8906,38 +9041,21 @@ diff --git a/client.js b/client.js + }); + load(); return () => { -- window.removeEventListener("mousedown", onDown); -- window.removeEventListener("keydown", onKey, true); +- stop = true; + stopped = true; -+ clearTimeout(timer); + clearTimeout(timer); + taskListeners.delete(setTasks); }; -- }, [open]); +- }, [done, sessionId, callId]); +- let replies = live.replies; +- if (done) { +- try { +- replies = JSON.parse(contentText(content)).replies ?? []; +- } catch { +- replies = []; + }, []); + return tasks; - } --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; @@ -9067,13 +9185,8 @@ 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(); @@ -9131,8 +9244,58 @@ diff --git a/client.js b/client.js + const text = previewOf(sessions, part); + if (text === "") continue; + done.push({ id: `chat:${bot.id}`, kind: "chat", status: "done", bot, at, title: text, place: "", chat: bot.id, ...people(roster, bot.id) }); -+ } -+ } + } + } +- const pending = done || !live.speaking ? [] : [live.speaking]; +- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; +- if (replies.length === 0 && pending.length === 0) return null; +- const room2 = roster.roomsById[sessionId]; +- return (0, import_react6.createElement)( +- "div", +- { className: "bt-group-replies" }, +- replies.map((reply, index) => { +- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; +- if (reply.kind === "user") { +- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( +- "div", +- { className: "bt-ububble" }, +- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) +- )); +- } +- if (reply.kind === "failed") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, +- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) +- ); +- } +- if (reply.kind === "lookup") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) +- ); +- } +- if (reply.kind === "missed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); +- } +- if (reply.kind === "resumed") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); +- } +- if (reply.kind === "cut") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); +- } +- if (reply.kind === "approval" || reply.kind === "working") { +- return (0, import_react6.createElement)( +- "div", +- { key: `${reply.botId}-${index}`, className: "bt-event" }, +- (0, import_react6.createElement)( +- "button", +- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, +- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), +- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) +- ) +- ); + const newest = (a, b) => (b.at ?? Infinity) - (a.at ?? Infinity); + for (const card of [...needs, ...progress, ...done]) { + card.activity = latestActivity(card, { roster, work, sessions }); @@ -9195,31 +9358,7 @@ 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" }, @@ -9238,21 +9377,17 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); -+ } + } +- if (reply.kind === "stopped") { +- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + }) : null, + slot.open ? (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Send it back")) : (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + slot.open = true; + redraw((count) => count + 1); + } }, 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)( -- "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"; @@ -9308,55 +9443,18 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; + event.preventDefault(); + send(); -+ } -+ }), + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + }), +- pending.map((id) => { +- const bot = roster.byId[id]; +- if (!bot) return null; +- return (0, import_react6.createElement)( + (0, import_react13.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy, onClick: send }, t("Submit")) - ); - } --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 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 roster = useRoster((value) => value); + const work = useWork ? useWork((value) => value) : {}; + const open = useUi ? useUi((value) => Boolean(value.inbox)) : false; + const tasks = useTasks(actions); @@ -9376,45 +9474,12 @@ diff --git a/client.js b/client.js + (0, import_react13.createElement)("span", { className: "bt-inbox-mark" }, (0, import_react13.createElement)(InboxIcon)), + wide ? (0, import_react13.createElement)("span", { className: "bt-inbox-label" }, t("Inbox")) : null, + count > 0 ? (0, import_react13.createElement)("span", { className: "bt-inbox-count", "aria-hidden": true }, count > 99 ? "99+" : String(count)) : null - ); - } --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"); @@ -9432,16 +9497,11 @@ diff --git a/client.js b/client.js + const writeBack = card.kind === "task" && card.task?.writeBack?.state === "failed" ? card.task.writeBack : void 0; + const unconfirmed = card.kind === "task" && card.task?.writeBack?.state === "unconfirmed" ? card.task.writeBack : void 0; + return (0, import_react13.createElement)( - "div", -- { className: "bt-group-msg" }, -- (0, import_react6.createElement)( ++ "div", + { 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", -- { 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 ++ "span", + { className: "bt-inbox-copy" }, + (0, import_react13.createElement)( + "span", @@ -9475,8 +9535,7 @@ 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" }, @@ -9485,15 +9544,8 @@ diff --git a/client.js b/client.js + ...(answerable ? alwaysChoices(card.approval, card.bot) : []).map(([choice, label]) => (0, import_react13.createElement)("button", { key: choice, type: "button", className: "bt-soft bt-soft-sm", "data-choice": choice, disabled: busy, onClick: () => onAnswer(card, choice) }, t(label))), + (0, import_react13.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-label": who ? t("Open {name}", { name: card.room?.name ?? who }) : t("Open"), onClick: () => onOpen(card) }, t("Open")) + ) - ); - } --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)(() => { @@ -9501,16 +9553,7 @@ diff --git a/client.js b/client.js + return () => clearInterval(timer); + }, [ms]); + return now; - } --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); @@ -9548,16 +9591,19 @@ diff --git a/client.js b/client.js + 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) ++ "div", + { 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", + "div", +- { key: `pending-${id}`, className: "bt-activity" }, +- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), +- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) +- ); +- }) + { className: "bt-mem-head" }, + (0, import_react13.createElement)("h2", { id: "bt-inbox-heading" }, t("Inbox")), + (0, import_react13.createElement)("span", { className: "bt-mem-status" }, t("Your Bots")), @@ -9592,14 +9638,17 @@ 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); --} --var scrollParent = (node) => { -- for (let at = node.parentElement; at; at = at.parentElement) { -- if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; +-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) { +- const calls = []; +- const seen = /* @__PURE__ */ new Set(); +- const visit = (call2) => { +- const id = call2.callId ?? call2; +- if (seen.has(id)) return; +- seen.add(id); +- calls.push(call2); +- for (const child of call2.subCalls ?? []) visit(child); +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)} @@ -9662,27 +9711,7 @@ 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"; +} @@ -9721,73 +9750,10 @@ diff --git a/client.js b/client.js +} + +// src/shared/board-card.js -+var CARD_SOURCES = ["github", "task"]; -+function githubCardKey(repo, number) { -+ const name = typeof repo === "string" ? repo.trim() : ""; -+ const value = Number(number); -+ if (name === "" || !Number.isInteger(value) || value <= 0) return null; -+ return `${name}#${value}`; -+} +function taskCardKey(id) { + const text = String(id ?? "").trim(); + return text === "" ? null : `task:${text}`; +} -+var repoOf = (repo) => typeof repo === "string" && repo.trim() !== "" ? repo.trim() : null; -+function githubCard(row, repo) { -+ const number = Number(row?.number); -+ if (!Number.isInteger(number) || number <= 0) return null; -+ const name = repoOf(repo); -+ return { -+ source: "github", -+ key: githubCardKey(name, number) ?? `github:${row.kind ?? "item"}:${number}`, -+ repo: name, -+ number, -+ kind: row.kind ?? null, -+ title: String(row.title ?? ""), -+ badge: row.badge ?? null, -+ labels: Array.isArray(row.labels) ? row.labels.slice() : [], -+ owner: row.owner ?? null, -+ ownerKind: row.ownerKind ?? null, -+ linked: row.linked ?? null, -+ sha: row.sha ?? null, -+ url: row.url ?? null - }; -- (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)( -- "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; @@ -9795,1321 +9761,10 @@ diff --git a/client.js b/client.js + source: "task", + key, + id: String(task.id), -+ repo: repoOf(task.repo), -+ number: null, -+ kind: "task", -+ title: String(task.title ?? ""), -+ badge: null, -+ labels: [], -+ owner: null, -+ ownerKind: null, -+ linked: null, -+ sha: null, -+ url: null -+ }; -+} -+function sameCard(a, b) { -+ return Boolean(a?.key) && a.key === b?.key; -+} -+ -+// src/client/board-card.js -+var import_react14 = require("react"); -+var DELIVERY_SKIN = { -+ card: "bt-del-card", -+ top: "bt-del-card-top", -+ id: "bt-del-id", -+ badge: "bt-del-badge", -+ title: "bt-del-title", -+ labels: "bt-del-labels", -+ label: "bt-del-label", -+ owner: "bt-del-owner", -+ linked: "bt-del-linked", -+ sha: "bt-del-sha" -+}; -+var TASK_SKIN = { -+ card: "bt-board-card", -+ top: "bt-board-card-top", -+ id: "bt-board-id", -+ badge: "bt-board-badge", -+ title: "bt-board-title", -+ labels: "bt-board-labels", -+ label: "bt-board-label", -+ owner: "bt-board-who", -+ linked: "bt-board-linked", -+ sha: "bt-board-sha", -+ note: "bt-board-note" -+}; -+function BoardCard({ -+ card, -+ skin = DELIVERY_SKIN, -+ attrs = {}, -+ idText = "", -+ topExtra = null, -+ badge = "", -+ meta = null, -+ note = "", -+ footer = null, -+ actions = null, -+ emptyTitle = "", -+ draggable = false, -+ dragValue = "", -+ onDragStart = null, -+ onDragEnd = null -+}) { -+ const labels = Array.isArray(card?.labels) ? card.labels : []; -+ return (0, import_react14.createElement)( -+ "article", -+ { -+ ...attrs, -+ className: skin.card, -+ "data-source": card?.source, -+ "data-key": card?.key ?? void 0, -+ draggable: draggable || void 0, -+ onDragStart: draggable ? (event) => { -+ event.dataTransfer?.setData("text/plain", dragValue); -+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; -+ onDragStart?.(); -+ } : void 0, -+ onDragEnd: draggable ? () => onDragEnd?.() : void 0 -+ }, -+ (0, import_react14.createElement)( -+ "span", -+ { className: skin.top }, -+ (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, -+ note ? (0, import_react14.createElement)("span", { className: skin.note ?? skin.linked }, note) : null, -+ meta, -+ card?.linked ? (0, import_react14.createElement)("span", { className: skin.linked }, t("linked to #{number}", { number: card.linked })) : null, -+ card?.sha ? (0, import_react14.createElement)("span", { className: skin.sha }, card.sha) : null, -+ 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 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"); -+var CHART_WIDTH = 168; -+var CHART_HEIGHT = 28; -+var PAD = 3; -+var DOT_RADIUS = 2; -+var HOLLOW_RADIUS = 2.5; -+var CHART_DAYS = 7; -+var CHART_KEYS = ["newIssues", "closedIssues", "newPullRequests", "mergedPullRequests", "ci"]; -+var COUNTING_KEYS = ["newIssues", "closedIssues", "newPullRequests", "mergedPullRequests"]; -+function chartDays(daily) { -+ if (!Array.isArray(daily)) return null; -+ const days = daily.filter((day) => day && typeof day === "object"); -+ return days.length ? days.slice(-CHART_DAYS) : null; -+} -+function todayPartial(daily) { -+ const days = chartDays(daily); -+ return Boolean(days && days.length && days[days.length - 1].partial === true); -+} -+function dayValue(day, key) { -+ if (!day || typeof day !== "object") return null; -+ if (key === "ci") { -+ const failed = day.ci?.failed; -+ const counted = day.ci?.counted; -+ const total = day.ci?.total; -+ 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)); -+ } -+ const value = day[key]; -+ return Number.isFinite(value) ? value : null; -+} -+function dayLabel2(day) { -+ if (typeof day?.date === "string" && /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(day.date)) return day.date; -+ const at = Date.parse(day?.start ?? ""); -+ if (!Number.isFinite(at)) return ""; -+ return new Date(at + 8 * 36e5).toISOString().slice(0, 10); -+} -+function pointTitle(day, key) { -+ const date = dayLabel2(day); -+ const value = dayValue(day, key); -+ const shown = value === null ? t("not available") : key === "ci" ? `${Math.round(value * 100)}%` : String(value); -+ return date ? `${date} · ${shown}` : shown; -+} -+function knownDays(daily, key) { -+ const days = chartDays(daily) ?? []; -+ return days.filter((day) => dayValue(day, key) !== null).length; -+} -+var round = (value) => Math.round(value * 100) / 100; -+function chartGeometry(daily, key, { width = CHART_WIDTH, height = CHART_HEIGHT } = {}) { -+ const days = chartDays(daily); -+ if (!days) return null; -+ const values = days.map((day) => dayValue(day, key)); -+ const known = values.filter((value) => value !== null); -+ const top = known.length ? Math.max(...known, 0) : 0; -+ const span = top > 0 ? top : 1; -+ const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0; -+ const dots = days.map((day, i) => { -+ const value = values[i]; -+ const last = i === days.length - 1; -+ const state = value === null ? "missing" : last && day.partial === true ? "partial" : "full"; -+ return { -+ index: i, -+ x: round(PAD + step * i), -+ y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), -+ today: last, -+ // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. -+ hollow: state === "partial", -+ partial: state === "partial", -+ state, -+ title: pointTitle(day, key), -+ value -+ }; - }); -- 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); -+ const segments = []; -+ let run = null; -+ for (const dot of dots) { -+ if (dot.y === null) { -+ if (run) { -+ segments.push(run); -+ run = null; - } -- 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")); -+ if (!run) run = { dots: [] }; -+ run.dots.push(dot); - } -+ if (run) segments.push(run); -+ return { -+ width, -+ height, -+ dots, -+ known: known.length, -+ segments: segments.map((segment) => ({ -+ d: segment.dots.map((dot, i) => `${i ? "L" : "M"}${dot.x} ${dot.y}`).join(" "), -+ // Only the stretch that ends today is the day that is not over yet. -+ dashed: segment.dots.some((dot) => dot.partial), -+ today: segment.dots.some((dot) => dot.today) -+ })) -+ }; - } --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 DeliveryChart({ daily, metricKey, label }) { -+ const geo = chartGeometry(daily, metricKey); -+ if (!geo || !geo.known) return null; -+ const name = label ? t("{label} over the last 7 days", { label }) : t("Last 7 days"); -+ return (0, import_react15.createElement)( -+ "svg", -+ { -+ className: "bt-del-chart", -+ "data-flybot": "delivery-chart", -+ "data-metric": metricKey, -+ width: geo.width, -+ height: geo.height, -+ viewBox: `0 0 ${geo.width} ${geo.height}`, -+ role: "img", -+ "aria-label": name -+ }, -+ geo.segments.map((segment, i) => (0, import_react15.createElement)("path", { -+ key: `s${i}`, -+ className: "bt-del-chart-line", -+ "data-today": segment.today ? "true" : void 0, -+ ...segment.dashed ? { "stroke-dasharray": "2 2" } : {}, -+ d: segment.d -+ })), -+ geo.dots.filter((dot) => dot.y !== null).map((dot) => (0, import_react15.createElement)("circle", { -+ key: `d${dot.index}`, -+ className: "bt-del-chart-dot", -+ "data-state": dot.state, -+ "data-hollow": dot.hollow ? "true" : void 0, -+ cx: dot.x, -+ cy: dot.y, -+ r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS -+ }, (0, import_react15.createElement)("title", null, dot.title))) -+ ); - } --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; -+function DeliveryMetrics({ rows = [], daily }) { -+ const days = chartDays(daily); -+ const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); -+ const fewest = short.length ? Math.min(...short) : 0; -+ const head = (0, import_react15.createElement)( -+ "div", -+ { className: "bt-del-dm-head", "data-flybot": "delivery-metrics-head" }, -+ (0, import_react15.createElement)("span", { className: "bt-del-dm-label" }, ""), -+ (0, import_react15.createElement)("span", { className: "bt-del-dm-chart-head" }, ""), -+ (0, import_react15.createElement)("span", { className: "bt-del-dm-value" }, t("Today")), -+ (0, import_react15.createElement)("span", { className: "bt-del-dm-value bt-del-dm-week" }, t("7 days")) -+ ); -+ const body = rows.map((row, index) => { -+ const key = CHART_KEYS.includes(row?.key) ? row.key : null; -+ const chart = days && key ? (0, import_react15.createElement)(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) : null; -+ const counted = key !== null && COUNTING_KEYS.includes(key); -+ const star = days && counted && knownDays(days, key) < days.length ? "*" : ""; -+ const cells = [ -+ (0, import_react15.createElement)("span", { key: "label", className: "bt-del-dm-label" }, row?.label ?? ""), -+ // A median row gets no chart cell at all: no line, no placeholder box, no total. -+ ...key ? [(0, import_react15.createElement)("span", { key: "chart", className: "bt-del-dm-chart" }, chart)] : [], -+ (0, import_react15.createElement)("span", { key: "today", className: "bt-del-dm-value bt-del-dm-today" }, row?.today ?? "—"), -+ (0, import_react15.createElement)("span", { key: "week", className: "bt-del-dm-value bt-del-dm-week" }, counted ? `Σ ${row?.week ?? "—"}${star}` : row?.week ?? "—") -+ ]; -+ return (0, import_react15.createElement)("div", { key: row?.label ?? String(index), className: "bt-del-dm-row", "data-metric": key ?? void 0 }, cells); - }); -- return fresh ? (0, import_react6.createElement)(NewDivider) : null; --} --function Nothing() { -- return null; -+ return (0, import_react15.createElement)( -+ "div", -+ { className: "bt-del-dm", "data-flybot": "delivery-metrics" }, -+ head, -+ body, -+ days ? null : (0, import_react15.createElement)("p", { className: "bt-del-chart-empty", "data-flybot": "delivery-chart-empty" }, t("No 7-day data yet.")), -+ days && short.length ? (0, import_react15.createElement)("p", { className: "bt-del-chart-foot", "data-flybot": "delivery-chart-foot" }, t("* {count} of {days} days had data", { count: fewest, days: days.length })) : 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}`; -+var DELIVERY_CHART_CSS = ` -+.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} -+.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} -+.bt-del-dm-head{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -+.bt-del-dm-label{flex:1 1 auto;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -+.bt-del-dm-head .bt-del-dm-label{color:inherit} -+.bt-del-dm-chart,.bt-del-dm-chart-head{flex:0 0 168px;display:flex;align-items:center;min-height:28px} -+.bt-del-dm-value{flex:0 0 auto;min-width:56px;text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--bt-ink)} -+.bt-del-dm-head .bt-del-dm-value{color:inherit;font-weight:inherit} -+.bt-del-dm-today{color:var(--bt-ink);font-weight:600} -+.bt-del-dm-week{color:var(--bt-ink-3);font-weight:400} -+.bt-del-chart{display:block;overflow:visible} -+.bt-del-chart-line{fill:none;stroke:var(--bt-accent);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} -+.bt-del-chart-dot{fill:var(--bt-accent);stroke:none} -+.bt-del-chart-dot[data-state=partial]{fill:none;stroke:var(--bt-accent);stroke-width:1.5} -+.bt-del-chart-empty,.bt-del-chart-foot{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -+@media (max-width:899.98px){ -+ .bt-del-dm-head{display:none} -+ .bt-del-dm-row{flex-wrap:wrap;gap:4px 8px;min-height:44px;align-content:center} -+ .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} -+ .bt-del-dm-value{min-width:0} - } --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; -+`; -+ -+// src/client/delivery-view.js -+var import_react16 = require("react"); -+var POLL_MS3 = 6e4; -+var LOADING_POLL_MS = 2e3; -+var RETRY_FLOOR_MS = 1e3; -+var TIMER_MAX_MS = 2 ** 31 - 1; -+function pollDelay(state, payload = null, now = Date.now()) { -+ if (state === "loading" || state === "idle") return LOADING_POLL_MS; -+ const at = Number(payload?.retryAt); -+ if ((state === "failed" || state === "stale") && Number.isFinite(at) && at > 0) { -+ return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS); - } -- 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; -+ return POLL_MS3; - } --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; -- }); -- if (!label && !fresh) return null; -- return (0, import_react6.createElement)( -- import_react6.Fragment, -- null, -- label ? (0, import_react6.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, -- fresh ? (0, import_react6.createElement)(NewDivider) : null -+var DELIVERY_COLUMNS = [ -+ { id: "not-started", label: "Not started" }, -+ { id: "in-progress", label: "In progress" }, -+ { id: "done", label: "Done" } -+]; -+function stagesOf(view) { -+ const columns = view?.columns; -+ if (columns === null || typeof columns !== "object") return DELIVERY_COLUMNS; -+ const present = Object.keys(columns); -+ if (present.length === 0) return DELIVERY_COLUMNS; -+ const known = DELIVERY_COLUMNS.filter((column) => present.includes(column.id)); -+ const unlabelled = present.filter((id) => !DELIVERY_COLUMNS.some((column) => column.id === id)).map((id) => ({ id, label: id })); -+ return [...known, ...unlabelled]; -+} -+var BADGES = { -+ inc: "INC · rework", -+ "pass-enqueue": "PASS · waiting for enqueue", -+ "pass-merge": "PASS · waiting for merge", -+ "ci-red": "CI red", -+ "ci-running": "CI running", -+ "no-verdict": "No verdict", -+ // The checks could not be read: that is not "no verdict", and saying so blamed the pull request -+ // for a permission the instance is missing (#1211). -+ "checks-unknown": "Checks unknown" -+}; -+var badgeLabel = (badge) => badge ? t(BADGES[badge] ?? badge) : ""; -+function taskColumnOf(task) { -+ const col = columnOf(task); -+ if (col === "not-started") return "not-started"; -+ if (col === "in-progress") return "in-progress"; -+ return "done"; -+} -+var dropProps = (column, { dragging, onMove, onDrag }) => ({ -+ onDragOver: (event) => { -+ if (!dragging) return; -+ event.preventDefault(); -+ }, -+ onDrop: (event) => { -+ event.preventDefault(); -+ const id = event.dataTransfer?.getData("text/plain") ?? ""; -+ if (id) onMove?.(id, column); -+ onDrag?.(null); -+ } -+}); -+function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { -+ const columns = { "not-started": [], "in-progress": [], "done": [] }; -+ for (const task of tasks ?? []) { -+ const col = taskColumnOf(task); -+ if (columns[col]) columns[col].push(task); -+ } -+ const columnTitles = { -+ "not-started": t("Not started"), -+ "in-progress": t("In progress"), -+ "done": t("Done") -+ }; -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-board-columns" }, -+ ["not-started", "in-progress", "done"].map((column) => (0, import_react16.createElement)( -+ "section", -+ { -+ key: column, -+ className: "bt-board-column", -+ "data-column": column, -+ "aria-label": columnTitles[column], -+ ...dropProps(column, { dragging, onMove, onDrag }) -+ }, -+ (0, import_react16.createElement)( -+ "h3", -+ { className: "bt-board-column-head" }, -+ (0, import_react16.createElement)("span", null, columnTitles[column]), -+ (0, import_react16.createElement)("span", { className: "bt-board-count" }, String(columns[column].length)) -+ ), -+ columns[column].length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : columns[column].map((task) => (0, import_react16.createElement)(BoardCard2, { -+ key: task.id, -+ task, -+ roster, -+ movable: canMoveTask(roster, task), -+ onMove, -+ onDrag -+ })) -+ )) - ); - } --function Shimmer({ children }) { -- return (0, import_react6.createElement)("span", { className: "bt-shimmer" }, children); --} --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"] -+function duration(ms) { -+ if (!Number.isFinite(ms) || ms < 0) return "—"; -+ const hours = ms / 36e5; -+ if (hours < 1) return `${Math.max(1, Math.round(ms / 6e4))}m`; -+ if (hours < 48) return `${Math.round(hours)}h`; -+ return `${Math.round(hours / 24)}d`; -+} -+var secondsOf = (ms) => String(Math.max(0, Math.round((Number(ms) || 0) / 1e3))); -+function retryCountdown(retryAt, now = Date.now()) { -+ const at = Number(retryAt); -+ if (!Number.isFinite(at) || at <= 0) return null; -+ return Math.max(0, Math.round((at - now) / 1e3)); -+} -+var loadingText = (elapsedMs) => t("First load in progress, {seconds} s elapsed", { seconds: secondsOf(elapsedMs) }); -+var staleText = (asOf, error) => t("Showing the snapshot from {time} · refresh failed: {message}", { -+ time: clockOf(asOf), -+ message: String(error?.message ?? error ?? "") -+}); -+function hintFor(error, { repo = null } = {}) { -+ if (!error) return ""; -+ const scope = error.scope ?? null; -+ const kind = error.kind ?? null; -+ const target = repo ?? t("this repository"); -+ if (kind === "no-repo") return t("Set delivery.repo in this instance's settings to the repository this instance works in."); -+ if (kind === "no-token") return t("Add a GITHUB_TOKEN in this instance's settings — one that can read {repo}.", { repo: target }); -+ if (kind === "rate-limit") { -+ return error.retryAfterMs === null || error.retryAfterMs === void 0 ? t("GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).") : t("GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.", { seconds: secondsOf(error.retryAfterMs) }); -+ } -+ if (kind === "timeout") return t("GitHub did not answer in time; the next attempt happens while this page is open."); -+ if (scope === "actions" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Actions: Read."); -+ if (scope === "checks" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Checks: Read."); -+ if ((scope === "comments" || scope === "issues") && (kind === "not-found" || kind === "forbidden")) return t("Give the token Issues: Read."); -+ if (scope === "pulls" && (kind === "not-found" || kind === "forbidden")) return t("Give the token Pull requests: Read."); -+ if (scope === "repo" || kind === "not-found" || kind === "forbidden" || kind === "auth") { -+ return t("Check the repository name, and that the token can see {repo}.", { repo: target }); -+ } -+ if (kind === "server" || kind === "network") return t("GitHub is not answering this instance right now; retry in a moment."); -+ return t("The next attempt happens while this page is open; the reason above names what failed."); -+} -+function missingText(entry) { -+ const status = entry?.status === null || entry?.status === void 0 ? "" : ` · ${entry.status}`; -+ const part = entry?.part === "ci" ? t("CI data") : entry?.part === "budget" ? t("The rest of the board") : entry?.part ?? t("Part of the board"); -+ const where = entry?.repo ? `${entry.repo} · ` : ""; -+ return t("{part} was not fetched this time: {message}", { part: `${where}${part}`, message: `${String(entry?.message ?? "")}${status}` }); -+} -+var clockOf = (iso) => { -+ const at = Date.parse(iso ?? ""); -+ if (!Number.isFinite(at)) return "—"; -+ return new Date(at).toLocaleTimeString(void 0, { hour: "2-digit", minute: "2-digit" }); - }; --var TOOL_PHRASES = [ -- [/web_?search|search_?web/i, "Searching online"], -- [/fetch|browse|browser|web/i, "Reading a page"], -- [/shell|bash|pwsh|powershell|exec|command|terminal/i, "Running shell commands"], -- [/^(read|view|cat)\b|read_/i, "Opening a file"], -- [/grep|glob|list_dir|^ls$|find|search/i, "Looking through files"], -- [/write|edit|patch|apply|replace/i, "Writing changes"], -- [/image|photo|draw/i, "Working on an image"] --]; --var GROUP_PHRASES = { create_group: "Creating a group", update_group: "Updating the group", delete_group: "Deleting the group", post_to_group: "Posting in the group" }; --function toolActivity(name, arg, findBot, self) { -- switch (name) { -- case "ask_user": -- return null; -- case "request_secret": -- return null; -- case "list_secrets": -- return { label: t("Checking the keys"), state: "searching" }; -- case "ask_user_question": -- return { label: t("Waiting for your answer"), state: "alert" }; -- case "group_relay": -- return { relay: true, state: "orbit" }; -- case "message_bot": { -- const target = findBot(arg("to")); -- return { label: target ? t("Asking {name}", { name: target.name }) : t("Asking another Bot"), state: "sending" }; -- } -- case "stop_bot": { -- const target = findBot(arg("bot")); -- return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; -+var timeOf = (iso) => { -+ const at = Date.parse(iso ?? ""); -+ if (!Number.isFinite(at)) return "—"; -+ return new Date(at).toLocaleString(void 0, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); -+}; -+function metricRows(snapshot) { -+ const today = snapshot?.today ?? {}; -+ const week = snapshot?.week ?? {}; -+ const ci = today.ci ?? null; -+ const share = (entry) => { -+ if (!entry || !Number.isFinite(entry.failed)) return "—"; -+ const denominator = Number.isFinite(entry.counted) && entry.counted > 0 ? entry.counted : Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null; -+ return denominator === null ? "—" : `${Math.round(entry.failed / denominator * 100)}%`; -+ }; -+ const count = (value) => value === null || value === void 0 ? "—" : String(value); -+ const rows = [ -+ { label: t("New issues"), key: "newIssues" }, -+ { label: t("Closed issues"), key: "closedIssues" }, -+ { label: t("New pull requests"), key: "newPullRequests" }, -+ { label: t("Merged pull requests"), key: "mergedPullRequests" }, -+ { key: "ci", label: t("CI red share"), today: share(today.ci), week: share(week.ci) }, -+ { label: t("Median issue close"), key: "medianIssueCloseMs", format: duration }, -+ { label: t("Median PR merge"), key: "medianPullMergeMs", format: duration } -+ ].map((row) => { -+ if (row.today !== void 0) return { key: row.key, label: row.label, today: row.today, week: row.week }; -+ const format = row.format ?? count; -+ return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) }; -+ }); -+ return { -+ dayStart: snapshot?.dayStart ?? null, -+ rows, -+ // `null` is "not read this time", which the header prints as unknown; a real 0 stays a 0. -+ ci: ci === null ? null : { -+ failed: ci.failed ?? 0, -+ total: ci.total ?? 0, -+ counted: ci.counted ?? 0, -+ caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total, -+ superseded: ci.superseded ?? 0, -+ unknownDays: ci.unknownDays ?? [] -+ }, -+ open: snapshot?.open ?? {} -+ }; -+} -+function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { -+ let stopped = false; -+ let timer = null; -+ let state = "idle"; -+ let last = null; -+ const schedule = () => { -+ if (stopped) return; -+ if (timer !== null) clearTimer(timer); -+ timer = setTimer(() => { -+ timer = null; -+ void run(); -+ }, pollDelay(state, last, now())); -+ }; -+ const run = async (force = false) => { -+ try { -+ const next = await load(force); -+ if (stopped) return; -+ if (next && typeof next === "object") { -+ last = next; -+ state = typeof next.state === "string" ? next.state : "fresh"; -+ onView(next); -+ } else { -+ state = "failed"; -+ last = null; -+ onView({ state: "failed", error: { message: "The host did not answer", kind: "network", endpoint: null, status: null, at: null } }); -+ } -+ schedule(); -+ } catch (error) { -+ if (stopped) return; -+ state = "failed"; -+ last = null; -+ onView({ state: "failed", error: { message: String(error?.message ?? error), kind: "network", endpoint: null, status: null, at: null } }); -+ schedule(); - } -- case "create_bot": -- return { label: t("Creating {name}", { name: arg("name") ?? t("a Bot") }), state: "sending" }; -- case "update_bot": { -- const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); -- return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; -+ }; -+ return { -+ start: () => { -+ void run(); -+ }, -+ // Retry: the screen changes now, then the force read runs; the old view is kept so its columns -+ // do not flash away while the round starts. -+ retry: (from = null) => { -+ if (stopped) return; -+ state = "loading"; -+ last = null; -+ onView({ ...from && typeof from === "object" ? from : {}, state: "loading", since: new Date(now()).toISOString(), elapsedMs: 0, refreshing: false }); -+ if (timer !== null) { -+ clearTimer(timer); -+ timer = null; -+ } -+ void run(true); -+ }, -+ stop: () => { -+ stopped = true; -+ if (timer !== null) clearTimer(timer); -+ timer = null; - } -- case "list_bots": -- return { label: t("Checking the team"), state: "searching" }; -- case "read_group_chat": -- return { label: t("Reading {name}", { name: arg("group") ?? t("a group chat") }), state: "searching" }; -- case "read_own_chat": -- return { label: t("Looking back through the chat"), state: "searching" }; -- case "remember": -- return { label: t("Saving to memory"), state: "working" }; -- case "forget": -- return { label: t("Updating memory"), state: "working" }; -- case "recall": -- return { label: t("Checking memory"), state: "searching" }; -- default: -- if (GROUP_PHRASES[name]) return { label: t(GROUP_PHRASES[name]), state: "sending" }; -- const phrase = TOOL_PHRASES.find(([pattern]) => pattern.test(name))?.[1]; -- return { label: phrase ? t(phrase) : null, state: toolState(name) }; -+ }; -+} -+function repoSourceText(source) { -+ if (source === "config") return t("from config"); -+ if (source === "remote") return t("from remote"); -+ if (source === "package") return t("from the package (default)"); -+ return null; -+} -+function useDeliveryBoard(actions) { -+ const [view, setView] = (0, import_react16.useState)({ state: "idle" }); -+ const [retry, setRetry] = (0, import_react16.useState)(() => () => { -+ }); -+ (0, import_react16.useEffect)(() => { -+ let current = { state: "idle" }; -+ const poller = createPoller({ -+ load: (force) => typeof actions?.deliveryBoard === "function" ? actions.deliveryBoard(force ? { force: true } : {}) : Promise.resolve(null), -+ onView: (next) => { -+ current = next; -+ setView(next); -+ } -+ }); -+ setRetry(() => () => poller.retry(current)); -+ poller.start(); -+ return () => poller.stop(); -+ }, [actions]); -+ return [view, retry]; -+} -+function probeText(report) { -+ if (!report) return ""; -+ const repos = Array.isArray(report.repos) ? report.repos : []; -+ const head = report.repo ?? `${repos.length} repositories`; -+ const lines = [`${head}${report.repoSource ? ` · ${report.repoSource}` : ""} · ${timeOf(report.at)}`]; -+ for (const row of report.rows ?? []) { -+ const status = row.status === null || row.status === void 0 ? "—" : String(row.status); -+ const where = report.repo === null && row.repo ? `${row.repo} ` : ""; -+ lines.push(`${where}${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); - } -+ if (report.error) lines.push(String(report.error)); -+ return lines.join("\n"); - } --function workPhrase(tool) { -- if (!tool) return t("Thinking about it"); -- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); -+function Diagnose({ onProbe }) { -+ const [report, setReport] = (0, import_react16.useState)(null); -+ const [busy, setBusy] = (0, import_react16.useState)(false); -+ const run = () => { -+ if (busy) return; -+ setBusy(true); -+ Promise.resolve(onProbe?.()).then((next) => { -+ setReport(next ?? { rows: [], error: "no answer" }); -+ }).catch((error) => setReport({ rows: [], error: String(error?.message ?? error) })).finally(() => setBusy(false)); -+ }; -+ return (0, import_react16.createElement)( -+ "span", -+ { className: "bt-del-diag" }, -+ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: run, "aria-label": t("Diagnose") }, busy ? t("Checking…") : t("Diagnose")), -+ report ? (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-diag-box" }, -+ (0, import_react16.createElement)("pre", { className: "bt-del-diag-text", "data-probe": "report" }, probeText(report)), -+ (0, import_react16.createElement)("button", { -+ type: "button", -+ className: "bt-soft bt-soft-sm", -+ onClick: () => { -+ try { -+ globalThis.navigator?.clipboard?.writeText?.(probeText(report)); -+ } catch { -+ } -+ } -+ }, t("Copy")), -+ (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setReport(null) }, t("Close")) -+ ) : null -+ ); - } --function useLiveNote(actions, sessionId, id, entry) { -- const key = entry ? JSON.stringify(entry) : ""; -- (0, import_react6.useEffect)(() => { -- if (!key) return void 0; -- actions.noteLive(sessionId, id, JSON.parse(key)); -- return () => actions.noteLive(sessionId, id, null); -- }, [sessionId, id, key]); -+function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { -+ const payload = view ?? snapshot; -+ if (!payload) return null; -+ const metricsPayload = payload.metrics ?? payload; -+ const { rows, ci, open } = metricRows(metricsPayload); -+ const error = payload.error ?? null; -+ const state = payload.state ?? "fresh"; -+ const repoName = payload.repo ?? repo ?? null; -+ const repoSource = payload.repoSource ?? null; -+ const sourceText = repoSourceText(repoSource); -+ const repoCount = Array.isArray(payload.repos) ? payload.repos.length : 0; -+ const shared2 = repoCount > 1; -+ const openCount = (value) => value === null || value === void 0 ? "—" : String(value); -+ const partial = state !== "loading" && todayPartial(metricsPayload.daily); -+ return (0, import_react16.createElement)( -+ "header", -+ { className: "bt-del-head" }, -+ // Whose numbers these are, and where the name came from (#1250): a package default that looks -+ // like a fact is what showed one instance another repository's board. -+ (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-repo", "data-repo": shared2 ? void 0 : repoName ?? void 0, "data-repo-source": shared2 ? void 0 : repoSource ?? void 0, "data-repos": shared2 ? String(repoCount) : void 0 }, -+ shared2 ? t("{count} repositories", { count: repoCount }) : repoName === null ? t("no repository — set delivery.repo") : repoName, -+ shared2 || sourceText === null ? "" : ` · ${sourceText}` -+ ), -+ (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-metrics" }, -+ (0, import_react16.createElement)(DeliveryMetrics, { rows, daily: metricsPayload.daily }), -+ // An unreadable CI number is "unknown", never "0 of 0" (#1211). -+ (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-ci", "data-ci": ci === null ? "unknown" : "read" }, -+ ci === null ? t("CI not available this time") : t("CI failed {failed} of {total}", { failed: ci.failed, total: ci.total }), -+ ci !== null && ci.caveat ? ` · ${t("counted {counted} of {total}", { counted: ci.counted, total: ci.total })}` : "", -+ ci !== null && ci.superseded ? ` · ${t("{count} superseded", { count: ci.superseded })}` : "", -+ ci !== null && ci.unknownDays?.length ? ` · ${t("{count} day(s) not read", { count: ci.unknownDays.length })}` : "" -+ ), -+ (0, import_react16.createElement)("p", { className: "bt-del-open" }, t("Open: {issues} issues ({needs} need a human), {prs} pull requests", { -+ issues: openCount(open.issues), -+ needs: openCount(open.needsHuman), -+ prs: openCount(open.pullRequests) -+ }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== void 0 ? ` · ${t("median open age {age}", { age: duration(open.medianIssueAgeMs) })}` : "") -+ ), -+ (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-asof" }, -+ state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), -+ partial ? ` · ${t("today is partial")}` : "", -+ payload.refreshing ? ` · ${t("refreshing…")}` : "", -+ error ? ` · ${hintFor(error, { repo })}` : "", -+ onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, -+ onProbe ? (0, import_react16.createElement)(Diagnose, { onProbe }) : null -+ ) -+ ); - } --function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi, useWork }) { -- const [live, setLive] = (0, import_react6.useState)({ replies: [], speaking: null }); -- (0, import_react6.useEffect)(() => { -- if (done) return void 0; -- let stop = false; -- let timer; -- let seen = ""; -- const tick = async () => { -- const result = await actions.roomProgress(sessionId); -- if (stop) return; -- const entry = (result ?? []).find((item) => item.callId === callId) ?? (result ?? [])[0]; -- const key = entry ? JSON.stringify([entry.replies, entry.speaking]) : ""; -- if (entry && key !== seen) { -- seen = key; -- setLive({ replies: entry.replies ?? [], speaking: entry.speaking ?? null }); -+var BOARD_FILTERS = ["all", "review", "done-today"]; -+function BoardFilters({ filter, onFilter }) { -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-filters", role: "group", "aria-label": t("Filter") }, -+ BOARD_FILTERS.map((id) => (0, import_react16.createElement)("button", { -+ key: id, -+ type: "button", -+ className: "bt-soft bt-soft-sm", -+ "data-filter": id, -+ "aria-pressed": filter === id ? "true" : "false", -+ onClick: () => onFilter(id) -+ }, id === "all" ? t("All") : id === "review" ? t("Review") : t("Done today"))) -+ ); -+} -+var stageOf = (card, column) => card?.stage ?? column; -+var matchesFilter = (card, column, filter) => filter === "all" || stageOf(card, column) === filter; -+function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = "all", roster = null, dragging = null, onMove = null, onDrag = null }) { -+ const columns = view?.columns ?? {}; -+ const drawn = placed === null || placed === void 0 ? null : new Set(placed); -+ const deliveryCards = (id) => (columns[id] ?? []).filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ""}#${card.kind}${card.number}`)).filter((card) => matchesFilter(card, id, filter)); -+ const tasksByColumn = { "not-started": [], "in-progress": [], "done": [] }; -+ for (const task of filter === "all" ? tasks ?? [] : []) { -+ const col = taskColumnOf(task); -+ if (tasksByColumn[col]) tasksByColumn[col].push(task); -+ } -+ const allCards = (id) => { -+ const delivery = deliveryCards(id); -+ const taskList = tasksByColumn[id] ?? []; -+ return [...taskList, ...delivery]; -+ }; -+ const taskIds = new Set((tasks ?? []).map((task) => task?.id)); -+ const isTask = (item) => taskIds.has(item?.id); -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-columns" }, -+ stagesOf(view).map((column) => (0, import_react16.createElement)( -+ "section", -+ { -+ key: column.id, -+ className: "bt-del-column", -+ "data-column": column.id, -+ ...dropProps(column.id, { dragging, onMove, onDrag }) -+ }, -+ (0, import_react16.createElement)( -+ "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(allCards(column.id).length)) -+ ), -+ allCards(column.id).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : allCards(column.id).map((item) => isTask(item) ? (0, import_react16.createElement)(BoardCard2, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) : (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? item.repo ?? ""}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null })) -+ )) -+ ); -+} -+function DeliveryWorkflowSections({ sections, repo = null, filter = "all" }) { -+ 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) => { -+ const cards = column.cards.filter((card) => matchesFilter(card, column.id, filter)); -+ return (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(cards.length)) -+ ), -+ cards.length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : 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 multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) { -+ const successful = (entries2 ?? []).filter((entry) => entry?.columns !== void 0); -+ const failed = (entries2 ?? []).filter((entry) => entry?.columns === void 0); -+ const merged = { columns: { "not-started": [], "in-progress": [], "done": [] } }; -+ for (const entry of successful) { -+ for (const [column, cards] of Object.entries(entry.columns)) { -+ if (merged.columns[column]) { -+ merged.columns[column].push(...cards.map((card) => ({ ...card, repo: card.repo ?? entry.repo }))); - } -- timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); -- }; -- void tick(); -- return () => { -- stop = true; -- clearTimeout(timer); -- }; -- }, [done, sessionId, callId]); -- let replies = live.replies; -- if (done) { -- try { -- replies = JSON.parse(contentText(content)).replies ?? []; -- } catch { -- replies = []; - } - } -- const pending = done || !live.speaking ? [] : [live.speaking]; -- const speakerTool = useWork?.((work) => work.bots?.[live.speaking]?.tool) ?? void 0; -- if (replies.length === 0 && pending.length === 0) return null; -- const room2 = roster.roomsById[sessionId]; -- return (0, import_react6.createElement)( -+ return (0, import_react16.createElement)( - "div", -- { className: "bt-group-replies" }, -- replies.map((reply, index) => { -- const bot = roster.byId[reply.botId] ?? { id: reply.botId, name: reply.name, color: reply.color ?? "gray" }; -- if (reply.kind === "user") { -- return (0, import_react6.createElement)("div", { key: `user-${index}`, className: "bt-urow" }, (0, import_react6.createElement)( -- "div", -- { className: "bt-ububble" }, -- (0, import_react6.createElement)(import_dsh_client_ui_primitives.MarkdownText, { text: reply.text, labels: mdLabels(), variant: "compact" }) -- )); -- } -- if (reply.kind === "failed") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event bt-event-error", role: "status", title: [reply.code, reply.status, reply.text].filter(Boolean).join(" · ") }, -- t("{name} could not answer: {reason}", { name: bot.name, reason: failureLabel(reply.code, reply.text) }) -- ); -- } -- if (reply.kind === "lookup") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- reply.text ? t("{name} looked up its own chat for “{text}”", { name: bot.name, text: reply.text }) : t("{name} looked up its own chat", { name: bot.name }) -- ); -- } -- if (reply.kind === "missed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); -- } -- if (reply.kind === "resumed") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); -- } -- if (reply.kind === "cut") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart again and stopped", { name: bot.name })); -- } -- if (reply.kind === "approval" || reply.kind === "working") { -- return (0, import_react6.createElement)( -- "div", -- { key: `${reply.botId}-${index}`, className: "bt-event" }, -- (0, import_react6.createElement)( -- "button", -- { type: "button", className: "bt-event-whole", onClick: () => actions.showDetails(sessionId) }, -- (0, import_react6.createElement)(BotMark, { bot, size: 16 }), -- (0, import_react6.createElement)("span", { className: "bt-event-name" }, t(reply.kind === "working" ? "{name} started on it" : "{name} paused for your approval", { name: bot.name })) -- ) -- ); -- } -- if (reply.kind === "stopped") { -- return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); -- } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); -- }), -- pending.map((id) => { -- const bot = roster.byId[id]; -- if (!bot) return null; -- return (0, import_react6.createElement)( -+ { className: "bt-del-multi" }, -+ // Successful repos: one shared column set with all cards and tasks once -+ successful.length > 0 ? (0, import_react16.createElement)(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) : null, -+ // Failed repos: show error blocks -+ failed.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { -+ key: entry.repo ?? "unknown", -+ entry, -+ tasks: [], -+ // Don't pass tasks to failed repo blocks -+ roster, -+ dragging, -+ onMove, -+ onDrag, -+ filter -+ })) -+ ); -+} -+function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = "all" }) { -+ const state = entry?.state ?? "fresh"; -+ const source = repoSourceText(entry?.repoSource); -+ return (0, import_react16.createElement)( -+ "section", -+ { className: "bt-del-repo-block", "data-repo": entry?.repo ?? void 0, "data-state": state }, -+ (0, import_react16.createElement)( -+ "h3", -+ { className: "bt-del-repo-head" }, -+ entry?.repo ?? t("no repository — set delivery.repo"), -+ source === null ? "" : ` · ${source}` -+ ), -+ entry?.columns === void 0 ? (0, import_react16.createElement)( -+ "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 })}` : "" -+ ) : (0, import_react16.createElement)(DeliveryColumns, { view: entry, repo: entry.repo, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag }) -+ ); -+} -+function DeliveryBoard({ view, snapshot, message = "", repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { -+ const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); -+ const state = payload.state ?? "fresh"; -+ const retryAt = Number(payload.retryAt); -+ const ticking = (state === "failed" || state === "stale") && Number.isFinite(retryAt) && retryAt > 0; -+ const [now, setNow] = (0, import_react16.useState)(() => Date.now()); -+ (0, import_react16.useEffect)(() => { -+ if (!ticking) return void 0; -+ setNow(Date.now()); -+ const timer = setInterval(() => setNow(Date.now()), 1e3); -+ return () => clearInterval(timer); -+ }, [ticking, retryAt]); -+ const countdown = retryCountdown(payload.retryAt, now); -+ const [filter, setFilter] = (0, import_react16.useState)("all"); -+ const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; -+ const hasTasks = Array.isArray(tasks) && tasks.length > 0; -+ if (state === "idle") { -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "board", "data-state": state }, -+ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), -+ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, -+ message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("No snapshot yet.")) -+ ); -+ } -+ if (state === "loading") { -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "board", "data-state": state }, -+ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), -+ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, -+ (0, import_react16.createElement)("p", { className: "bt-del-loading", role: "status" }, loadingText(payload.elapsedMs)), -+ (0, import_react16.createElement)("p", { className: "bt-del-hint" }, t("The first round reads the repository once; the columns appear as soon as it lands.")) -+ ); -+ } -+ const hasBoard = Boolean(payload.columns) || entries2 !== null && entries2.some((entry) => entry.columns !== void 0); -+ if (state === "failed" || !hasBoard) { -+ const error = payload.error ?? null; -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "board", "data-state": state }, -+ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), -+ hasTasks ? (0, import_react16.createElement)(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, -+ (0, import_react16.createElement)( - "div", -- { key: `pending-${id}`, className: "bt-activity" }, -- (0, import_react6.createElement)(BotMark, { bot, size: 22, state: speakerTool ? toolState(speakerTool) : "thinking" }), -- (0, import_react6.createElement)(Shimmer, null, speakerTool ? `${bot.name} · ${workPhrase(speakerTool)}…` : t("{name} is typing…", { name: bot.name })) -- ); -- }) -+ { className: "bt-board-error", role: "alert" }, -+ (0, import_react16.createElement)("p", null, String(error?.message ?? message ?? t("The delivery board is not ready yet."))), -+ error ? (0, import_react16.createElement)("p", { className: "bt-del-hint" }, hintFor(error, { repo })) : null, -+ (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-hint", "data-retry-at": payload.retryAt ?? void 0 }, -+ countdown === null ? t("While this page is open it retries on its own; press Retry to try again now.") : t("Retrying in {seconds} s · press Retry to try again now", { seconds: countdown }) -+ ), -+ onRetry ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null -+ ) -+ ); -+ } -+ const missing = payload.missing ?? []; -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "board", "data-state": state }, -+ (0, import_react16.createElement)(BoardFilters, { filter, onFilter: setFilter }), -+ message ? (0, import_react16.createElement)("p", { className: "bt-board-error", role: "alert" }, message) : null, -+ state === "stale" && payload.error ? (0, import_react16.createElement)( -+ "p", -+ { className: "bt-del-stale", role: "status" }, -+ staleText(payload.asOf, payload.error), -+ ` · ${hintFor(payload.error, { repo })}`, -+ countdown === null ? "" : ` · ${t("Retrying in {seconds} s · press Retry to try again now", { seconds: countdown })}` -+ ) : 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 }), -+ // 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, filter }) : null, -+ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) : multiRepoView(entries2, { tasks, filter, roster, dragging, onMove, onDrag }) -+ ); -+} -+function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { -+ const [view, retry] = useDeliveryBoard(actions); -+ return (0, import_react16.createElement)(DeliveryBoard, { -+ view, -+ repo: view?.repo ?? null, -+ tasks, -+ roster, -+ dragging, -+ onMove, -+ onDrag, -+ 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, "data-stage": card?.stage ?? 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-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} -+.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} -+.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 }) - ); - } --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) { -- const calls = []; -- const seen = /* @__PURE__ */ new Set(); -- const visit = (call2) => { -- const id = call2.callId ?? call2; -- if (seen.has(id)) return; -- seen.add(id); -- calls.push(call2); -- for (const child of call2.subCalls ?? []) visit(child); -- }; ++ title: String(task.title ?? "") + }; - visit(root); -- return calls; -+function shortId(id) { -+ const hex = String(id ?? "").replace(/^task-/, ""); -+ return `#${hex.slice(-6)}`; +- return calls; } -function useShownCards(actions, sessionId, callIds) { - const key = JSON.stringify(callIds); @@ -11119,10 +9774,68 @@ diff --git a/client.js b/client.js - actions.noteCards(sessionId, ids, 1); - return () => actions.noteCards(sessionId, ids, -1); - }, [sessionId, key]); -+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)); -+ return [...tasks ?? [], ...newest.slice(0, limit)]; ++ ++// src/client/board-card.js ++var import_react14 = require("react"); ++var TASK_SKIN = { ++ card: "bt-board-card", ++ top: "bt-board-card-top", ++ id: "bt-board-id", ++ badge: "bt-board-badge", ++ title: "bt-board-title", ++ labels: "bt-board-labels", ++ label: "bt-board-label", ++ owner: "bt-board-who", ++ note: "bt-board-note" ++}; ++function BoardCard({ ++ card, ++ skin = TASK_SKIN, ++ attrs = {}, ++ idText = "", ++ topExtra = null, ++ badge = "", ++ meta = null, ++ note = "", ++ footer = null, ++ actions = null, ++ emptyTitle = "", ++ draggable = false, ++ dragValue = "", ++ onDragStart = null, ++ onDragEnd = null ++}) { ++ const labels = Array.isArray(card?.labels) ? card.labels : []; ++ return (0, import_react14.createElement)( ++ "article", ++ { ++ ...attrs, ++ className: skin.card, ++ "data-source": card?.source, ++ "data-key": card?.key ?? void 0, ++ draggable: draggable || void 0, ++ onDragStart: draggable ? (event) => { ++ event.dataTransfer?.setData("text/plain", dragValue); ++ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move"; ++ onDragStart?.(); ++ } : void 0, ++ onDragEnd: draggable ? () => onDragEnd?.() : void 0 ++ }, ++ (0, import_react14.createElement)( ++ "span", ++ { className: skin.top }, ++ (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 ++ ), ++ (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, ++ note ? (0, import_react14.createElement)("span", { className: skin.note }, note) : null, ++ meta, ++ footer, ++ actions ++ ); } -function renderCall(call2, { sessionId, roster, actions, useUi, useWork, findBot }) { - const tool = toolName(call2); @@ -11193,6 +9906,28 @@ 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; ++ ++// src/client/kanban.js ++var import_react15 = require("react"); ++var POLL_MS3 = 15e3; ++function BoardIcon() { ++ return (0, import_react15.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_react15.createElement)("rect", { x: 2.5, y: 3, width: 4, height: 14, rx: 1 }), ++ (0, import_react15.createElement)("rect", { x: 8, y: 3, width: 4, height: 9, rx: 1 }), ++ (0, import_react15.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)); ++ return [...tasks ?? [], ...newest.slice(0, limit)]; ++} +function boardColumns(tasks) { + const columns = { "not-started": [], "in-progress": [], done: [] }; + for (const task of tasks ?? []) { @@ -11359,11 +10094,11 @@ diff --git a/client.js b/client.js +var upsert = (list, task) => list.some((item) => item.id === task.id) ? list.map((item) => item.id === task.id ? task : item) : [...list, task]; +var without = (list, id) => list.filter((item) => item.id !== id); +function useBoardTasks(actions, scope) { -+ const [own, setOwn] = (0, import_react17.useState)([]); -+ const [groupTasks, setGroupTasks] = (0, import_react17.useState)([]); -+ const [archive, setArchive] = (0, import_react17.useState)([]); ++ const [own, setOwn] = (0, import_react15.useState)([]); ++ const [groupTasks, setGroupTasks] = (0, import_react15.useState)([]); ++ const [archive, setArchive] = (0, import_react15.useState)([]); + const key = scope?.kind === "group" ? `group:${scope.roomId}` : "own"; -+ (0, import_react17.useEffect)(() => { ++ (0, import_react15.useEffect)(() => { + let stopped = false; + const load = () => { + const query = scope?.kind === "group" ? { roomId: scope.roomId } : {}; @@ -11385,7 +10120,7 @@ diff --git a/client.js b/client.js - observer.observe(document.querySelector("[data-conversation-scroll]") ?? document.body, { childList: true, subtree: true }); - find(); + load(); -+ const timer = setInterval(load, POLL_MS4); ++ const timer = setInterval(load, POLL_MS3); return () => { - cancelAnimationFrame(frame); - observer.disconnect(); @@ -11440,19 +10175,8 @@ diff --git a/client.js b/client.js - const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); - item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; - item.addEventListener("animationend", settle); -- } -- }; -- const observer = new MutationObserver(() => { -- if (!frame) frame = requestAnimationFrame(scan); -- }); -- scan(); -- return () => { -- cancelAnimationFrame(frame); -- observer.disconnect(); -- }; -- }, []); +function RailButton({ row, selected, meta, onSelect }) { -+ return (0, import_react17.createElement)( ++ return (0, import_react15.createElement)( + "button", + { + type: "button", @@ -11464,84 +10188,10 @@ diff --git a/client.js b/client.js + style: row.depth ? { paddingLeft: `${10 + row.depth * 14}px` } : void 0, + onClick: () => onSelect(row.kind === "group" ? { kind: "group", roomId: row.id } : { kind: "bot", botId: row.id }) + }, -+ (0, import_react17.createElement)("span", { className: "bt-board-rail-name" }, row.name), -+ meta ? (0, import_react17.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null ++ (0, import_react15.createElement)("span", { className: "bt-board-rail-name" }, row.name), ++ meta ? (0, import_react15.createElement)("span", { className: "bt-board-rail-meta" }, meta) : null + ); - } --function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { -- const roster = useRoster((value) => value); -- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); -- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); -- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); -- const bot = roster.byId[sessionId]; -- const room2 = roster.roomsById[sessionId]; -- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); -- const streaming = entries2.some((entry) => entry.text); -- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); -- const relaying = tools.some((entry) => entry.relay); -- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); -- const tool = tools.at(-1); -- const inputRef = (0, import_react7.useRef)(0); -- const inputAt = Math.max(inputRef.current, pendingInput); -- inputRef.current = inputAt; -- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; -- const [shown, setShown] = (0, import_react7.useState)(null); -- const wantKey = want?.key ?? null; -- const markState = (0, import_react7.useRef)("thinking"); -- if (want) markState.current = want.state; -- (0, import_react7.useEffect)(() => { -- if (wantKey === null || shown?.key === wantKey) return void 0; -- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); -- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; -- if (wait <= 0) { -- make(); -- return void 0; -- } -- const timer = setTimeout(make, wait); -- return () => clearTimeout(timer); -- }, [wantKey, shown]); -- const [phase, setPhase] = (0, import_react7.useState)(null); -- const endedAt = (0, import_react7.useRef)(0); -- const shift = (0, import_react7.useRef)(null); -- const leaving = (0, import_react7.useRef)(false); -- leaving.current = phase !== null && !visible; -- (0, import_react7.useEffect)(() => { -- if (visible) { -- setPhase("in"); -- return void 0; -- } -- setPhase((current) => current === "in" ? "out" : current); -- const timer = setTimeout(() => { -- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; -- setPhase((current) => current === "out" ? null : current); -- }, 140); -- return () => clearTimeout(timer); -- }, [visible]); -- (0, import_react7.useLayoutEffect)(() => { -- if (phase !== null) return; -- endedAt.current = Date.now(); -- setShown(null); -- const before = shift.current; -- shift.current = null; -- const scroller = document.querySelector("[data-conversation-scroll]"); -- if (before === null || !scroller || reducedMotion()) return; -- const delta = before - scroller.scrollTop; -- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); -- }, [phase]); -- (0, import_react7.useEffect)(() => { -- if (!running) actions.clearLive(sessionId); -- }, [running, sessionId]); -- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); -- const [now, setNow] = (0, import_react7.useState)(Date.now); -- (0, import_react7.useEffect)(() => { -- if (phase !== "in") return void 0; -- const timer = setInterval(() => setNow(Date.now()), 1e4); -- return () => clearInterval(timer); -- }, [phase]); -- const host = useRunningHost(running); -- 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)( ++} +function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) { + const column = columnOf(task); + const priority = priorityOf(task); @@ -11549,7 +10199,7 @@ diff --git a/client.js b/client.js + const who = botName(roster, task); + const question = task.waitingFor === "input" ? task.inputRequest?.question : ""; + const overall = task.kind === "group"; -+ return (0, import_react17.createElement)(BoardCard, { ++ return (0, import_react15.createElement)(BoardCard, { + card: taskCard(task), + skin: TASK_SKIN, + attrs: { @@ -11561,33 +10211,33 @@ diff --git a/client.js b/client.js + idText: shortId(task.id), + emptyTitle: t("Untitled task"), + topExtra: [ -+ (0, import_react17.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), -+ (0, import_react17.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) ++ (0, import_react15.createElement)("span", { key: "priority", className: "bt-board-priority", "data-priority": priority }, priorityLabel(priority)), ++ (0, import_react15.createElement)("span", { key: "status", className: "bt-board-status" }, statusLabel(task.status)) + ], + note: task.note ?? "", + meta: [ -+ overall ? (0, import_react17.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, -+ partOf2 ? (0, import_react17.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, -+ question ? (0, import_react17.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null ++ overall ? (0, import_react15.createElement)("span", { key: "kind", className: "bt-board-kind" }, t("Group task")) : null, ++ partOf2 ? (0, import_react15.createElement)("span", { key: "part", className: "bt-board-part" }, t("Part of {title}", { title: partOf2 })) : null, ++ question ? (0, import_react15.createElement)("span", { key: "wait", className: "bt-board-wait" }, question) : null + ], -+ footer: (0, import_react17.createElement)( ++ footer: (0, import_react15.createElement)( + "span", + { className: "bt-board-foot" }, -+ who ? (0, import_react17.createElement)("span", { className: "bt-board-who" }, who) : null, -+ (0, import_react17.createElement)( ++ who ? (0, import_react15.createElement)("span", { className: "bt-board-who" }, who) : null, ++ (0, import_react15.createElement)( + "span", + { className: "bt-board-progress" }, -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "span", + { className: "bt-board-progress-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": progress, "aria-label": t("Progress") }, -+ (0, import_react17.createElement)("span", { style: { width: `${progress}%` } }) ++ (0, import_react15.createElement)("span", { style: { width: `${progress}%` } }) + ), -+ (0, import_react17.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) ++ (0, import_react15.createElement)("span", { className: "bt-board-percent" }, t("{percent}%", { percent: progress })) + ) + ), + actions: [ -+ movable && column === "not-started" ? (0, import_react17.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, -+ movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react17.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null ++ movable && column === "not-started" ? (0, import_react15.createElement)("button", { key: "start", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "in-progress") }, t("Start")) : null, ++ movable && column === "in-progress" && task.status !== "blocked" ? (0, import_react15.createElement)("button", { key: "complete", type: "button", className: "bt-soft bt-soft-sm", onClick: () => onMove?.(task.id, "done") }, t("Complete")) : null + ], + draggable: movable, + dragValue: task.id, @@ -11602,7 +10252,7 @@ diff --git a/client.js b/client.js + const tree = lanes ? columnTree(tasks) : null; + const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; + const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; -+ return (0, import_react17.createElement)( ++ return (0, import_react15.createElement)( + "section", + { + className: "bt-board-column", @@ -11617,33 +10267,43 @@ diff --git a/client.js b/client.js + const id = event.dataTransfer?.getData("text/plain"); + if (id) onMove?.(id, column); + onDrag?.(null); -+ } + } +- }; +- const observer = new MutationObserver(() => { +- if (!frame) frame = requestAnimationFrame(scan); +- }); +- scan(); +- return () => { +- cancelAnimationFrame(frame); +- observer.disconnect(); +- }; +- }, []); + }, -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "h3", + { className: "bt-board-column-head" }, -+ (0, import_react17.createElement)("span", null, columnTitle(column)), -+ (0, import_react17.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) ++ (0, import_react15.createElement)("span", null, columnTitle(column)), ++ (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) + ), -+ dragging ? (0, import_react17.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, -+ tasks.length === 0 && !dragging ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, -+ tree ? tree.nests.map((nest) => (0, import_react17.createElement)( ++ dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, ++ tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, ++ tree ? tree.nests.map((nest) => (0, import_react15.createElement)( + "div", + { key: nest.task.id, className: "bt-board-nest" }, -+ (0, import_react17.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), -+ nest.children.map((child) => (0, import_react17.createElement)( ++ (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), ++ nest.children.map((child) => (0, import_react15.createElement)( + "div", + { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, -+ (0, import_react17.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) ++ (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) + )) + )) : null, -+ linked.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), -+ lanes ? lanesOf(plain, roster).map((lane) => (0, import_react17.createElement)( ++ linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), ++ lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( + "div", + { key: lane.id, className: "bt-board-lane" }, -+ (0, import_react17.createElement)("span", { className: "bt-board-lane-name" }, lane.name), -+ lane.tasks.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) -+ )) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) ++ (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), ++ lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) ++ )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + ); +} +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { @@ -11651,26 +10311,16 @@ diff --git a/client.js b/client.js + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; + const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; -+ return (0, import_react17.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)( -- "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 ++ return (0, import_react15.createElement)( ++ "div", + { className: "bt-board-body" }, -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "aside", + { className: "bt-board-rail", "aria-label": t("Task board") }, -+ (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), -+ rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { ++ (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), ++ rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + const count = counts2?.[row.id] ?? { done: 0, total: 0 }; -+ return (0, import_react17.createElement)(RailButton, { ++ return (0, import_react15.createElement)(RailButton, { + key: row.id, + row, + selected: row.id === selectedBot, @@ -11678,27 +10328,27 @@ diff --git a/client.js b/client.js + meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" + }); + }), -+ rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, -+ rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) ++ rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, ++ rail.groups.map((row) => (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) + ), -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "div", + { className: "bt-board-main" }, -+ error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, -+ lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, -+ scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( ++ error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, ++ lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, ++ scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( + "form", + { className: "bt-board-add", onSubmit: (event) => { + event.preventDefault(); + onCreate(draft); + } }, -+ (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), -+ (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) ++ (0, import_react15.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), ++ (0, import_react15.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) + ) : null, -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "div", + { className: "bt-board-columns" }, -+ ["not-started", "in-progress", "done"].map((column) => (0, import_react17.createElement)(BoardColumn, { ++ ["not-started", "in-progress", "done"].map((column) => (0, import_react15.createElement)(BoardColumn, { + key: column, + column, + tasks: columns[column], @@ -11708,24 +10358,14 @@ diff --git a/client.js b/client.js + onMove, + onDrag + })) - ) - ) -- ), host); ++ ) ++ ) + ); - } -- --// src/client/dsh-offer.js --var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); --var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; --var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); --function showsDshRow(dsh, app) { -- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -- if (dsh.kind === "npm") return true; -- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++} +function KanbanRow({ wide, useUi, actions }) { + const open = useUi ? useUi((value) => Boolean(value.board)) : false; + const label = t("Tasks"); -+ return (0, import_react17.createElement)( ++ return (0, import_react15.createElement)( + "button", + { + type: "button", @@ -11736,66 +10376,52 @@ diff --git a/client.js b/client.js + "aria-expanded": open, + onClick: () => actions.openBoard() + }, -+ (0, import_react17.createElement)("span", { className: "bt-board-mark" }, (0, import_react17.createElement)(BoardIcon)), -+ wide ? (0, import_react17.createElement)("span", { className: "bt-board-label" }, label) : null ++ (0, import_react15.createElement)("span", { className: "bt-board-mark" }, (0, import_react15.createElement)(BoardIcon)), ++ wide ? (0, import_react15.createElement)("span", { className: "bt-board-label" }, label) : null + ); +} +function scopeName(roster, scope) { -+ if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); -+ if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; -+ return ""; -+} -+function UnifiedBoard({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { -+ const selectedBot = scope?.kind === "bot" ? scope.botId : ""; -+ const selectedRoom = scope?.kind === "group" ? scope.roomId : ""; -+ const lanes = scope?.kind === "group"; -+ return (0, import_react17.createElement)( -+ "div", -+ { className: "bt-board-body" }, -+ (0, import_react17.createElement)( -+ "aside", -+ { className: "bt-board-rail", "aria-label": t("Task board") }, -+ (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), -+ rail.bots.length === 0 ? (0, import_react17.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { -+ const count = counts2?.[row.id] ?? { done: 0, total: 0 }; -+ return (0, import_react17.createElement)(RailButton, { -+ key: row.id, -+ row, -+ selected: row.id === selectedBot, -+ onSelect, -+ meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" -+ }); -+ }), -+ rail.groups.length > 0 ? (0, import_react17.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, -+ rail.groups.map((row) => (0, import_react17.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect })) -+ ), -+ (0, import_react17.createElement)( -+ "div", -+ { className: "bt-board-main" }, -+ scope?.kind === "bot" && onCreate ? (0, import_react17.createElement)( -+ "form", -+ { className: "bt-board-add", onSubmit: (event) => { -+ event.preventDefault(); -+ onCreate(draft); -+ } }, -+ (0, import_react17.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), -+ (0, import_react17.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) -+ ) : null, -+ error ? (0, import_react17.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, -+ lanes ? (0, import_react17.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, -+ (0, import_react17.createElement)(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }) -+ ) -+ ); -+} ++ if (scope?.kind === "group") return roster.roomsById?.[scope.roomId]?.name || t("Group"); ++ if (scope?.kind === "bot") return roster.byId?.[scope.botId]?.name || ""; ++ return ""; + } +-function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { +- const roster = useRoster((value) => value); +- const running = useSessionStatus((map) => map.get(sessionId)?.running === true); +- const liveKey = useLive((state) => JSON.stringify(state[sessionId] ?? {})); +- const pendingInput = useSession((snapshot) => Math.max(0, ...(snapshot?.pendingSubmissions ?? []).filter((sub) => sub.placement !== "queued").map((sub) => sub.time))); +- const bot = roster.byId[sessionId]; +- const room2 = roster.roomsById[sessionId]; +- const entries2 = (0, import_react7.useMemo)(() => Object.values(JSON.parse(liveKey)), [liveKey]); +- const streaming = entries2.some((entry) => entry.text); +- const tools = entries2.filter((entry) => !entry.text).sort((a, b) => a.at - b.at); +- const relaying = tools.some((entry) => entry.relay); +- const visible = running && !streaming && !relaying && (bot !== void 0 || room2 !== void 0); +- const tool = tools.at(-1); +- const inputRef = (0, import_react7.useRef)(0); +- const inputAt = Math.max(inputRef.current, pendingInput); +- inputRef.current = inputAt; +- const want = !visible ? null : inputAt > (tool?.at ?? 0) ? { key: `input:${inputAt}`, pool: PHRASES.thinking, state: "thinking" } : tool ? { key: tool.label ? `tool:${tool.label}` : "working", label: tool.label, pool: PHRASES.working, state: tool.state ?? "working" } : { key: "thinking", pool: PHRASES.thinking, state: "thinking" }; +- const [shown, setShown] = (0, import_react7.useState)(null); +- const wantKey = want?.key ?? null; +- const markState = (0, import_react7.useRef)("thinking"); +- if (want) markState.current = want.state; +- (0, import_react7.useEffect)(() => { +- if (wantKey === null || shown?.key === wantKey) return void 0; +- const make = () => setShown({ key: wantKey, text: want.label ?? drawPhrase(want.pool), at: Date.now() }); +- const wait = shown ? shown.at + PHRASE_HOLD_MS - Date.now() : 0; +- if (wait <= 0) { +- make(); +- return void 0; +function KanbanDialog({ roster, actions, leaving = false }) { + const rail = boardRail(roster); -+ const [scope, setScope] = (0, import_react17.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); -+ const [dragging, setDragging] = (0, import_react17.useState)(null); -+ const [error, setError] = (0, import_react17.useState)(""); -+ const [draft, setDraft] = (0, import_react17.useState)(""); ++ const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); ++ const [dragging, setDragging] = (0, import_react15.useState)(null); ++ const [error, setError] = (0, import_react15.useState)(""); ++ const [draft, setDraft] = (0, import_react15.useState)(""); + const { own, tasks, archive, replace } = useBoardTasks(actions, scope); + const boardTasks = boardTasksFor(tasks, archive); -+ const close = (0, import_react17.useCallback)(() => actions.closeBoard(), []); ++ const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []); + useEscape(close); + const counts2 = {}; + for (const task of [...own, ...archive]) { @@ -11813,7 +10439,19 @@ diff --git a/client.js b/client.js + replace(await actions.taskUpdate({ id, column })); + } catch (failure) { + setError(failure?.message ?? String(failure)); -+ } + } +- const timer = setTimeout(make, wait); +- return () => clearTimeout(timer); +- }, [wantKey, shown]); +- const [phase, setPhase] = (0, import_react7.useState)(null); +- const endedAt = (0, import_react7.useRef)(0); +- const shift = (0, import_react7.useRef)(null); +- const leaving = (0, import_react7.useRef)(false); +- leaving.current = phase !== null && !visible; +- (0, import_react7.useEffect)(() => { +- if (visible) { +- setPhase("in"); +- return void 0; + }; + const create = async (title) => { + const text = String(title ?? "").trim(); @@ -11824,25 +10462,68 @@ diff --git a/client.js b/client.js + setDraft(""); + } catch (failure) { + setError(failure?.message ?? String(failure)); -+ } + } +- setPhase((current) => current === "in" ? "out" : current); +- const timer = setTimeout(() => { +- shift.current = document.querySelector("[data-conversation-scroll]")?.scrollTop ?? null; +- setPhase((current) => current === "out" ? null : current); +- }, 140); +- return () => clearTimeout(timer); +- }, [visible]); +- (0, import_react7.useLayoutEffect)(() => { +- if (phase !== null) return; +- endedAt.current = Date.now(); +- setShown(null); +- const before = shift.current; +- shift.current = null; +- const scroller = document.querySelector("[data-conversation-scroll]"); +- if (before === null || !scroller || reducedMotion()) return; +- const delta = before - scroller.scrollTop; +- if (delta > 0) scroller.querySelector("[data-chat-flow]")?.animate([{ transform: `translateY(${-delta}px)` }, { transform: "none" }], { duration: 140, easing: "cubic-bezier(.23,1,.32,1)" }); +- }, [phase]); +- (0, import_react7.useEffect)(() => { +- if (!running) actions.clearLive(sessionId); +- }, [running, sessionId]); +- useRowEntrances(() => leaving.current || Date.now() - endedAt.current < 300); +- const [now, setNow] = (0, import_react7.useState)(Date.now); +- (0, import_react7.useEffect)(() => { +- if (phase !== "in") return void 0; +- const timer = setInterval(() => setNow(Date.now()), 1e4); +- return () => clearInterval(timer); +- }, [phase]); +- const host = useRunningHost(running); +- 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)( + }; + const name = scopeName(roster, scope); -+ return (0, import_react17.createElement)( -+ "div", ++ return (0, import_react15.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)( +- "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-mem-layer bt-board-layer", "data-leaving": leaving || void 0 }, -+ (0, import_react17.createElement)("div", { className: "bt-scrim", onClick: close }), -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)("div", { className: "bt-scrim", onClick: close }), ++ (0, import_react15.createElement)( + "div", + { className: "bt-mem bt-board", role: "dialog", "aria-modal": true, "aria-labelledby": "bt-board-heading" }, -+ (0, import_react17.createElement)( ++ (0, import_react15.createElement)( + "div", + { className: "bt-mem-head" }, -+ (0, import_react17.createElement)("h2", { id: "bt-board-heading" }, t("Board")), -+ (0, import_react17.createElement)("span", { className: "bt-mem-status" }, name), -+ (0, import_react17.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react17.createElement)(CloseIcon)) ++ (0, import_react15.createElement)("h2", { id: "bt-board-heading" }, t("Board")), ++ (0, import_react15.createElement)("span", { className: "bt-mem-status" }, name), ++ (0, import_react15.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react15.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close board"), autoFocus: true, onClick: close }, (0, import_react15.createElement)(CloseIcon)) + ), -+ (0, import_react17.createElement)(UnifiedBoard, { ++ (0, import_react15.createElement)(BoardView, { + roster, + tasks: boardTasks, + rail, @@ -11851,20 +10532,27 @@ diff --git a/client.js b/client.js + dragging, + error, + draft, -+ actions, + onDraft: setDraft, + onSelect: setScope, + onMove: move, + onDrag: setDragging, + onCreate: create + }) -+ ) + ) +- ), host); + ); } +- +-// src/client/dsh-offer.js +-var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); +-var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; +-var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); +-function showsDshRow(dsh, app) { +- if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; +- if (dsh.kind === "npm") return true; +- return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); +var KANBAN_CSS = ` +.bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -+.bt-board-views{display:flex;gap:6px;align-items:center} -+.bt-board-views .bt-soft[aria-selected=true]{background:var(--bt-active,var(--bt-hover));color:var(--bt-ink)} +.bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} +.bt-board-mark svg{width:18px;height:18px} +.bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -11924,22 +10612,22 @@ diff --git a/client.js b/client.js + .bt-mem.bt-board{width:100vw;border-radius:0;border:0} + .bt-board-columns{flex-direction:column} + .bt-board-column{min-width:0} -+} -+` + DELIVERY_CSS; + } ++`; // src/client/sidebar.js -var import_react8 = require("react"); -+var import_react18 = require("react"); ++var import_react16 = require("react"); var import_react_dom3 = require("react-dom"); var import_dsh_client_ui_primitives2 = require("@deepseek-ai/dsh-client-ui-primitives"); function useMarkedUnread(id, active, marked, actions) { - (0, import_react8.useEffect)(() => { -+ (0, import_react18.useEffect)(() => { ++ (0, import_react16.useEffect)(() => { if (active && marked) actions.clearUnread(id); }, [active, marked]); } -var PinMark = () => (0, import_react8.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react8.createElement)(PinMarkIcon)); -+var PinMark = () => (0, import_react18.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react18.createElement)(PinMarkIcon)); ++var PinMark = () => (0, import_react16.createElement)("span", { className: "bt-pin", "aria-hidden": true }, (0, import_react16.createElement)(PinMarkIcon)); var useApproval = (useWork, match) => useWork ? useWork((work) => (work.approvals ?? []).some(match)) : false; var useGroupWork = (useWork, pick2) => useWork ? useWork((work) => pick2(work.rooms ?? {}).join(",")) : ""; var workKeys = (text) => text === "" ? [] : text.split(",").map((key) => { @@ -11948,7 +10636,7 @@ diff --git a/client.js b/client.js }); -var workLine = (lead, tool, more) => (0, import_react8.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); -function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked }) { -+var workLine = (lead, tool, more) => (0, import_react18.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); ++var workLine = (lead, tool, more) => (0, import_react16.createElement)(Shimmer, null, `${lead} · ${workPhrase(tool)}…${more > 0 ? ` +${more}` : ""}`); +function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) { const id = bot.id; const part = currentPart(bot); @@ -11986,7 +10674,7 @@ diff --git a/client.js b/client.js + const label = [shown, main ? t("Main Bot") : "", pinned ? t("Pinned") : "", running || away ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); if (tile && wide) { - return (0, import_react8.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react8.createElement)( -+ return (0, import_react18.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react18.createElement)( ++ return (0, import_react16.createElement)("div", { className: "bt-tile-wrap" }, (0, import_react16.createElement)( "button", { type: "button", @@ -11997,16 +10685,16 @@ diff --git a/client.js b/client.js - (0, import_react8.createElement)(BotAvatar, { bot, size: 60, state, main: true }), - (0, import_react8.createElement)("span", { className: "bt-tile-name" }, bot.name), - bot.role ? (0, import_react8.createElement)("span", { className: "bt-chip" }, bot.role) : null -+ (0, import_react18.createElement)(BotAvatar, { bot, size: 60, state, main: true }), -+ (0, import_react18.createElement)("span", { className: "bt-tile-name" }, shown), ++ (0, import_react16.createElement)(BotAvatar, { bot, size: 60, state, main: true }), ++ (0, import_react16.createElement)("span", { className: "bt-tile-name" }, shown), + // The Main Bot's tile gets the same unread dot as a row (#420); a wait shows on its avatar. -+ unread && !waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, -+ bot.role ? (0, import_react18.createElement)("span", { className: "bt-chip" }, bot.role) : null ++ unread && !waiting && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-tile-unread", "aria-hidden": true }) : null, ++ bot.role ? (0, import_react16.createElement)("span", { className: "bt-chip" }, bot.role) : null )); } - return (0, import_react8.createElement)( -+ const unreadMark = wide && waiting && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; -+ const row = (0, import_react18.createElement)( ++ const unreadMark = wide && waiting && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null; ++ const row = (0, import_react16.createElement)( "button", { type: "button", @@ -12023,27 +10711,27 @@ diff --git a/client.js b/client.js - (0, import_react8.createElement)(BotAvatar, { bot, size: 36, state, main }), - pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, - wide ? (0, import_react8.createElement)( -+ (0, import_react18.createElement)(BotAvatar, { bot, size: 36, state, main }), -+ pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, -+ wide ? (0, import_react18.createElement)( ++ (0, import_react16.createElement)(BotAvatar, { bot, size: 36, state, main }), ++ pinned && !wide ? (0, import_react16.createElement)(PinMark) : null, ++ wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, bot.name), pinned ? (0, import_react8.createElement)(PinMark) : null), - awayLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, awayLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( + "span", + { className: "bt-row-name" }, -+ (0, import_react18.createElement)("span", { className: "bt-row-title" }, shown), -+ bot.remote ? (0, import_react18.createElement)(RemoteMark, { remote: bot.remote }) : null, -+ pinned ? (0, import_react18.createElement)(PinMark) : null ++ (0, import_react16.createElement)("span", { className: "bt-row-title" }, shown), ++ bot.remote ? (0, import_react16.createElement)(RemoteMark, { remote: bot.remote }) : null, ++ pinned ? (0, import_react16.createElement)(PinMark) : null + ), -+ secondLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, secondLine) : null ++ secondLine ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, secondLine) : null ) : null, - wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null + unreadMark + ); + const settingsLabel = bot.remote ? t("Bot settings") : t("Edit Bot"); -+ const settings = wide ? (0, import_react18.createElement)("button", { ++ const settings = wide ? (0, import_react16.createElement)("button", { + type: "button", + className: "bt-row-settings", + "aria-label": settingsLabel, @@ -12054,8 +10742,8 @@ diff --git a/client.js b/client.js + keepSidebarStill(event.currentTarget); + actions.openSettings("bots", id); + } -+ }, (0, import_react18.createElement)(GearIcon)) : null; -+ if (settings) return (0, import_react18.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); ++ }, (0, import_react16.createElement)(GearIcon)) : null; ++ if (settings) return (0, import_react16.createElement)("div", { className: "bt-row-wrap", "data-bot-id": id }, row, settings); + return row; +} +function keepSidebarStill(node) { @@ -12068,14 +10756,14 @@ diff --git a/client.js b/client.js + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => requestAnimationFrame(reset)); +} +function RemoteMark({ remote }) { -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( + "span", + { className: "bt-row-remote", title: remote.gone ? `${remote.handle} · ${t("no longer shared")}` : remote.handle, "aria-label": t("Friend's Bot") }, -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( + "svg", + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react18.createElement)("circle", { cx: 12, cy: 12, r: 9 }), -+ (0, import_react18.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) ++ (0, import_react16.createElement)("circle", { cx: 12, cy: 12, r: 9 }), ++ (0, import_react16.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ) + ); +} @@ -12087,15 +10775,15 @@ diff --git a/client.js b/client.js + return status === "candidate" && expires && Number.isFinite(Date.parse(expires)) && Date.parse(expires) <= Date.now(); +} +function FaceMark() { -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( + "span", + { className: "bt-row-remote bt-row-face", title: t("Face-to-face"), "aria-label": t("Face-to-face") }, -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( + "svg", + { width: 11, height: 11, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react18.createElement)("circle", { cx: 9, cy: 8, r: 3 }), -+ (0, import_react18.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), -+ (0, import_react18.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) ++ (0, import_react16.createElement)("circle", { cx: 9, cy: 8, r: 3 }), ++ (0, import_react16.createElement)("circle", { cx: 16, cy: 9, r: 2.5 }), ++ (0, import_react16.createElement)("path", { d: "M4 18a5 5 0 0 1 10 0M14 18a4 4 0 0 1 6 0" }) + ) ); } @@ -12124,7 +10812,7 @@ diff --git a/client.js b/client.js + const alert = question || waiting; + const face = room2.origin === "face-to-face"; + const label = [hover, face ? t("Face-to-face") : "", pinned ? t("Pinned") : "", members.length > 0 ? t("Working") : "", waiting ? t("Waiting for your approval") : alert ? t("Waiting for your answer") : ""].filter(Boolean).join(", "); -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "button", { type: "button", @@ -12144,25 +10832,25 @@ diff --git a/client.js b/client.js - (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), - pinned && !wide ? (0, import_react8.createElement)(PinMark) : null, - wide ? (0, import_react8.createElement)( -+ (0, import_react18.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), -+ pinned && !wide ? (0, import_react18.createElement)(PinMark) : null, -+ wide ? (0, import_react18.createElement)( ++ (0, import_react16.createElement)(RoomAvatar, { room: room2, roster, size: 36 }), ++ pinned && !wide ? (0, import_react16.createElement)(PinMark) : null, ++ wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, (0, import_react8.createElement)("span", { className: "bt-row-title" }, room2.name), pinned ? (0, import_react8.createElement)(PinMark) : null), - workingLine ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react18.createElement)("span", { className: "bt-row-name" }, (0, import_react18.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react18.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react18.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react18.createElement)(PinMark) : null), -+ workingLine ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null ++ (0, import_react16.createElement)("span", { className: "bt-row-name" }, (0, import_react16.createElement)("span", { className: face ? "bt-row-title bt-row-title-face" : "bt-row-title" }, face ? faceRowTitle(title) : title), face ? (0, import_react16.createElement)(FaceMark) : room2.sharedFrom ? (0, import_react16.createElement)("span", { className: "bt-tag", title: from }, t("Shared")) : null, pinned ? (0, import_react16.createElement)(PinMark) : null), ++ workingLine ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, workingLine) : preview ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && approval && !active ? (0, import_react8.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -+ wide && alert && !active ? (0, import_react18.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ wide && alert && !active ? (0, import_react16.createElement)("span", { className: "bt-unread bt-unread-wait", "aria-hidden": true }) : wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ); } function AgentSessionRow({ session, agent, actions, renaming, index }) { - const input = (0, import_react8.useRef)(null); - (0, import_react8.useLayoutEffect)(() => { -+ const input = (0, import_react18.useRef)(null); -+ (0, import_react18.useLayoutEffect)(() => { ++ const input = (0, import_react16.useRef)(null); ++ (0, import_react16.useLayoutEffect)(() => { if (!renaming) return; input.current?.focus(); input.current?.select(); @@ -12171,11 +10859,11 @@ diff --git a/client.js b/client.js })(); }; - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "div", { className: "bt-agent-sub-row bt-agent-edit", style: { "--i": index } }, - (0, import_react8.createElement)("input", { -+ (0, import_react18.createElement)("input", { ++ (0, import_react16.createElement)("input", { ref: input, defaultValue: session.title, "aria-label": t("Rename"), @@ -12184,7 +10872,7 @@ diff --git a/client.js b/client.js ); } - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "button", { type: "button", @@ -12194,8 +10882,8 @@ diff --git a/client.js b/client.js }, - (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, session.title), - (0, import_react8.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") -+ (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, session.title), -+ (0, import_react18.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") ++ (0, import_react16.createElement)("span", { className: "bt-agent-sub-title" }, session.title), ++ (0, import_react16.createElement)("span", { className: "bt-agent-sub-time" }, session.at > 0 ? relativeShort(session.at) : "") ); } function AgentRow({ agent, actions, useSessions, useSessionStatus, useUi, wide, marked }) { @@ -12204,11 +10892,11 @@ diff --git a/client.js b/client.js const label = ["DSH Agent", running ? t("Working") : "", unread ? t("Unread activity") : ""].filter(Boolean).join(", "); const open = active && wide; - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "div", { className: "bt-agent" }, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "button", { type: "button", @@ -12218,39 +10906,39 @@ diff --git a/client.js b/client.js }, - (0, import_react8.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react8.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), - wide ? (0, import_react8.createElement)( -+ (0, import_react18.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react18.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), -+ wide ? (0, import_react18.createElement)( ++ (0, import_react16.createElement)("span", { className: `bt-agent-ava${running ? " bt-run" : ""}` }, (0, import_react16.createElement)(import_dsh_client_ui_primitives2.FishLogo, { size: 22 })), ++ wide ? (0, import_react16.createElement)( "span", { className: "bt-row-body" }, - (0, import_react8.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), - preview ? (0, import_react8.createElement)("span", { className: "bt-row-preview" }, preview) : null -+ (0, import_react18.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), -+ preview ? (0, import_react18.createElement)("span", { className: "bt-row-preview" }, preview) : null ++ (0, import_react16.createElement)("span", { className: "bt-row-name" }, "DSH Agent"), ++ preview ? (0, import_react16.createElement)("span", { className: "bt-row-preview" }, preview) : null ) : null, - wide && unread && !active ? (0, import_react8.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null -+ wide && unread && !active ? (0, import_react18.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ++ wide && unread && !active ? (0, import_react16.createElement)("span", { className: "bt-unread", "aria-hidden": true }) : null ), - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "div", { className: "bt-agent-sub", "data-open": open || void 0 }, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "div", { className: "bt-agent-sub-in" }, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "button", { type: "button", className: "bt-agent-sub-row bt-agent-new", style: { "--i": 0 }, onClick: () => { void actions.newAgentSession(agent.id).catch((error) => actions.reportError(error, "agent-session")); } }, - (0, import_react8.createElement)(PlusIcon), - (0, import_react8.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) -+ (0, import_react18.createElement)(PlusIcon), -+ (0, import_react18.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) ++ (0, import_react16.createElement)(PlusIcon), ++ (0, import_react16.createElement)("span", { className: "bt-agent-sub-title" }, t("New session")) ), - sessions.map((session, index) => (0, import_react8.createElement)(AgentSessionRow, { -+ sessions.map((session, index) => (0, import_react18.createElement)(AgentSessionRow, { ++ sessions.map((session, index) => (0, import_react16.createElement)(AgentSessionRow, { key: session.id, session, agent, @@ -12260,8 +10948,8 @@ diff --git a/client.js b/client.js function useListGlide(listRef, layout, key) { - const tops = (0, import_react8.useRef)({ layout, map: /* @__PURE__ */ new Map() }); - (0, import_react8.useLayoutEffect)(() => { -+ const tops = (0, import_react18.useRef)({ layout, map: /* @__PURE__ */ new Map() }); -+ (0, import_react18.useLayoutEffect)(() => { ++ const tops = (0, import_react16.useRef)({ layout, map: /* @__PURE__ */ new Map() }); ++ (0, import_react16.useLayoutEffect)(() => { const list = listRef.current; if (!list) return; const next = /* @__PURE__ */ new Map(); @@ -12269,7 +10957,7 @@ diff --git a/client.js b/client.js tops.current = { layout, map: next }; }, [layout, key]); } -+var NodeActions = class extends import_react18.Component { ++var NodeActions = class extends import_react16.Component { + constructor(props) { + super(props); + this.state = { failed: false }; @@ -12281,33 +10969,33 @@ diff --git a/client.js b/client.js + console.error("flybot: nodeActions failed", error); + } + render() { -+ return this.state.failed ? null : (0, import_react18.createElement)(this.props.component, { bot: this.props.bot }); ++ return this.state.failed ? null : (0, import_react16.createElement)(this.props.component, { bot: this.props.bot }); + } +}; +function TreeNode({ node, rowProps, markedUnread, collapsed, toggle, actions }) { + const { bot, depth, children } = node; + const closed = collapsed[bot.id] === true; -+ return (0, import_react18.createElement)( -+ import_react18.Fragment, ++ return (0, import_react16.createElement)( ++ import_react16.Fragment, + null, -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( + "div", + { className: "bt-tree-item", "data-depth": depth, style: { paddingLeft: `${depth * 16}px` } }, -+ children.length ? (0, import_react18.createElement)( ++ children.length ? (0, import_react16.createElement)( + "button", + { type: "button", className: "bt-tree-toggle", "aria-expanded": !closed, "aria-label": `${closed ? t("Expand") : t("Collapse")} ${bot.name}`, onClick: () => toggle(bot.id) }, -+ (0, import_react18.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") -+ ) : (0, import_react18.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), -+ (0, import_react18.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), -+ typeof actions === "function" ? (0, import_react18.createElement)(NodeActions, { component: actions, bot }) : null ++ (0, import_react16.createElement)("span", { className: "bt-tree-chev", "aria-hidden": true }, "▾") ++ ) : (0, import_react16.createElement)("span", { className: "bt-tree-toggle bt-tree-leaf", "aria-hidden": true }), ++ (0, import_react16.createElement)(BotRow, { ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }), ++ typeof actions === "function" ? (0, import_react16.createElement)(NodeActions, { component: actions, bot }) : null + ), -+ closed ? null : children.map((child) => (0, import_react18.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) ++ closed ? null : children.map((child) => (0, import_react16.createElement)(TreeNode, { key: child.bot.id, node: child, rowProps, markedUnread, collapsed, toggle, actions })) + ); +} function TeamSidebar(props) { const { wide, useRoster, useUi, useSessions, actions } = props; - const listRef = (0, import_react8.useRef)(null); -+ const listRef = (0, import_react18.useRef)(null); ++ const listRef = (0, import_react16.useRef)(null); const roster = useRoster((value) => value); const markedUnread = useUi((value) => value.unread); props.useRegistry((value) => value); @@ -12316,7 +11004,7 @@ diff --git a/client.js b/client.js return JSON.stringify(stamps); }); - const order = (0, import_react8.useMemo)(() => { -+ const order = (0, import_react18.useMemo)(() => { ++ const order = (0, import_react16.useMemo)(() => { const stamps = JSON.parse(updated); const entries2 = [ - ...roster.bots.filter((bot) => bot.id !== roster.mainBotId).map((bot) => ({ kind: "bot", item: bot, at: stamps[currentPart(bot)] ?? bot.createdAt })), @@ -12334,7 +11022,7 @@ diff --git a/client.js b/client.js }, [roster, updated]); const main = roster.byId[roster.mainBotId]; + const structure = useSidebarStructure(); -+ const [collapsed, setCollapsed] = (0, import_react18.useState)({}); ++ const [collapsed, setCollapsed] = (0, import_react16.useState)({}); + const toggle = (id) => setCollapsed((value) => ({ ...value, [id]: !value[id] })); + const inTree = (entry) => entry.kind === "bot" && !entry.item.remote; + const listed = (entry) => inTree(entry) && structure?.include?.(entry.item.id) !== false; @@ -12347,11 +11035,11 @@ diff --git a/client.js b/client.js actions.openPalette(event.key); }; - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "div", { className: wide ? "bt-side" : "bt-side bt-rail", onKeyDown: typeToSearch }, - wide ? (0, import_react8.createElement)( -+ wide ? (0, import_react18.createElement)( ++ wide ? (0, import_react16.createElement)( "div", { className: "bt-side-top" }, - (0, import_react8.createElement)("span", { className: "bt-brand" }, (0, import_react8.createElement)(BrandMark, { size: 22 }), (0, import_react8.createElement)(BrandName)), @@ -12360,31 +11048,31 @@ diff --git a/client.js b/client.js - (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) - ) : (0, import_react8.createElement)( - import_react8.Fragment, -+ (0, import_react18.createElement)("span", { className: "bt-brand" }, (0, import_react18.createElement)(BrandMark, { size: 22 }), (0, import_react18.createElement)(BrandName)), -+ (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -+ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react18.createElement)(SearchIcon)), -+ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) -+ ) : (0, import_react18.createElement)( -+ import_react18.Fragment, ++ (0, import_react16.createElement)("span", { className: "bt-brand" }, (0, import_react16.createElement)(BrandMark, { size: 22 }), (0, import_react16.createElement)(BrandName)), ++ (0, import_react16.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Search"), title: t("Search"), onClick: () => actions.openPalette() }, (0, import_react16.createElement)(SearchIcon)), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react16.createElement)(PlusIcon)) ++ ) : (0, import_react16.createElement)( ++ import_react16.Fragment, null, - (0, import_react8.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), - (0, import_react8.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react8.createElement)(PlusIcon)) -+ (0, import_react18.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), -+ (0, import_react18.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react18.createElement)(PlusIcon)) ++ (0, import_react16.createElement)(UpdateButton, { useUpdate: props.useUpdate, actions }), ++ (0, import_react16.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("New…"), title: t("New…"), onClick: () => actions.openNewChat("new") }, (0, import_react16.createElement)(PlusIcon)) ), - wide && roster.readOnly ? (0, import_react8.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, - (0, import_react8.createElement)( -+ wide && roster.readOnly ? (0, import_react18.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, -+ (0, import_react18.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), -+ (0, import_react18.createElement)(KanbanRow, { wide, useUi, actions }), -+ (0, import_react18.createElement)( ++ wide && roster.readOnly ? (0, import_react16.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ (0, import_react16.createElement)(InboxRow, { wide, useRoster, useWork: props.useWork, useUi, actions }), ++ (0, import_react16.createElement)(KanbanRow, { wide, useUi, actions }), ++ (0, import_react16.createElement)( "div", { ref: listRef, className: "bt-list", role: "region", "aria-label": t("Bot list") }, - main ? (0, import_react8.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react8.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), - order.map((entry) => entry.kind === "bot" ? (0, import_react8.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react8.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react8.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })) -+ tree ? (0, import_react18.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react18.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react18.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react18.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), -+ order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react18.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react18.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react18.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), -+ wide ? (0, import_react18.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react18.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ++ tree ? (0, import_react16.createElement)("div", { className: "bt-tree", role: "group", "aria-label": t("Bot tree") }, tree.map((node) => (0, import_react16.createElement)(TreeNode, { key: node.bot.id, node, rowProps, markedUnread, collapsed, toggle, actions: structure?.nodeActions }))) : main ? (0, import_react16.createElement)(BotRow, { ...rowProps, bot: main, marked: markedUnread[main.id] === true }) : roster.ready ? null : (0, import_react16.createElement)("div", { className: "bt-hint" }, t("Setting up your Main Bot…")), ++ order.filter((entry) => !(tree && inTree(entry)) && !(inTree(entry) && structure?.include?.(entry.item.id) === false)).map((entry) => entry.kind === "bot" ? (0, import_react16.createElement)(BotRow, { key: entry.item.id, ...rowProps, bot: entry.item, marked: markedUnread[entry.item.id] === true }) : entry.kind === "room" ? (0, import_react16.createElement)(RoomRow, { key: entry.item.id, ...rowProps, room: entry.item, marked: markedUnread[entry.item.id] === true }) : (0, import_react16.createElement)(AgentRow, { key: entry.item.id, ...rowProps, agent: entry.item, marked: markedUnread[entry.item.id] === true })), ++ wide ? (0, import_react16.createElement)(FlyBotSections, { roster, actions, renderBot: (bot) => (0, import_react16.createElement)(BotRow, { key: bot.id, ...rowProps, bot, asRow: true, marked: markedUnread[bot.id] === true }) }) : null ) ); } @@ -12396,12 +11084,12 @@ diff --git a/client.js b/client.js - const [dshError, setDshError] = (0, import_react8.useState)(""); - const button = (0, import_react8.useRef)(null); - (0, import_react8.useEffect)(() => { -+ const [open, setOpen] = (0, import_react18.useState)(false); -+ const [box, setBox] = (0, import_react18.useState)(null); -+ const [error, setError] = (0, import_react18.useState)(""); -+ const [dshError, setDshError] = (0, import_react18.useState)(""); -+ const button = (0, import_react18.useRef)(null); -+ (0, import_react18.useEffect)(() => { ++ const [open, setOpen] = (0, import_react16.useState)(false); ++ const [box, setBox] = (0, import_react16.useState)(null); ++ const [error, setError] = (0, import_react16.useState)(""); ++ const [dshError, setDshError] = (0, import_react16.useState)(""); ++ const button = (0, import_react16.useRef)(null); ++ (0, import_react16.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-update-pop") || button.current?.contains(event.target))) return; @@ -12417,11 +11105,11 @@ diff --git a/client.js b/client.js - return (0, import_react8.createElement)( - import_react8.Fragment, + const dshText = dshPhase === "installed" ? t("Restart DSH to start using DSH {version}.", { version: dsh.installed ?? dsh.verified }) : dsh?.available === false && dsh?.blockedBy ? t("Update FlyBot core {version} first.", { version: dsh.blockedBy }) : t("You have {version}.", { version: dsh?.current }); -+ return (0, import_react18.createElement)( -+ import_react18.Fragment, ++ return (0, import_react16.createElement)( ++ import_react16.Fragment, null, - (0, import_react8.createElement)("button", { -+ (0, import_react18.createElement)("button", { ++ (0, import_react16.createElement)("button", { ref: button, type: "button", className: "bt-round bt-update", @@ -12431,30 +11119,30 @@ diff --git a/client.js b/client.js onClick: toggle - }, (0, import_react8.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react8.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), - open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -+ }, (0, import_react18.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react18.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), -+ open && box ? (0, import_react_dom3.createPortal)((0, import_react18.createElement)( ++ }, (0, import_react16.createElement)(CloudDownloadIcon, { done: phase === "installed" }), phase === "failed" || dshPhase === "failed" ? (0, import_react16.createElement)("span", { className: "bt-update-dot", "aria-hidden": true }) : null), ++ open && box ? (0, import_react_dom3.createPortal)((0, import_react16.createElement)( "div", - { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("DS Bot update") : t("DSH update"), style: box }, - update?.available || phase !== "idle" ? (0, import_react8.createElement)( - import_react8.Fragment, + { className: "bt-update-pop", role: "dialog", "aria-label": update?.available || phase !== "idle" ? t("FlyBot core update") : t("DSH update"), style: box }, -+ update?.available || phase !== "idle" ? (0, import_react18.createElement)( -+ import_react18.Fragment, ++ update?.available || phase !== "idle" ? (0, import_react16.createElement)( ++ import_react16.Fragment, null, - (0, import_react8.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("DS Bot {version}", { version: update.latest })), - (0, import_react8.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using DS Bot {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), - message ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), -+ (0, import_react18.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), -+ message ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)("div", { className: "bt-update-title" }, phase === "installed" ? t("Update installed") : t("FlyBot core {version}", { version: update.latest })), ++ (0, import_react16.createElement)("div", { className: "bt-update-text" }, phase === "installed" ? t("Restart DSH to start using FlyBot core {version}.", { version: update.installed ?? update.latest }) : t("You have {version}.", { version: update.current })), ++ message ? (0, import_react16.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: message })) : null, ++ (0, import_react16.createElement)( "div", { className: "bt-update-actions" }, - update.notesUrl ? (0, import_react8.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, - phase === "installed" ? null : (0, import_react8.createElement)("button", { -+ update.notesUrl ? (0, import_react18.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, -+ phase === "installed" ? null : (0, import_react18.createElement)("button", { ++ update.notesUrl ? (0, import_react16.createElement)("a", { className: "bt-update-notes", href: update.notesUrl, target: "_blank", rel: "noopener noreferrer" }, t("What's new")) : null, ++ phase === "installed" ? null : (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", disabled: phase === "installing", @@ -12463,23 +11151,23 @@ diff --git a/client.js b/client.js ) ) : null, - dshRow ? (0, import_react8.createElement)( -+ dshRow ? (0, import_react18.createElement)( ++ dshRow ? (0, import_react16.createElement)( "div", { className: "bt-update-dsh" }, - (0, import_react8.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), - (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), - dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -+ (0, import_react18.createElement)("div", { className: "bt-update-text" }, dshText), -+ dshMessage ? (0, import_react18.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), ++ (0, import_react16.createElement)("div", { className: "bt-update-text" }, dshText), ++ dshMessage ? (0, import_react16.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, ++ (0, import_react16.createElement)( "div", { className: "bt-update-actions" }, - dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { - }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { -+ dsh.kind === "app" ? dsh.available ? (0, import_react18.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { -+ }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react18.createElement)("button", { ++ dsh.kind === "app" ? dsh.available ? (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { ++ }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react16.createElement)("button", { type: "button", className: "bt-update-go", disabled: dshPhase === "installing", @@ -12511,11 +11199,11 @@ diff --git a/client.js b/client.js - setTip(false); - }; - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "span", { className: "bt-connect-wrap" }, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "button", - { - ref: button, @@ -12538,20 +11226,20 @@ diff --git a/client.js b/client.js - t("Plugin compatibility is not fully verified.") - ), document.body) : null + { type: "button", className: "bt-connect bt-connect-foot", onClick: () => actions.openConnectors() }, -+ (0, import_react18.createElement)(PlugIcon), ++ (0, import_react16.createElement)(PlugIcon), + t("Connect apps") + ) ); } function YouAvatar() { - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "span", { className: "bt-you-wrap" }, - (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), - (0, import_react8.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) -+ (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), -+ (0, import_react18.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ++ (0, import_react16.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react16.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })), ++ (0, import_react16.createElement)("span", { style: { position: "absolute", width: 1, height: 1, overflow: "hidden", clip: "rect(0 0 0 0)" } }, t("Settings")) ); } var openShellSettings = null; @@ -12562,7 +11250,7 @@ diff --git a/client.js b/client.js +var MODELS_STEP_ID = "flybot-models"; +var MODELS_STEP_ORDER = 1e3; +function ModelsSettingsStep({ explicit, complete, openSection }) { -+ (0, import_react18.useLayoutEffect)(() => { ++ (0, import_react16.useLayoutEffect)(() => { + if (explicit) openSection("models"); + complete(); + }, []); @@ -12580,8 +11268,8 @@ diff --git a/client.js b/client.js function AccountLauncher({ seat = true, settingsOpen, openSettings, openOnboarding, actions }) { - const [open, setOpen] = (0, import_react8.useState)(false); - (0, import_react8.useLayoutEffect)(() => { -+ const [open, setOpen] = (0, import_react18.useState)(false); -+ (0, import_react18.useLayoutEffect)(() => { ++ const [open, setOpen] = (0, import_react16.useState)(false); ++ (0, import_react16.useLayoutEffect)(() => { if (!seat) return void 0; actions.seatAccount(true); return () => actions.seatAccount(false); @@ -12589,13 +11277,13 @@ diff --git a/client.js b/client.js - const [box, setBox] = (0, import_react8.useState)(null); - const button = (0, import_react8.useRef)(null); - (0, import_react8.useEffect)(() => { -+ const [box, setBox] = (0, import_react18.useState)(null); -+ const button = (0, import_react18.useRef)(null); -+ (0, import_react18.useEffect)(() => { ++ const [box, setBox] = (0, import_react16.useState)(null); ++ const button = (0, import_react16.useRef)(null); ++ (0, import_react16.useEffect)(() => { if (settingsOpen) setOpen(false); }, [settingsOpen]); - (0, import_react8.useEffect)(() => { -+ (0, import_react18.useEffect)(() => { ++ (0, import_react16.useEffect)(() => { if (!openSettings) return void 0; openShellSettings = openSettings; return () => { @@ -12603,7 +11291,7 @@ diff --git a/client.js b/client.js }; }, [openSettings]); - (0, import_react8.useEffect)(() => { -+ (0, import_react18.useEffect)(() => { ++ (0, import_react16.useEffect)(() => { if (!openOnboarding) return void 0; openShellOnboarding = openOnboarding; return () => { @@ -12611,7 +11299,7 @@ diff --git a/client.js b/client.js }; }, [openOnboarding]); - (0, import_react8.useEffect)(() => { -+ (0, import_react18.useEffect)(() => { ++ (0, import_react16.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (event.target instanceof Element && (event.target.closest(".bt-account-menu") || button.current?.contains(event.target))) return; @@ -12633,30 +11321,30 @@ diff --git a/client.js b/client.js ]; - return (0, import_react8.createElement)( - import_react8.Fragment, -+ return (0, import_react18.createElement)( -+ import_react18.Fragment, ++ return (0, import_react16.createElement)( ++ import_react16.Fragment, null, - (0, import_react8.createElement)( -+ (0, import_react18.createElement)( ++ (0, import_react16.createElement)( "button", { ref: button, type: "button", className: "bt-account", "aria-label": t("Account"), "aria-haspopup": "menu", "aria-expanded": open, onClick: toggle }, - (0, import_react8.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) -+ (0, import_react18.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react18.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ++ (0, import_react16.createElement)("span", { className: "bt-you", "aria-hidden": true }, (0, import_react16.createElement)(BotMark, { look: MAIN_LOOK, size: 24 })) ), - open && box ? (0, import_react_dom3.createPortal)((0, import_react8.createElement)( -+ open && box ? (0, import_react_dom3.createPortal)((0, import_react18.createElement)( ++ open && box ? (0, import_react_dom3.createPortal)((0, import_react16.createElement)( "div", { className: "bt-menu bt-menu-up bt-account-menu", role: "menu", "aria-label": t("Account"), style: box }, - groups.map((group, index) => (0, import_react8.createElement)( - import_react8.Fragment, -+ (0, import_react18.createElement)(FlyBotAccountHeader, { actions }), -+ groups.map((group, index) => (0, import_react18.createElement)( -+ import_react18.Fragment, ++ (0, import_react16.createElement)(FlyBotAccountHeader, { actions }), ++ groups.map((group, index) => (0, import_react16.createElement)( ++ import_react16.Fragment, { key: index }, - index > 0 ? (0, import_react8.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, - group.map(([label, Icon, run, hint]) => (0, import_react8.createElement)("button", { -+ index > 0 ? (0, import_react18.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, hint]) => (0, import_react18.createElement)("button", { ++ index > 0 ? (0, import_react16.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, ++ group.map(([label, Icon, run, hint]) => (0, import_react16.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -12665,7 +11353,7 @@ diff --git a/client.js b/client.js void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } - }, (0, import_react8.createElement)(Icon), (0, import_react8.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react8.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) -+ }, (0, import_react18.createElement)(Icon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react18.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) ++ }, (0, import_react16.createElement)(Icon), (0, import_react16.createElement)("span", { className: "bt-menu-label" }, label), hint ? (0, import_react16.createElement)("span", { className: "bt-menu-hint" }, hint) : null)) )) ), document.body) : null ); @@ -12675,8 +11363,8 @@ diff --git a/client.js b/client.js }); - const lastBot = (0, import_react8.useRef)(null); - (0, import_react8.useEffect)(() => { -+ const lastBot = (0, import_react18.useRef)(null); -+ (0, import_react18.useEffect)(() => { ++ const lastBot = (0, import_react16.useRef)(null); ++ (0, import_react16.useEffect)(() => { if (!roster.ready || !roster.mainBotId) return void 0; const [id, blank3] = mainView.split("|"); const bot = id === "" ? void 0 : roster.byId[id]; @@ -12693,20 +11381,20 @@ diff --git a/client.js b/client.js const seated = props.useUi((value) => value.accountSeated === true); if (seated) return null; - return (0, import_react8.createElement)("span", { className: "bt-foot-account" }, (0, import_react8.createElement)(AccountLauncher, { ...props, seat: false })); -+ return (0, import_react18.createElement)("span", { className: "bt-foot-account" }, (0, import_react18.createElement)(AccountLauncher, { ...props, seat: false })); ++ return (0, import_react16.createElement)("span", { className: "bt-foot-account" }, (0, import_react16.createElement)(AccountLauncher, { ...props, seat: false })); } function BotReturn({ wide, useSurface, actions }) { const mode = useSurface((value) => value.mode); if (mode !== "agent") return null; - return (0, import_react8.createElement)( -+ return (0, import_react18.createElement)( ++ return (0, import_react16.createElement)( "button", - { type: "button", className: "bt-return", "aria-label": t("DS Bot"), title: t("DS Bot"), onClick: () => actions.setSurface("bot") }, - (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size: 20 }), - wide ? (0, import_react8.createElement)("span", { className: "bt-return-name" }, t("DS Bot")) : null + { type: "button", className: "bt-return", "aria-label": t("FlyBot"), title: t("FlyBot"), onClick: () => actions.setSurface("bot") }, -+ (0, import_react18.createElement)(FlyBotMark, { size: 20 }), -+ wide ? (0, import_react18.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null ++ (0, import_react16.createElement)(FlyBotMark, { size: 20 }), ++ wide ? (0, import_react16.createElement)("span", { className: "bt-return-name" }, t("FlyBot")) : null ); } var RETURN_CSS = ` @@ -12723,11 +11411,11 @@ diff --git a/client.js b/client.js `; function BrandMark({ size = 24 }) { - return (0, import_react8.createElement)("span", { className: "bt-brand-whale" }, (0, import_react8.createElement)(BotMark, { look: MAIN_LOOK, size })); -+ return (0, import_react18.createElement)("span", { className: "bt-brand-mark" }, (0, import_react18.createElement)(FlyBotMark, { size })); ++ return (0, import_react16.createElement)("span", { className: "bt-brand-mark" }, (0, import_react16.createElement)(FlyBotMark, { size })); } function BrandName() { - return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); -+ return (0, import_react18.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); ++ return (0, import_react16.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, t("FlyBot")); } var SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} @@ -12749,18 +11437,18 @@ diff --git a/client.js b/client.js // src/client/header.js -var import_react9 = require("react"); -+var import_react19 = require("react"); ++var import_react17 = require("react"); var import_react_dom4 = require("react-dom"); -var GoArrow = () => (0, import_react9.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react9.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); -+var GoArrow = () => (0, import_react19.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); ++var GoArrow = () => (0, import_react17.createElement)("svg", { width: 14, height: 14, viewBox: "-12 -12 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2.2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react17.createElement)("path", { d: "M-7 0H7M2 -5L7 0L2 5" })); var MEMORY_NOTICE_MS = 3200; var noNews = (select) => select({}); function useMemoryNotice(ids, useMemoryNews) { const seen = useMemoryNews((map) => Math.max(0, ...ids.map((id) => map?.[id]?.seen ?? 0))); - const [shown, setShown] = (0, import_react9.useState)(false); - (0, import_react9.useEffect)(() => { -+ const [shown, setShown] = (0, import_react19.useState)(false); -+ (0, import_react19.useEffect)(() => { ++ const [shown, setShown] = (0, import_react17.useState)(false); ++ (0, import_react17.useEffect)(() => { const left = seen + MEMORY_NOTICE_MS - Date.now(); setShown(seen > 0 && left > 0); if (seen === 0 || left <= 0) return void 0; @@ -12770,19 +11458,19 @@ diff --git a/client.js b/client.js function MemoryNotice({ shown }) { - const inner = (0, import_react9.useRef)(null); - const [width, setWidth] = (0, import_react9.useState)(0); -+ const inner = (0, import_react19.useRef)(null); -+ const [width, setWidth] = (0, import_react19.useState)(0); ++ const inner = (0, import_react17.useRef)(null); ++ const [width, setWidth] = (0, import_react17.useState)(0); const label = t("Memory updated"); - (0, import_react9.useLayoutEffect)(() => { -+ (0, import_react19.useLayoutEffect)(() => { ++ (0, import_react17.useLayoutEffect)(() => { if (shown) setWidth(inner.current?.scrollWidth ?? 0); }, [label, shown]); - return (0, import_react9.createElement)( -+ return (0, import_react19.createElement)( ++ return (0, import_react17.createElement)( "span", { className: "bt-pill-mem", "aria-hidden": true, style: { "--bt-mem-w": `${width}px` } }, - (0, import_react9.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react9.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react9.createElement)(MemoryIcon)), label) -+ (0, import_react19.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react19.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react19.createElement)(MemoryIcon)), label) ++ (0, import_react17.createElement)("span", { className: "bt-pill-mem-in", ref: inner }, (0, import_react17.createElement)("span", { className: "bt-pill-mem-ico" }, (0, import_react17.createElement)(MemoryIcon)), label) ); } function HeaderPill({ sessionId, useRoster, useSessions, useSessionStatus, useSession, useActivity, useMemoryNews, useTurnClock, useUi, useLive, actions }) { @@ -12791,7 +11479,7 @@ diff --git a/client.js b/client.js return first && !unseenSince(visit, first.start) ? clockLabel(first.start) : ""; }); - (0, import_react9.useEffect)(() => { -+ (0, import_react19.useEffect)(() => { ++ (0, import_react17.useEffect)(() => { if (opening) document.body.style.setProperty("--bt-first-sep", JSON.stringify(opening)); else document.body.style.removeProperty("--bt-first-sep"); return () => document.body.style.removeProperty("--bt-first-sep"); @@ -12825,11 +11513,11 @@ diff --git a/client.js b/client.js - return (0, import_react9.createElement)( + const changed2 = useNews((map) => latestMemoryChange(map, memoryIds)); + const memoryNotice = notice && changed2 !== null && roster.memory !== false; -+ return (0, import_react19.createElement)( ++ return (0, import_react17.createElement)( "div", { className: "bt-head" }, - (0, import_react9.createElement)( -+ (0, import_react19.createElement)( ++ (0, import_react17.createElement)( "button", { type: "button", @@ -12845,24 +11533,24 @@ diff --git a/client.js b/client.js - (0, import_react9.createElement)("span", { className: "bt-pill-name" }, bot?.name ?? room2?.name ?? (title || t("New chat"))), - (0, import_react9.createElement)(MemoryNotice, { shown: memoryNotice }), - (0, import_react9.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react9.createElement)(GoArrow)) -+ bot ? (0, import_react19.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react19.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, -+ (0, import_react19.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), -+ (0, import_react19.createElement)(MemoryNotice, { shown: memoryNotice }), -+ (0, import_react19.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react19.createElement)(GoArrow)) ++ bot ? (0, import_react17.createElement)(BotAvatar, { bot, size: 24, state: botState(running, doing, roster.questions?.[currentPart(bot)] !== void 0), badge: false }) : room2 ? (0, import_react17.createElement)(RoomAvatar, { room: room2, roster, size: 24 }) : null, ++ (0, import_react17.createElement)("span", { className: face ? "bt-pill-name bt-pill-name-face" : "bt-pill-name", title: face ? full2 : void 0 }, name || title || t("New chat")), ++ (0, import_react17.createElement)(MemoryNotice, { shown: memoryNotice }), ++ (0, import_react17.createElement)("span", { className: "bt-pill-go", "aria-hidden": true }, (0, import_react17.createElement)(GoArrow)) ), - (0, import_react9.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), - (0, import_react9.createElement)(NewMessagePills, { key: sessionId, sessionId, useSessions, useUi, useTurnClock }), - (0, import_react9.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) -+ (0, import_react19.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), -+ (0, import_react19.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), -+ (0, import_react19.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ++ (0, import_react17.createElement)("span", { className: "bt-sr", role: "status" }, memoryNotice ? t("Memory updated") : ""), ++ (0, import_react17.createElement)(NewMessagePills, { key: sessionId, sessionId, hiddenTurns, useSessions, useUi, useTurnClock, actions }), ++ (0, import_react17.createElement)(ActivityIndicator, { key: `activity:${sessionId}`, sessionId, useRoster, useSessionStatus, useSession, useLive, actions }) ); } function useTranscriptView() { - const [view, setView] = (0, import_react9.useState)(null); - (0, import_react9.useEffect)(() => { -+ const [view, setView] = (0, import_react19.useState)(null); -+ (0, import_react19.useEffect)(() => { ++ const [view, setView] = (0, import_react17.useState)(null); ++ (0, import_react17.useEffect)(() => { let frame = 0; let watched = null; const observer = new MutationObserver(() => schedule()); @@ -12882,24 +11570,24 @@ diff --git a/client.js b/client.js - const [release, setRelease] = (0, import_react9.useState)({ base: null, dismissed: false }); - const [above, setAbove] = (0, import_react9.useState)({ seen: false, dismissed: false }); - (0, import_react9.useEffect)(() => { -+ const [release, setRelease] = (0, import_react19.useState)({ base: null, dismissed: false }); -+ const [above, setAbove] = (0, import_react19.useState)({ seen: false, dismissed: false }); -+ (0, import_react19.useEffect)(() => { ++ const [release, setRelease] = (0, import_react17.useState)({ base: null, dismissed: false }); ++ const [above, setAbove] = (0, import_react17.useState)({ seen: false, dismissed: false }); ++ (0, import_react17.useEffect)(() => { setRelease({ base: unpinned ? replies : null, dismissed: false }); }, [unpinned]); - (0, import_react9.useEffect)(() => { -+ (0, import_react19.useEffect)(() => { ++ (0, import_react17.useEffect)(() => { if (view?.divider === "in-view") setAbove((state) => state.seen ? state : { ...state, seen: true }); }, [view?.divider]); + const movedOn = useTurnClock((state) => movedOnSince(visit, state[sessionId], hiddenTurns)); -+ (0, import_react19.useEffect)(() => { ++ (0, import_react17.useEffect)(() => { + if (above.seen && movedOn) actions?.settleVisit?.(sessionId); + }, [above.seen, movedOn, sessionId]); const fresh = release.base === null ? 0 : replies - release.base; const showDown = unpinned && fresh > 0 && !release.dismissed; const showUp = view?.divider === "above" && !above.seen && !above.dismissed; - (0, import_react9.useEffect)(() => { -+ (0, import_react19.useEffect)(() => { ++ (0, import_react17.useEffect)(() => { const scroller = document.querySelector("[data-conversation-scroll]"); if (!scroller) return void 0; scroller.toggleAttribute("data-bt-news", showDown); @@ -12908,19 +11596,19 @@ diff --git a/client.js b/client.js if (!view || !showDown && !showUp) return null; const label = (count) => count === 1 ? t("1 new message") : count > 0 ? t("{count} new messages", { count }) : t("New messages"); - const pill = (direction, count, style, onJump, onDismiss) => (0, import_react9.createElement)( -+ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react19.createElement)( ++ const pill = (direction, count, style, onJump, onDismiss) => (0, import_react17.createElement)( "div", { className: "bt-news", "data-direction": direction, style }, - (0, import_react9.createElement)( -+ (0, import_react19.createElement)( ++ (0, import_react17.createElement)( "button", { type: "button", className: "bt-news-jump", onClick: onJump }, - (0, import_react9.createElement)("span", { className: "bt-news-ico" }, (0, import_react9.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), -+ (0, import_react19.createElement)("span", { className: "bt-news-ico" }, (0, import_react19.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), ++ (0, import_react17.createElement)("span", { className: "bt-news-ico" }, (0, import_react17.createElement)(direction === "up" ? ArrowUpIcon : ArrowDownIcon)), label(count) ), - (0, import_react9.createElement)("button", { -+ (0, import_react19.createElement)("button", { ++ (0, import_react17.createElement)("button", { type: "button", className: "bt-news-x", "aria-label": t("Dismiss new messages"), @@ -12929,7 +11617,7 @@ diff --git a/client.js b/client.js onDismiss(); } - }, (0, import_react9.createElement)(CloseIcon)) -+ }, (0, import_react19.createElement)(CloseIcon)) ++ }, (0, import_react17.createElement)(CloseIcon)) ); const toLatest = () => { const scroller = document.querySelector("[data-conversation-scroll]"); @@ -12949,8 +11637,8 @@ diff --git a/client.js b/client.js - ); + const toDivider = () => document.querySelector("[data-conversation-scroll] .bt-new-sep")?.scrollIntoView({ block: "center", behavior: "smooth" }); + return (0, import_react_dom4.createPortal)( -+ (0, import_react19.createElement)( -+ import_react19.Fragment, ++ (0, import_react17.createElement)( ++ import_react17.Fragment, + null, + showUp ? pill("up", unread, { left: view.center, top: view.top }, toDivider, () => setAbove((state) => ({ ...state, dismissed: true }))) : null, + showDown ? pill("down", fresh, { left: view.center, bottom: view.bottom }, toLatest, () => setRelease((state) => ({ ...state, dismissed: true }))) : null @@ -13059,7 +11747,7 @@ diff --git a/client.js b/client.js // src/client/composer.js -var import_react10 = require("react"); -+var import_react20 = require("react"); ++var import_react18 = require("react"); var import_react_dom5 = require("react-dom"); var speechApi = () => window.SpeechRecognition ?? window.webkitSpeechRecognition; var keepComposerFocus = (event) => event.preventDefault(); @@ -13068,15 +11756,15 @@ diff --git a/client.js b/client.js - const [note, setNote] = (0, import_react10.useState)(""); - const recognition = (0, import_react10.useRef)(null); - const latest = (0, import_react10.useRef)(onText); -+ const [listening, setListening] = (0, import_react20.useState)(false); -+ const [note, setNote] = (0, import_react20.useState)(""); -+ const recognition = (0, import_react20.useRef)(null); -+ const latest = (0, import_react20.useRef)(onText); ++ const [listening, setListening] = (0, import_react18.useState)(false); ++ const [note, setNote] = (0, import_react18.useState)(""); ++ const recognition = (0, import_react18.useRef)(null); ++ const latest = (0, import_react18.useRef)(onText); latest.current = onText; - (0, import_react10.useEffect)(() => () => recognition.current?.abort(), []); - (0, import_react10.useEffect)(() => { -+ (0, import_react20.useEffect)(() => () => recognition.current?.abort(), []); -+ (0, import_react20.useEffect)(() => { ++ (0, import_react18.useEffect)(() => () => recognition.current?.abort(), []); ++ (0, import_react18.useEffect)(() => { if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); @@ -13090,14 +11778,14 @@ diff --git a/client.js b/client.js + const self = roster.self; + const humans = (room2?.participants ?? []).filter((p) => p.kind === "human" && p.leftSeq === void 0); + if (room2?.viewerRole === "invited-owner") { -+ return (0, import_react20.createElement)( ++ return (0, import_react18.createElement)( + "span", + { className: "bt-composer-as", title: t("You can remove your Bot from this room") }, + t("Shared room from {name}", { name: room2.sharedFrom?.inviterName || t("a friend") }) + ); + } + if (!self || !room2 || humans.length < 2) return null; -+ return (0, import_react20.createElement)( ++ return (0, import_react18.createElement)( + "span", + { className: "bt-composer-as", title: t("Messages in this room are attributed to you") }, + t("Sending as {name}", { name: self.displayName || self.memberId }) @@ -13109,7 +11797,7 @@ diff --git a/client.js b/client.js }); const quote = useUi((state) => state.quote?.sessionId === sessionId ? state.quote : null); - (0, import_react10.useEffect)(() => { -+ (0, import_react20.useEffect)(() => { ++ (0, import_react18.useEffect)(() => { if (!quote || !inputActions) return; actions.clearQuote(quote.token); const excerpt = quote.text.replace(/\s+/g, " ").trim(); @@ -13128,11 +11816,11 @@ diff --git a/client.js b/client.js const pluginMic = actions.speech() !== void 0; - return (0, import_react10.createElement)( - import_react10.Fragment, -+ return (0, import_react20.createElement)( -+ import_react20.Fragment, ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, null, - pluginMic ? null : (0, import_react10.createElement)("button", { -+ pluginMic ? null : (0, import_react20.createElement)("button", { ++ pluginMic ? null : (0, import_react18.createElement)("button", { type: "button", className: "bt-mic", "aria-label": dictation.listening ? t("Stop dictation") : t("Dictate"), @@ -13142,8 +11830,8 @@ diff --git a/client.js b/client.js onClick: () => dictation.listening ? dictation.stop() : dictation.start() - }, (0, import_react10.createElement)(MicIcon)), - (0, import_react10.createElement)("button", { -+ }, (0, import_react20.createElement)(MicIcon)), -+ (0, import_react20.createElement)("button", { ++ }, (0, import_react18.createElement)(MicIcon)), ++ (0, import_react18.createElement)("button", { type: "button", className: "bt-voice", "aria-label": t("Start voice chat"), @@ -13152,8 +11840,8 @@ diff --git a/client.js b/client.js onClick: () => actions.openVoice(sessionId) - }, (0, import_react10.createElement)(WaveIcon, { size: 18 })), - dictation.note ? (0, import_react10.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null -+ }, (0, import_react20.createElement)(WaveIcon, { size: 18 })), -+ dictation.note ? (0, import_react20.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null ++ }, (0, import_react18.createElement)(WaveIcon, { size: 18 })), ++ dictation.note ? (0, import_react18.createElement)("span", { className: "bt-mic-note", role: "status" }, dictation.note) : null ); } function menuIcons(roster) { @@ -13162,21 +11850,21 @@ diff --git a/client.js b/client.js if (!row.bot) return PeopleIcon; const id = row.bot.id; - if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react10.createElement)(BotMark, { bot: roster.getSnapshot().byId[id] ?? row.bot, size })); -+ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react20.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); ++ if (!marks.has(id)) marks.set(id, ({ size = 14 }) => (0, import_react18.createElement)(BotMark, { bot: markBotForMention(row.bot, roster.getSnapshot()), size })); return marks.get(id); }; } function ComposerPlus({ sessionId, inputActions, useRoster, actions }) { const roster = useRoster((value) => value); - const [open, setOpen] = (0, import_react10.useState)(null); -+ const [open, setOpen] = (0, import_react20.useState)(null); ++ const [open, setOpen] = (0, import_react18.useState)(null); const [shown, leaving] = useLinger(open, 120); - const button = (0, import_react10.useRef)(null); - const menu = (0, import_react10.useRef)(null); - (0, import_react10.useEffect)(() => { -+ const button = (0, import_react20.useRef)(null); -+ const menu = (0, import_react20.useRef)(null); -+ (0, import_react20.useEffect)(() => { ++ const button = (0, import_react18.useRef)(null); ++ const menu = (0, import_react18.useRef)(null); ++ (0, import_react18.useEffect)(() => { if (!open) return void 0; const onDown = (event) => { if (![button.current, menu.current].some((node) => node?.contains(event.target))) setOpen(null); @@ -13186,11 +11874,11 @@ diff --git a/client.js b/client.js const canReference = typeof inputActions?.captureInsertion === "function" && actions.canReferenceChat(); - return (0, import_react10.createElement)( - import_react10.Fragment, -+ return (0, import_react20.createElement)( -+ import_react20.Fragment, ++ return (0, import_react18.createElement)( ++ import_react18.Fragment, null, - (0, import_react10.createElement)("button", { -+ (0, import_react20.createElement)("button", { ++ (0, import_react18.createElement)("button", { ref: button, type: "button", className: "bt-plus", @@ -13200,8 +11888,8 @@ diff --git a/client.js b/client.js onClick: toggle - }, (0, import_react10.createElement)(PlusIcon)), - shown ? (0, import_react_dom5.createPortal)((0, import_react10.createElement)( -+ }, (0, import_react20.createElement)(PlusIcon)), -+ shown ? (0, import_react_dom5.createPortal)((0, import_react20.createElement)( ++ }, (0, import_react18.createElement)(PlusIcon)), ++ shown ? (0, import_react_dom5.createPortal)((0, import_react18.createElement)( "div", { ref: menu, @@ -13212,9 +11900,9 @@ diff --git a/client.js b/client.js - (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react10.createElement)(PaperclipIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), - canReference ? (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react10.createElement)(ChatsIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, - (0, import_react10.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react10.createElement)(ClockIcon), (0, import_react10.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) -+ (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react20.createElement)(PaperclipIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), -+ canReference ? (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react20.createElement)(ChatsIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, -+ (0, import_react20.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react20.createElement)(ClockIcon), (0, import_react20.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) ++ (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: attach }, (0, import_react18.createElement)(PaperclipIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Add files"))), ++ canReference ? (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onMouseDown: keepComposerFocus, onClick: reference }, (0, import_react18.createElement)(ChatsIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Reference a chat"))) : null, ++ (0, import_react18.createElement)("button", { type: "button", role: "menuitem", onClick: schedule }, (0, import_react18.createElement)(ClockIcon), (0, import_react18.createElement)("span", { className: "bt-menu-label" }, t("Scheduled task"))) ), document.body) : null ); } @@ -13400,14 +12088,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"]); -@@ -6110,243 +6110,19 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { - let spokeAt = 0; - let quietSince = 0; - return (level, now) => { -- if (level >= loud) { -- spokeAt ||= now; -- quietSince = 0; -- return "speech"; +@@ -6114,239 +6114,15 @@ function turnDetector({ loud = 0.12, quiet = 0.06, quietMs = 1200 } = {}) { + spokeAt ||= now; + quietSince = 0; + return "speech"; - } - if (!spokeAt) return "waiting"; - if (level < quiet) quietSince ||= now; @@ -13637,10 +12321,6 @@ diff --git a/client.js b/client.js - ) - ) - ); -+ if (level >= loud) { -+ spokeAt ||= now; -+ quietSince = 0; -+ return "speech"; + } + if (!spokeAt) return "waiting"; + if (level < quiet) quietSince ||= now; @@ -13650,7 +12330,7 @@ diff --git a/client.js b/client.js // src/client/model-menu.js -var import_react13 = require("react"); -+var import_react21 = require("react"); ++var import_react19 = require("react"); var import_react_dom6 = require("react-dom"); var MENU_EXIT_MS = 120; function groupModels(models) { @@ -13669,8 +12349,8 @@ diff --git a/client.js b/client.js function useEfforts(served, ref, actions) { - const [fetched, setFetched] = (0, import_react13.useState)(void 0); - (0, import_react13.useEffect)(() => { -+ const [fetched, setFetched] = (0, import_react21.useState)(void 0); -+ (0, import_react21.useEffect)(() => { ++ const [fetched, setFetched] = (0, import_react19.useState)(void 0); ++ (0, import_react19.useEffect)(() => { setFetched(void 0); if (ref === void 0 || served?.reasoning !== void 0 || actions?.efforts === void 0) return void 0; let live = true; @@ -13680,15 +12360,15 @@ diff --git a/client.js b/client.js function EffortRows({ reasoning, current, onPick }) { - return (0, import_react13.createElement)( - import_react13.Fragment, -+ return (0, import_react21.createElement)( -+ import_react21.Fragment, ++ return (0, import_react19.createElement)( ++ import_react19.Fragment, null, - (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), - effortRows(reasoning).map((row) => (0, import_react13.createElement)( -+ (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), -+ effortRows(reasoning).map((row) => (0, import_react21.createElement)( ++ (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ (0, import_react19.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Thinking level")), ++ effortRows(reasoning).map((row) => (0, import_react19.createElement)( "button", { key: row.effort ?? "default", @@ -13698,21 +12378,21 @@ diff --git a/client.js b/client.js }, - (0, import_react13.createElement)("span", { className: "bt-menu-label" }, row.label), - (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react13.createElement)(CheckIcon) : null) -+ (0, import_react21.createElement)("span", { className: "bt-menu-label" }, row.label), -+ (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react21.createElement)(CheckIcon) : null) ++ (0, import_react19.createElement)("span", { className: "bt-menu-label" }, row.label), ++ (0, import_react19.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, row.effort === current ? (0, import_react19.createElement)(CheckIcon) : null) )) ); } function ModelRows({ roster, current, onPick }) { - return groupModels(roster.models).map(([provider, list]) => (0, import_react13.createElement)( - import_react13.Fragment, -+ return groupModels(roster.models).map(([provider, list]) => (0, import_react21.createElement)( -+ import_react21.Fragment, ++ return groupModels(roster.models).map(([provider, list]) => (0, import_react19.createElement)( ++ import_react19.Fragment, { key: list[0].provider ?? provider }, - (0, import_react13.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), - list.map((model) => (0, import_react13.createElement)( -+ (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), -+ list.map((model) => (0, import_react21.createElement)( ++ (0, import_react19.createElement)("div", { className: "bt-model-group", role: "presentation" }, provider), ++ list.map((model) => (0, import_react19.createElement)( "button", { key: model.ref, @@ -13723,25 +12403,25 @@ diff --git a/client.js b/client.js - (0, import_react13.createElement)("span", { className: "bt-menu-label" }, model.name), - model.ref === roster.defaultModel ? (0, import_react13.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, - (0, import_react13.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react13.createElement)(CheckIcon) : null) -+ (0, import_react21.createElement)("span", { className: "bt-menu-label" }, model.name), -+ model.ref === roster.defaultModel ? (0, import_react21.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, -+ (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react21.createElement)(CheckIcon) : null) ++ (0, import_react19.createElement)("span", { className: "bt-menu-label" }, model.name), ++ model.ref === roster.defaultModel ? (0, import_react19.createElement)("span", { className: "bt-menu-hint" }, t("Default")) : null, ++ (0, import_react19.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, model.ref === current ? (0, import_react19.createElement)(CheckIcon) : null) )) )); } -var Caret = () => (0, import_react13.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react13.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); -+var Caret = () => (0, import_react21.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react21.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); ++var Caret = () => (0, import_react19.createElement)("svg", { className: "bt-model-caret", width: 12, height: 12, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, (0, import_react19.createElement)("path", { d: "M5.5 8l4.5 4.5L14.5 8" })); function useAnchoredMenu() { - const [place, setPlace] = (0, import_react13.useState)(null); -+ const [place, setPlace] = (0, import_react21.useState)(null); ++ const [place, setPlace] = (0, import_react19.useState)(null); const [shownPlace, leaving] = useLinger(place, MENU_EXIT_MS); - const button = (0, import_react13.useRef)(null); - const menu = (0, import_react13.useRef)(null); -+ const button = (0, import_react21.useRef)(null); -+ const menu = (0, import_react21.useRef)(null); ++ const button = (0, import_react19.useRef)(null); ++ const menu = (0, import_react19.useRef)(null); const close = () => setPlace(null); - (0, import_react13.useEffect)(() => { -+ (0, import_react21.useEffect)(() => { ++ (0, import_react19.useEffect)(() => { if (place === null) return void 0; const onDown = (event) => { if (menu.current?.contains(event.target) || button.current?.contains(event.target)) return; @@ -13759,11 +12439,11 @@ diff --git a/client.js b/client.js const models = roster.models ?? []; - return (0, import_react13.createElement)( - import_react13.Fragment, -+ return (0, import_react21.createElement)( -+ import_react21.Fragment, ++ return (0, import_react19.createElement)( ++ import_react19.Fragment, null, - (0, import_react13.createElement)( -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( "button", { ref: button, @@ -13772,34 +12452,34 @@ diff --git a/client.js b/client.js onClick: toggle }, - (0, import_react13.createElement)( -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( "span", { className: "bt-model-copy" }, - (0, import_react13.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), - shown ? (0, import_react13.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null -+ (0, import_react21.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), -+ shown ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null ++ (0, import_react19.createElement)("span", { className: "bt-model-name" }, shown?.name ?? t("Choose a model")), ++ shown ? (0, import_react19.createElement)("span", { className: "bt-model-provider" }, shown.served || models.length === 0 ? effortLabel === void 0 ? shown.provider : `${shown.provider} · ${effortLabel}` : t("{provider} · not available", { provider: shown.provider })) : null ), - (0, import_react13.createElement)(Caret) -+ (0, import_react21.createElement)(Caret) ++ (0, import_react19.createElement)(Caret) ), - shownPlace ? (0, import_react_dom6.createPortal)((0, import_react13.createElement)( -+ shownPlace ? (0, import_react_dom6.createPortal)((0, import_react21.createElement)( ++ shownPlace ? (0, import_react_dom6.createPortal)((0, import_react19.createElement)( "div", { ref: menu, className: `bt-menu bt-model-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Model for {name}", { name: bot.name }), style: shownPlace, "data-leaving": leaving || void 0 }, - models.length > 0 ? (0, import_react13.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react13.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), - effortRows(reasoning).length > 0 ? (0, import_react13.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, - (0, import_react13.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react13.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -+ models.length > 0 ? (0, import_react21.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), -+ effortRows(reasoning).length > 0 ? (0, import_react21.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, -+ (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => { ++ models.length > 0 ? (0, import_react19.createElement)(ModelRows, { roster, current, onPick: pick2 }) : (0, import_react19.createElement)("div", { className: "bt-model-none" }, t("No models are set up in DeepSeek Harness yet.")), ++ effortRows(reasoning).length > 0 ? (0, import_react19.createElement)(EffortRows, { reasoning, current: picked, onPick: pickEffort }) : null, ++ (0, import_react19.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ (0, import_react19.createElement)("button", { type: "button", role: "menuitem", onClick: () => { close(); - openHarnessSettings(); - } }, (0, import_react13.createElement)(SlidersIcon), (0, import_react13.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) + openHarnessModels(); -+ } }, (0, import_react21.createElement)(SlidersIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) ++ } }, (0, import_react19.createElement)(SlidersIcon), (0, import_react19.createElement)("span", { className: "bt-menu-label" }, t("Models and providers…"))) ), document.body) : null ); } @@ -13808,15 +12488,15 @@ diff --git a/client.js b/client.js const bot = roster.byId[sessionId]; - const [busy, setBusy] = (0, import_react13.useState)(""); - const [error, setError] = (0, import_react13.useState)(""); -+ const [busy, setBusy] = (0, import_react21.useState)(""); -+ const [error, setError] = (0, import_react21.useState)(""); ++ const [busy, setBusy] = (0, import_react19.useState)(""); ++ const [error, setError] = (0, import_react19.useState)(""); const shown = Boolean(bot) && currentPart(bot) === sessionId && roster.ready; - (0, import_react13.useEffect)(() => { -+ (0, import_react21.useEffect)(() => { ++ (0, import_react19.useEffect)(() => { if (shown) void actions.refreshModels?.(); }, [shown, sessionId]); if (!bot || currentPart(bot) !== sessionId || !roster.ready) return null; -+ if (bot.remote) return bot.remote.gone ? (0, import_react21.createElement)(RemoteGoneDock, { bot }) : null; ++ if (bot.remote) return bot.remote.gone ? (0, import_react19.createElement)(RemoteGoneDock, { bot }) : null; const models = roster.models ?? []; const lost = bot.model ? !models.some((model) => model.ref === bot.model) : false; if (bot.model && (!lost || models.length === 0)) return null; @@ -13826,14 +12506,14 @@ diff --git a/client.js b/client.js const text = lost ? t("{model} is not available anymore. Pick another model for me.", { model: bot.model.split("/").slice(1).join("/") }) : models.length === 0 ? t("Hi, I'm {name}. I don't have a model yet. How do you want to set one up?", { name: bot.name }) : t("Hi, I'm {name}. Pick a model for me first. You can change it later in my details.", { name: bot.name }); - const choices = models.length === 0 ? setupChoices(actions).map(([label, run]) => (0, import_react13.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)) : [ - ...groupModels(models).map(([provider, list]) => (0, import_react13.createElement)( -+ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" })] : [ -+ ...groupModels(models).map(([provider, list]) => (0, import_react21.createElement)( ++ const choices = models.length === 0 ? [...setupChoices(actions).map(([label, run]) => (0, import_react19.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), (0, import_react19.createElement)(KeyHelpLink, { key: "key-help" })] : [ ++ ...groupModels(models).map(([provider, list]) => (0, import_react19.createElement)( "div", { key: list[0].provider ?? provider, className: "bt-model-choice-group", role: "group", "aria-label": provider }, - (0, import_react13.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), - (0, import_react13.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react13.createElement)("button", { -+ (0, import_react21.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), -+ (0, import_react21.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react21.createElement)("button", { ++ (0, import_react19.createElement)("div", { className: "bt-model-choice-provider", "aria-hidden": true }, provider), ++ (0, import_react19.createElement)("div", { className: "bt-model-choice-chips" }, list.map((model) => (0, import_react19.createElement)("button", { key: model.ref, type: "button", className: "bt-model-chip", @@ -13842,55 +12522,55 @@ diff --git a/client.js b/client.js title: model.ref === roster.defaultModel ? `${model.ref} · ${t("Harness default")}` : model.ref, onClick: () => pick2(model.ref) - }, model.name, busy === model.ref ? (0, import_react13.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) -+ }, model.name, busy === model.ref ? (0, import_react21.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) ++ }, model.name, busy === model.ref ? (0, import_react19.createElement)("span", { className: "bt-model-spin", "aria-label": t("Saving…") }) : null))) )), - (0, import_react13.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessSettings }, (0, import_react13.createElement)(SlidersIcon), t("Manage models")) -+ (0, import_react21.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react21.createElement)(SlidersIcon), t("Manage models")) ++ (0, import_react19.createElement)("button", { key: "manage", type: "button", className: "bt-model-chip bt-model-chip-quiet", onClick: openHarnessModels }, (0, import_react19.createElement)(SlidersIcon), t("Manage models")) ]; - return (0, import_react13.createElement)( -+ return (0, import_react21.createElement)( ++ return (0, import_react19.createElement)( "div", { className: "bt-model-dock" }, - (0, import_react13.createElement)( -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( "div", { className: "bt-astack", "data-lead": "" }, - (0, import_react13.createElement)( -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( "div", { className: "bt-msg" }, - (0, import_react13.createElement)( -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( "div", { className: "bt-msg-line" }, - (0, import_react13.createElement)(BotMark, { bot, size: 22 }), - (0, import_react13.createElement)("div", { className: "bt-bubble" }, text) -+ (0, import_react21.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react21.createElement)("div", { className: "bt-bubble" }, text) ++ (0, import_react19.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react19.createElement)("div", { className: "bt-bubble" }, text) ) ) ), - (0, import_react13.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), - error ? (0, import_react13.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null -+ (0, import_react21.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), -+ error ? (0, import_react21.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null ++ (0, import_react19.createElement)("div", { className: "bt-model-chips", role: "group", "aria-label": t("Choose a model") }, choices), ++ error ? (0, import_react19.createElement)("div", { className: "bt-secret-error bt-model-error", role: "alert" }, error) : null + ); +} +function RemoteGoneDock({ bot }) { -+ return (0, import_react21.createElement)( ++ return (0, import_react19.createElement)( + "div", + { className: "bt-model-dock", "data-flybot": "remote-gone" }, -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( + "div", + { className: "bt-astack", "data-lead": "" }, -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( + "div", + { className: "bt-msg" }, -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)( + "div", + { className: "bt-msg-line" }, -+ (0, import_react21.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react21.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) ++ (0, import_react19.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react19.createElement)("div", { className: "bt-bubble" }, t("{name} ({handle}) is no longer shared with you, so messages to it cannot be delivered. It runs on its owner's machine, so another model cannot stand in. Ask its owner for a new invite.", { name: bot.name, handle: bot.remote.handle })) + ) + ) + ) @@ -13906,7 +12586,7 @@ diff --git a/client.js b/client.js } +var KEY_HELP_URL = "https://github.com/ThinkFlowLab/FlyBot/blob/main/docs/deepseek-api-key.md"; +function KeyHelpLink() { -+ return (0, import_react21.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); ++ return (0, import_react19.createElement)("a", { className: "bt-model-help", href: KEY_HELP_URL, target: "_blank", rel: "noopener noreferrer" }, t("How to set a DeepSeek API key")); +} +function missingModelKey(message) { + const text = String(message ?? ""); @@ -13930,22 +12610,22 @@ diff --git a/client.js b/client.js + const message = missing ? t("This Bot's model has no API key yet. Set one up, then send your message again.") : code === "ACCOUNT_SIGNED_OUT" ? shell("message.failure.accountSignedOut", raw) : code === "ACCOUNT_SIGN_IN_REQUIRED" ? shell("message.failure.accountSignInRequired", raw) : code === "QUOTA" || code === "ACCOUNT_QUOTA" ? shell("message.failure.quota", raw) : code === "AUTH" ? shell("message.failure.auth", raw) : raw; + const choices = missing ? modelKeyChoices(actions ?? {}, missing.route) : []; + const deepseek = missing ? /^deepseek/i.test(missing.route) : false; -+ return (0, import_react21.createElement)( ++ return (0, import_react19.createElement)( + "div", + { className: "bt-turn-error", role: "status", "data-missing-key": missing ? "" : void 0 }, -+ (0, import_react21.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), -+ (0, import_react21.createElement)( ++ (0, import_react19.createElement)("span", { className: "bt-turn-error-dot", "aria-hidden": true }), ++ (0, import_react19.createElement)( + "div", + { className: "bt-turn-error-copy" }, -+ (0, import_react21.createElement)("span", { className: "bt-turn-error-title" }, title), -+ (0, import_react21.createElement)("span", { className: "bt-turn-error-message" }, message), -+ code ? (0, import_react21.createElement)("code", { className: "bt-turn-error-code" }, code) : null ++ (0, import_react19.createElement)("span", { className: "bt-turn-error-title" }, title), ++ (0, import_react19.createElement)("span", { className: "bt-turn-error-message" }, message), ++ code ? (0, import_react19.createElement)("code", { className: "bt-turn-error-code" }, code) : null + ), -+ choices.length > 0 ? (0, import_react21.createElement)( ++ choices.length > 0 ? (0, import_react19.createElement)( + "div", + { className: "bt-turn-error-actions" }, -+ choices.map(([label, run]) => (0, import_react21.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), -+ deepseek ? (0, import_react21.createElement)(KeyHelpLink, { key: "key-help" }) : null ++ choices.map(([label, run]) => (0, import_react19.createElement)("button", { key: label, type: "button", className: "bt-model-chip", onClick: run }, label)), ++ deepseek ? (0, import_react19.createElement)(KeyHelpLink, { key: "key-help" }) : null + ) : null + ); +} @@ -13954,18 +12634,18 @@ diff --git a/client.js b/client.js const entry = open ? actions.signInEntry?.() : null; if (!entry) return null; - return (0, import_react13.createElement)(SignInHost, { key: open, entry, actions }); -+ return (0, import_react21.createElement)(SignInHost, { key: open, entry, actions }); ++ return (0, import_react19.createElement)(SignInHost, { key: open, entry, actions }); } function SignInHost({ entry, actions }) { - const [ops] = (0, import_react13.useState)(() => entry.inject?.() ?? {}); -+ const [ops] = (0, import_react21.useState)(() => entry.inject?.() ?? {}); ++ const [ops] = (0, import_react19.useState)(() => entry.inject?.() ?? {}); const close = () => actions.closeSignIn(); - const useHook = (source) => (selector) => (0, import_react13.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); - const [hooks] = (0, import_react13.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); - return (0, import_react13.createElement)(entry.component, { -+ const useHook = (source) => (selector) => (0, import_react21.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); -+ const [hooks] = (0, import_react21.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); -+ return (0, import_react21.createElement)(entry.component, { ++ const useHook = (source) => (selector) => (0, import_react19.useSyncExternalStore)(source.subscribe, () => selector(source.getSnapshot())); ++ const [hooks] = (0, import_react19.useState)(() => ({ useAccount: useHook(ops.hooks.account), useTheme: useHook(ops.hooks.theme) })); ++ return (0, import_react19.createElement)(entry.component, { ...ops, ...hooks, t: actions.accountT(), @@ -13991,7 +12671,7 @@ diff --git a/client.js b/client.js `; +// src/client/model-test.js -+var import_react22 = require("react"); ++var import_react20 = require("react"); +var FAILURE_COPY = { + NO_MODEL: "Pick a model first.", + NO_KEY: "This model has no API key yet. Set one up in the model settings.", @@ -14016,12 +12696,12 @@ diff --git a/client.js b/client.js +} +var testModel = (id) => call("test-model", { id }); +function ModelTestRow({ bot, modelName, test = testModel }) { -+ const [state, setState] = (0, import_react22.useState)({ busy: false, outcome: void 0 }); -+ const live = (0, import_react22.useRef)(true); -+ (0, import_react22.useEffect)(() => () => { ++ const [state, setState] = (0, import_react20.useState)({ busy: false, outcome: void 0 }); ++ const live = (0, import_react20.useRef)(true); ++ (0, import_react20.useEffect)(() => () => { + live.current = false; + }, []); -+ (0, import_react22.useEffect)(() => { ++ (0, import_react20.useEffect)(() => { + setState({ busy: false, outcome: void 0 }); + }, [bot.id, bot.model]); + const start = () => { @@ -14035,21 +12715,21 @@ diff --git a/client.js b/client.js + }); + }; + const { busy, outcome } = state; -+ return (0, import_react22.createElement)( ++ return (0, import_react20.createElement)( + "div", + { className: "bt-set-row bt-bs-row bt-model-test" }, -+ (0, import_react22.createElement)( ++ (0, import_react20.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), -+ (0, import_react22.createElement)( ++ (0, import_react20.createElement)("span", { className: "bt-set-hint" }, t("Sends one short request to this model. It may be billed.")), ++ (0, import_react20.createElement)( + "span", + { className: "bt-model-test-result", role: "status", "aria-live": "polite", "data-ok": outcome === void 0 ? void 0 : String(outcome.ok) }, + busy ? t("Testing…") : outcome === void 0 ? null : outcome.text, -+ outcome?.code ? (0, import_react22.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null ++ outcome?.code ? (0, import_react20.createElement)("code", { className: "bt-model-test-code" }, outcome.code) : null + ) + ), -+ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) ++ (0, import_react20.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, "aria-busy": busy || void 0, onClick: start }, t("Test connection")) + ); +} +var MODEL_TEST_CSS = ` @@ -14062,11 +12742,11 @@ diff --git a/client.js b/client.js + // src/client/folder-menu.js -var import_react14 = require("react"); -+var import_react23 = require("react"); ++var import_react21 = require("react"); var import_react_dom7 = require("react-dom"); var folderName = (path) => String(path).replace(/[\\/]+$/, "").split(/[\\/]/).pop() || String(path); -var Tick = ({ on }) => (0, import_react14.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react14.createElement)(CheckIcon) : null); -+var Tick = ({ on }) => (0, import_react23.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react23.createElement)(CheckIcon) : null); ++var Tick = ({ on }) => (0, import_react21.createElement)("span", { className: "bt-model-tick", "aria-hidden": true }, on ? (0, import_react21.createElement)(CheckIcon) : null); function FolderMenu({ value, group = false, actions, run, onPick, wide = false }) { const { isOpen, shownPlace, leaving, button, menu, open, close } = useAnchoredMenu(); - const [info, setInfo] = (0, import_react14.useState)(null); @@ -14074,11 +12754,11 @@ diff --git a/client.js b/client.js - const [draft, setDraft] = (0, import_react14.useState)(""); - const [listing, setListing] = (0, import_react14.useState)(null); - const [problem, setProblem] = (0, import_react14.useState)(""); -+ const [info, setInfo] = (0, import_react23.useState)(null); -+ const [view, setView] = (0, import_react23.useState)("list"); -+ const [draft, setDraft] = (0, import_react23.useState)(""); -+ const [listing, setListing] = (0, import_react23.useState)(null); -+ const [problem, setProblem] = (0, import_react23.useState)(""); ++ const [info, setInfo] = (0, import_react21.useState)(null); ++ const [view, setView] = (0, import_react21.useState)("list"); ++ const [draft, setDraft] = (0, import_react21.useState)(""); ++ const [listing, setListing] = (0, import_react21.useState)(null); ++ const [problem, setProblem] = (0, import_react21.useState)(""); const fallback = group ? t("Each Bot's own folder") : t("Team workspace"); const toggle = () => { if (isOpen) { @@ -14087,50 +12767,50 @@ diff --git a/client.js b/client.js const known = info?.folders ?? []; const folders = value && !known.some((folder) => folder.path === value) ? [{ path: value, title: folderName(value) }, ...known] : known; - const listView = (0, import_react14.createElement)( -+ const listView = (0, import_react23.createElement)( ++ const listView = (0, import_react21.createElement)( "div", { key: "list", className: "bt-folder-view" }, - (0, import_react14.createElement)( -+ (0, import_react23.createElement)( ++ (0, import_react21.createElement)( "button", { type: "button", role: "menuitemradio", "aria-checked": !value, title: group ? void 0 : info?.team ?? void 0, onClick: () => choose("") }, - (0, import_react14.createElement)("span", { className: "bt-menu-label" }, fallback), - (0, import_react14.createElement)(Tick, { on: !value }) -+ (0, import_react23.createElement)("span", { className: "bt-menu-label" }, fallback), -+ (0, import_react23.createElement)(Tick, { on: !value }) ++ (0, import_react21.createElement)("span", { className: "bt-menu-label" }, fallback), ++ (0, import_react21.createElement)(Tick, { on: !value }) ), - folders.length > 0 ? (0, import_react14.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, - folders.map((folder) => (0, import_react14.createElement)( -+ folders.length > 0 ? (0, import_react23.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, -+ folders.map((folder) => (0, import_react23.createElement)( ++ folders.length > 0 ? (0, import_react21.createElement)("div", { className: "bt-model-group", role: "presentation" }, t("Folders")) : null, ++ folders.map((folder) => (0, import_react21.createElement)( "button", { key: folder.path, type: "button", role: "menuitemradio", "aria-checked": folder.path === value, title: folder.path, onClick: () => choose(folder.path) }, - (0, import_react14.createElement)("span", { className: "bt-folder-copy" }, (0, import_react14.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react14.createElement)("span", { className: "bt-folder-path" }, folder.path)), - (0, import_react14.createElement)(Tick, { on: folder.path === value }) -+ (0, import_react23.createElement)("span", { className: "bt-folder-copy" }, (0, import_react23.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react23.createElement)("span", { className: "bt-folder-path" }, folder.path)), -+ (0, import_react23.createElement)(Tick, { on: folder.path === value }) ++ (0, import_react21.createElement)("span", { className: "bt-folder-copy" }, (0, import_react21.createElement)("span", { className: "bt-menu-label" }, folder.title), (0, import_react21.createElement)("span", { className: "bt-folder-path" }, folder.path)), ++ (0, import_react21.createElement)(Tick, { on: folder.path === value }) )), - (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - info?.picker === "native" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, - info?.picker === "browse" ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, - (0, import_react14.createElement)("button", { type: "button", role: "menuitem", onClick: () => { -+ (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ info?.picker === "native" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, -+ info?.picker === "browse" ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, -+ (0, import_react23.createElement)("button", { type: "button", role: "menuitem", onClick: () => { ++ (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ info?.picker === "native" ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: pickNative }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Choose a folder…"))) : null, ++ info?.picker === "browse" ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => browse(value) }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Browse folders…"))) : null, ++ (0, import_react21.createElement)("button", { type: "button", role: "menuitem", onClick: () => { setDraft(value ?? ""); setView("type"); - } }, (0, import_react14.createElement)(PencilIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) -+ } }, (0, import_react23.createElement)(PencilIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) ++ } }, (0, import_react21.createElement)(PencilIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Enter a path…"))) ); - const typeView = (0, import_react14.createElement)( -+ const typeView = (0, import_react23.createElement)( ++ const typeView = (0, import_react21.createElement)( "div", { key: "type", className: "bt-folder-view" }, - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react14.createElement)(ChevronLeftIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react14.createElement)("input", { -+ (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))), -+ (0, import_react23.createElement)("input", { ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react21.createElement)(ChevronLeftIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Back"))), ++ (0, import_react21.createElement)("input", { className: "bt-folder-input", autoFocus: true, value: draft, @@ -14139,48 +12819,48 @@ diff --git a/client.js b/client.js } }), - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) -+ (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-use", disabled: draft.trim() === "", onClick: typed }, t("Use this folder")) ); const parent = listing?.crumbs?.length > 1 ? listing.crumbs.at(-2) : void 0; const inside = listing?.entries?.filter((entry) => !entry.hidden) ?? []; - const browseView = listing ? (0, import_react14.createElement)( -+ const browseView = listing ? (0, import_react23.createElement)( ++ const browseView = listing ? (0, import_react21.createElement)( "div", { key: `browse:${listing.path}`, className: "bt-folder-view" }, - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react14.createElement)(ChevronLeftIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Back"))), - (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), - parent ? (0, import_react14.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react14.createElement)(FolderIcon), (0, import_react14.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, - inside.map((entry) => (0, import_react14.createElement)( -+ (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react23.createElement)(ChevronLeftIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Back"))), -+ (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), -+ parent ? (0, import_react23.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react23.createElement)(FolderIcon), (0, import_react23.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, -+ inside.map((entry) => (0, import_react23.createElement)( ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-back", onClick: () => setView("list") }, (0, import_react21.createElement)(ChevronLeftIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Back"))), ++ (0, import_react21.createElement)("div", { className: "bt-folder-path bt-folder-here" }, listing.path), ++ parent ? (0, import_react21.createElement)("button", { type: "button", role: "menuitem", title: parent.path, onClick: () => browse(parent.path) }, (0, import_react21.createElement)(FolderIcon), (0, import_react21.createElement)("span", { className: "bt-menu-label" }, t("Up one level"))) : null, ++ inside.map((entry) => (0, import_react21.createElement)( "button", { key: entry.path, type: "button", role: "menuitem", onClick: () => browse(entry.path) }, - (0, import_react14.createElement)(FolderIcon), - (0, import_react14.createElement)("span", { className: "bt-menu-label" }, entry.name), - (0, import_react14.createElement)(ChevronRightIcon) -+ (0, import_react23.createElement)(FolderIcon), -+ (0, import_react23.createElement)("span", { className: "bt-menu-label" }, entry.name), -+ (0, import_react23.createElement)(ChevronRightIcon) ++ (0, import_react21.createElement)(FolderIcon), ++ (0, import_react21.createElement)("span", { className: "bt-menu-label" }, entry.name), ++ (0, import_react21.createElement)(ChevronRightIcon) )), - inside.length === 0 ? (0, import_react14.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, - listing.truncated ? (0, import_react14.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, - (0, import_react14.createElement)("div", { className: "bt-menu-sep", role: "separator" }), - (0, import_react14.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) -+ inside.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, -+ listing.truncated ? (0, import_react23.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, -+ (0, import_react23.createElement)("div", { className: "bt-menu-sep", role: "separator" }), -+ (0, import_react23.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) ++ inside.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-model-none" }, t("No folders inside.")) : null, ++ listing.truncated ? (0, import_react21.createElement)("div", { className: "bt-folder-path bt-folder-here" }, t("Some folders are not shown.")) : null, ++ (0, import_react21.createElement)("div", { className: "bt-menu-sep", role: "separator" }), ++ (0, import_react21.createElement)("button", { type: "button", className: "bt-folder-use", onClick: () => choose(listing.path) }, t("Use this folder")) ) : null; const shownView = view === "type" ? typeView : view === "browse" && browseView ? browseView : listView; - return (0, import_react14.createElement)( - import_react14.Fragment, -+ return (0, import_react23.createElement)( -+ import_react23.Fragment, ++ return (0, import_react21.createElement)( ++ import_react21.Fragment, null, - (0, import_react14.createElement)( -+ (0, import_react23.createElement)( ++ (0, import_react21.createElement)( "button", { ref: button, @@ -14189,24 +12869,24 @@ diff --git a/client.js b/client.js onClick: toggle }, - (0, import_react14.createElement)( -+ (0, import_react23.createElement)( ++ (0, import_react21.createElement)( "span", { className: "bt-model-copy" }, - (0, import_react14.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), - value ? (0, import_react14.createElement)("span", { className: "bt-model-provider" }, value) : null -+ (0, import_react23.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), -+ value ? (0, import_react23.createElement)("span", { className: "bt-model-provider" }, value) : null ++ (0, import_react21.createElement)("span", { className: "bt-model-name" }, value ? folderName(value) : fallback), ++ value ? (0, import_react21.createElement)("span", { className: "bt-model-provider" }, value) : null ), - (0, import_react14.createElement)(Caret) -+ (0, import_react23.createElement)(Caret) ++ (0, import_react21.createElement)(Caret) ), - shownPlace ? (0, import_react_dom7.createPortal)((0, import_react14.createElement)( -+ shownPlace ? (0, import_react_dom7.createPortal)((0, import_react23.createElement)( ++ shownPlace ? (0, import_react_dom7.createPortal)((0, import_react21.createElement)( "div", { ref: menu, className: `bt-menu bt-model-menu bt-folder-menu${shownPlace.bottom !== void 0 ? " bt-menu-up" : ""}`, role: "menu", "aria-label": t("Working folder"), style: shownPlace, "data-leaving": leaving || void 0 }, shownView, - problem ? (0, import_react14.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null -+ problem ? (0, import_react23.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null ++ problem ? (0, import_react21.createElement)("div", { className: "bt-folder-error", role: "alert" }, problem) : null ), document.body) : null ); } @@ -14216,8 +12896,8 @@ diff --git a/client.js b/client.js // src/client/schedules.js -var import_react15 = require("react"); -var POLL_MS = 3e4; -+var import_react24 = require("react"); -+var POLL_MS5 = 3e4; ++var import_react22 = require("react"); ++var POLL_MS4 = 3e4; var shared = null; var listeners = /* @__PURE__ */ new Set(); function publish(view) { @@ -14227,8 +12907,8 @@ diff --git a/client.js b/client.js function useSchedules(actions) { - const [view, setView] = (0, import_react15.useState)(shared); - (0, import_react15.useEffect)(() => { -+ const [view, setView] = (0, import_react24.useState)(shared); -+ (0, import_react24.useEffect)(() => { ++ const [view, setView] = (0, import_react22.useState)(shared); ++ (0, import_react22.useEffect)(() => { listeners.add(setView); let stopped = false; let timer; @@ -14237,7 +12917,7 @@ diff --git a/client.js b/client.js }).catch(() => { }).finally(() => { - if (!stopped) timer = setTimeout(load, POLL_MS); -+ if (!stopped) timer = setTimeout(load, POLL_MS5); ++ if (!stopped) timer = setTimeout(load, POLL_MS4); }); load(); return () => { @@ -14266,9 +12946,9 @@ diff --git a/client.js b/client.js + } +} +function TaskRow({ task, actions, onView, onEdit, onCopy, showBot, roster }) { -+ const [confirming, setConfirming] = (0, import_react24.useState)(false); -+ const [busy, setBusy] = (0, import_react24.useState)(false); -+ (0, import_react24.useEffect)(() => { ++ const [confirming, setConfirming] = (0, import_react22.useState)(false); ++ const [busy, setBusy] = (0, import_react22.useState)(false); ++ (0, import_react22.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); @@ -14284,7 +12964,7 @@ diff --git a/client.js b/client.js + onView(next); + }, () => setBusy(false)); + }; -+ const [retryError, setRetryError] = (0, import_react24.useState)(""); ++ const [retryError, setRetryError] = (0, import_react22.useState)(""); + const retry = () => { + setBusy(true); + setRetryError(""); @@ -14301,33 +12981,33 @@ diff --git a/client.js b/client.js + const stoppable = task.status === "active" || task.status === "paused"; + const failed = task.lastRun?.result === "failed"; + const last = lastRunText(task.lastRun); -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( "div", - { className: "bt-set-row bt-task", "data-done": task.status === "active" ? void 0 : "", "data-busy": busy || void 0 }, - showBot ? (0, import_react15.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, - (0, import_react15.createElement)( + { className: "bt-set-row bt-task", "data-done": stoppable ? void 0 : "", "data-paused": task.status === "paused" || void 0, "data-busy": busy || void 0 }, -+ showBot ? (0, import_react24.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, -+ (0, import_react24.createElement)( ++ showBot ? (0, import_react22.createElement)(BotAvatar, { bot: roster.byId[task.botId], size: 28, badge: false, live: false }) : null, ++ (0, import_react22.createElement)( "span", { className: "bt-set-copy" }, - (0, import_react15.createElement)("span", { className: "bt-task-title" }, task.title), - (0, import_react15.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), - task.prompt && task.prompt !== task.title ? (0, import_react15.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null -+ (0, import_react24.createElement)("span", { className: "bt-task-title" }, task.title), -+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), -+ task.prompt && task.prompt !== task.title ? (0, import_react24.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, -+ last ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, -+ retryError ? (0, import_react24.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null ++ (0, import_react22.createElement)("span", { className: "bt-task-title" }, task.title), ++ (0, import_react22.createElement)("span", { className: "bt-set-hint" }, [timingText(task), room2 ? t("in {name}", { name: room2.name }) : ""].filter(Boolean).join(" · ")), ++ task.prompt && task.prompt !== task.title ? (0, import_react22.createElement)("span", { className: "bt-task-prompt" }, task.prompt) : null, ++ last ? (0, import_react22.createElement)("span", { className: "bt-task-last", "data-failed": failed || void 0 }, last) : null, ++ retryError ? (0, import_react22.createElement)("span", { className: "bt-task-last", "data-failed": "", role: "alert" }, retryError) : null ), - (0, import_react15.createElement)("span", { className: "bt-task-next" }, nextText(task)), - (0, import_react15.createElement)("button", { -+ (0, import_react24.createElement)("span", { className: "bt-task-next" }, nextText(task)), -+ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, -+ onEdit ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react24.createElement)(PencilIcon)) : null, -+ onCopy ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react24.createElement)(DuplicateIcon)) : null, -+ stoppable ? (0, import_react24.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, -+ (0, import_react24.createElement)("button", { ++ (0, import_react22.createElement)("span", { className: "bt-task-next" }, nextText(task)), ++ failed && task.status !== "paused" && actions.scheduleRetry ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, title: t("Run the task once now"), onClick: retry }, t("Retry")) : null, ++ onEdit ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Edit"), title: t("Edit"), onClick: () => onEdit(task) }, (0, import_react22.createElement)(PencilIcon)) : null, ++ onCopy ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-tool", disabled: busy, "aria-label": t("Copy to another Bot or group chat"), title: t("Copy to…"), onClick: () => onCopy(task) }, (0, import_react22.createElement)(DuplicateIcon)) : null, ++ stoppable ? (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-pause", disabled: busy, onClick: toggle }, task.status === "paused" ? t("Resume") : t("Pause")) : null, ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-mini bt-task-remove", "data-confirm": confirming || void 0, @@ -14336,7 +13016,7 @@ diff --git a/client.js b/client.js title: confirming ? void 0 : t("Delete task"), onClick: remove - }, confirming ? t("Delete") : (0, import_react15.createElement)(TrashIcon)) -+ }, confirming ? t("Delete") : (0, import_react24.createElement)(TrashIcon)) ++ }, confirming ? t("Delete") : (0, import_react22.createElement)(TrashIcon)) ); } var pad = (number) => String(number).padStart(2, "0"); @@ -14378,17 +13058,17 @@ diff --git a/client.js b/client.js +} +function TaskForm({ roster, actions, start, task, onDone, onCancel }) { + const first = formStart(task); -+ const [target, setTarget] = (0, import_react24.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); -+ const [title, setTitle] = (0, import_react24.useState)(first.title); -+ const [prompt, setPrompt] = (0, import_react24.useState)(first.prompt); -+ const [kind, setKind] = (0, import_react24.useState)(first.kind); -+ const [at, setAt] = (0, import_react24.useState)(first.at); -+ const [time, setTime] = (0, import_react24.useState)(first.time); -+ const [weekdays, setWeekdays] = (0, import_react24.useState)(first.weekdays); -+ const [count, setCount] = (0, import_react24.useState)(first.count); -+ const [unit, setUnit] = (0, import_react24.useState)(first.unit); -+ const [busy, setBusy] = (0, import_react24.useState)(false); -+ const [error, setError] = (0, import_react24.useState)(""); ++ const [target, setTarget] = (0, import_react22.useState)(task ? task.botId ?? task.roomId : start ?? roster.bots[0]?.id ?? ""); ++ const [title, setTitle] = (0, import_react22.useState)(first.title); ++ const [prompt, setPrompt] = (0, import_react22.useState)(first.prompt); ++ const [kind, setKind] = (0, import_react22.useState)(first.kind); ++ const [at, setAt] = (0, import_react22.useState)(first.at); ++ const [time, setTime] = (0, import_react22.useState)(first.time); ++ const [weekdays, setWeekdays] = (0, import_react22.useState)(first.weekdays); ++ const [count, setCount] = (0, import_react22.useState)(first.count); ++ const [unit, setUnit] = (0, import_react22.useState)(first.unit); ++ const [busy, setBusy] = (0, import_react22.useState)(false); ++ const [error, setError] = (0, import_react22.useState)(""); const timing = () => { - if (kind === "at") return { kind, at: new Date(at).toISOString() }; + if (kind === "cron") return { kind: "keep" }; @@ -14412,60 +13092,60 @@ diff --git a/client.js b/client.js }; const toggleDay = (day) => setWeekdays((days) => days.includes(day) ? days.filter((item) => item !== day) : [...days, day].sort()); - return (0, import_react15.createElement)( -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( "form", { className: "bt-set-card bt-task-form", onSubmit: submit }, - (0, import_react15.createElement)( -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Send to"))), ++ (0, import_react22.createElement)( "select", { className: "bt-select bt-task-bot", value: target, onChange: (event) => setTarget(event.target.value) }, - (0, import_react15.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react15.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), - roster.rooms.length > 0 ? (0, import_react15.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react15.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null -+ (0, import_react24.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react24.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), -+ roster.rooms.length > 0 ? (0, import_react24.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react24.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null ++ (0, import_react22.createElement)("optgroup", { label: t("Bots") }, roster.bots.map((bot) => (0, import_react22.createElement)("option", { key: bot.id, value: bot.id }, bot.name))), ++ roster.rooms.length > 0 ? (0, import_react22.createElement)("optgroup", { label: t("Group chats") }, roster.rooms.map((room2) => (0, import_react22.createElement)("option", { key: room2.id, value: room2.id }, room2.name))) : null ) ), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("Name"))), - (0, import_react15.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Name"))), -+ (0, import_react24.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Name"))), ++ (0, import_react22.createElement)("input", { className: "bt-bs-input", value: title, maxLength: 120, placeholder: t("Morning briefing"), autoFocus: true, onChange: (event) => setTitle(event.target.value) }) ), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), - (0, import_react15.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), -+ (0, import_react24.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("What to do"))), ++ (0, import_react22.createElement)("textarea", { className: "bt-bs-input bt-bs-area", value: prompt, rows: 3, placeholder: t("Sum up what changed since yesterday and what needs me today."), onChange: (event) => setPrompt(event.target.value) }) ), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-bs-label" }, t("When"))), - (0, import_react15.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react15.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("When"))), -+ (0, import_react24.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react24.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("When"))), ++ (0, import_react22.createElement)(Segmented, { label: t("When"), value: kind }, KINDS.map(([id, label]) => (0, import_react22.createElement)("button", { key: id, type: "button", "aria-pressed": kind === id, onClick: () => setKind(id) }, t(label)))), ++ (0, import_react22.createElement)( "div", { key: kind, className: "bt-task-when bt-veil" }, - kind === "at" ? (0, import_react15.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, - kind === "weekly" ? (0, import_react15.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react15.createElement)("button", { -+ kind === "cron" ? (0, import_react24.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, -+ kind === "at" ? (0, import_react24.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, -+ kind === "weekly" ? (0, import_react24.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react24.createElement)("button", { ++ kind === "cron" ? (0, import_react22.createElement)("span", { className: "bt-task-every" }, timingText(task)) : null, ++ kind === "at" ? (0, import_react22.createElement)("input", { className: "bt-input", type: "datetime-local", value: at, "aria-label": t("Date and time"), onChange: (event) => setAt(event.target.value) }) : null, ++ kind === "weekly" ? (0, import_react22.createElement)("div", { className: "bt-task-days", role: "group", "aria-label": t("Days") }, [1, 2, 3, 4, 5, 6, 7].map((day) => (0, import_react22.createElement)("button", { key: day, type: "button", className: "bt-task-day", @@ -14475,34 +13155,34 @@ diff --git a/client.js b/client.js - kind === "daily" || kind === "weekly" ? (0, import_react15.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, - kind === "every" ? (0, import_react15.createElement)( - import_react15.Fragment, -+ kind === "daily" || kind === "weekly" ? (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, -+ kind === "every" ? (0, import_react24.createElement)( -+ import_react24.Fragment, ++ kind === "daily" || kind === "weekly" ? (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: time, "aria-label": t("Time"), onChange: (event) => setTime(event.target.value) }) : null, ++ kind === "every" ? (0, import_react22.createElement)( ++ import_react22.Fragment, null, - (0, import_react15.createElement)("span", { className: "bt-task-every" }, t("Every")), - (0, import_react15.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)("span", { className: "bt-task-every" }, t("Every")), -+ (0, import_react24.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("span", { className: "bt-task-every" }, t("Every")), ++ (0, import_react22.createElement)("input", { className: "bt-input bt-task-count", type: "number", min: 1, value: count, "aria-label": t("How many"), onChange: (event) => setCount(event.target.value) }), ++ (0, import_react22.createElement)( "select", { className: "bt-select bt-task-unit", value: unit, "aria-label": t("Unit"), onChange: (event) => setUnit(event.target.value) }, - UNITS.map(([id, , label]) => (0, import_react15.createElement)("option", { key: id, value: id }, t(label))) -+ UNITS.map(([id, , label]) => (0, import_react24.createElement)("option", { key: id, value: id }, t(label))) ++ UNITS.map(([id, , label]) => (0, import_react22.createElement)("option", { key: id, value: id }, t(label))) ) ) : null ) ), - error ? (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, - (0, import_react15.createElement)( -+ error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, -+ (0, import_react24.createElement)( ++ error ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, ++ (0, import_react22.createElement)( "div", { className: "bt-set-row bt-task-foot" }, - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), - (0, import_react15.createElement)("button", { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, busy ? t("Creating…") : t("Create task")) -+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ (0, import_react22.createElement)( + "button", + { type: "submit", className: "bt-send bt-soft-sm", disabled: busy || !target || title.trim() === "" || prompt.trim() === "" }, + task ? busy ? t("Saving…") : t("Save changes") : busy ? t("Creating…") : t("Create task") @@ -14517,25 +13197,25 @@ diff --git a/client.js b/client.js + actions.selectPanel("plugins"); +} +function AutomationOff({ actions }) { -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( "div", { className: "bt-set-card bt-task-off" }, - (0, import_react15.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react15.createElement)(ClockIcon)), - (0, import_react15.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), - (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings.")), - (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: openHarnessSettings }, t("Open Harness settings")) -+ (0, import_react24.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react24.createElement)(ClockIcon)), -+ (0, import_react24.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), -+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), -+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ++ (0, import_react22.createElement)("span", { className: "bt-task-off-mark" }, (0, import_react22.createElement)(ClockIcon)), ++ (0, import_react22.createElement)("span", { className: "bt-task-off-title" }, t("Turn on Automation tasks in DSH")), ++ (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page.")), ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + ); +} +function QuietHoursCard({ view, actions }) { + const quiet = view.quietHours ?? { on: false }; -+ const [busy, setBusy] = (0, import_react24.useState)(false); -+ const [error, setError] = (0, import_react24.useState)(""); -+ const [from, setFrom] = (0, import_react24.useState)(quiet.from ?? "22:00"); -+ const [to, setTo] = (0, import_react24.useState)(quiet.to ?? "07:00"); ++ const [busy, setBusy] = (0, import_react22.useState)(false); ++ const [error, setError] = (0, import_react22.useState)(""); ++ const [from, setFrom] = (0, import_react22.useState)(quiet.from ?? "22:00"); ++ const [to, setTo] = (0, import_react22.useState)(quiet.to ?? "07:00"); + const unchecked = view.quietUnchecked === true; + const refresh = () => { + void Promise.resolve(actions.schedules?.()).then((next) => { @@ -14553,23 +13233,23 @@ diff --git a/client.js b/client.js + refresh(); + }, (failure) => setError(hostText(failure?.message ?? String(failure)))).finally(() => setBusy(false)); + }; -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( + "section", + { className: "bt-set-section" }, -+ (0, import_react24.createElement)("h3", null, t("Quiet hours")), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("h3", null, t("Quiet hours")), ++ (0, import_react22.createElement)( + "div", + { className: "bt-set-card" }, -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react24.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), -+ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) ++ (0, import_react22.createElement)("span", { className: "bt-bs-label" }, quiet.on ? t("On, {from}–{to}", { from: quiet.from, to: quiet.to }) : t("Off")), ++ (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("A new or edited task cannot be due inside the window. It never mutes a task that already exists.")) + ), -+ (0, import_react24.createElement)("button", { ++ (0, import_react22.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + disabled: busy, @@ -14577,21 +13257,21 @@ diff --git a/client.js b/client.js + onClick: () => save(quiet.on ? { on: false } : { on: true, from, to }) + }, quiet.on ? t("Turn off") : t("Turn on")) + ), -+ quiet.on ? (0, import_react24.createElement)( ++ quiet.on ? (0, import_react22.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Window"))), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Window"))), ++ (0, import_react22.createElement)( + "div", + { className: "bt-task-when" }, -+ (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), -+ (0, import_react24.createElement)("span", { className: "bt-task-every" }, "–"), -+ (0, import_react24.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), -+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) ++ (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: from, "aria-label": t("Quiet hours start"), disabled: busy, onChange: (event) => setFrom(event.target.value) }), ++ (0, import_react22.createElement)("span", { className: "bt-task-every" }, "–"), ++ (0, import_react22.createElement)("input", { className: "bt-input bt-task-time", type: "time", value: to, "aria-label": t("Quiet hours end"), disabled: busy, onChange: (event) => setTo(event.target.value) }), ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => save({ on: true, from, to }) }, t("Save window")) + ) + ) : null, -+ error ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, -+ quiet.on && unchecked ? (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null ++ error ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint" }, error)) : null, ++ quiet.on && unchecked ? (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("A repeat or cron task can still run inside quiet hours, because DSH decides its next run. FlyBot says so here rather than claiming a silence it cannot keep."))) : null + ) ); } @@ -14600,10 +13280,10 @@ diff --git a/client.js b/client.js - const [adding, setAdding] = (0, import_react15.useState)(Boolean(start)); - if (view === null) return (0, import_react15.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); - if (!view.available) return (0, import_react15.createElement)(AutomationOff); -+ const [adding, setAdding] = (0, import_react24.useState)(Boolean(start)); -+ const [editing, setEditing] = (0, import_react24.useState)(null); -+ if (view === null) return (0, import_react24.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); -+ if (!view.available) return (0, import_react24.createElement)(AutomationOff, { actions }); ++ const [adding, setAdding] = (0, import_react22.useState)(Boolean(start)); ++ const [editing, setEditing] = (0, import_react22.useState)(null); ++ if (view === null) return (0, import_react22.createElement)("div", { className: "bt-set-hint" }, t("Loading…")); ++ if (!view.available) return (0, import_react22.createElement)(AutomationOff, { actions }); const shown = (next) => { publish(next); setAdding(false); @@ -14616,41 +13296,41 @@ diff --git a/client.js b/client.js const byChat = [ - ...roster.bots.map((bot) => [bot.id, (0, import_react15.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), - ...roster.rooms.map((room2) => [room2.id, (0, import_react15.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) -+ ...roster.bots.map((bot) => [bot.id, (0, import_react24.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), -+ ...roster.rooms.map((room2) => [room2.id, (0, import_react24.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ++ ...roster.bots.map((bot) => [bot.id, (0, import_react22.createElement)(BotAvatar, { bot, size: 18, badge: false, live: false }), bot.name, view.tasks.filter((task) => task.botId === bot.id)]), ++ ...roster.rooms.map((room2) => [room2.id, (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 18 }), room2.name, view.tasks.filter((task) => task.botId === void 0 && task.roomId === room2.id)]) ].filter(([, , , tasks]) => tasks.length > 0); return [ - adding ? (0, import_react15.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react15.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setAdding(true) }, (0, import_react15.createElement)(PlusIcon), t("New task"))), - byChat.length === 0 && !adding ? (0, import_react15.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react15.createElement)("div", { className: "bt-set-row" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react15.createElement)( -+ (0, import_react24.createElement)(QuietHoursCard, { key: "quiet", view, actions }), -+ adding ? (0, import_react24.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react24.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { ++ (0, import_react22.createElement)(QuietHoursCard, { key: "quiet", view, actions }), ++ adding ? (0, import_react22.createElement)(TaskForm, { key: "form", roster, actions, start, onDone: shown, onCancel: () => setAdding(false) }) : (0, import_react22.createElement)("div", { key: "add", className: "bt-actions" }, (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => { + setEditing(null); + setAdding(true); -+ } }, (0, import_react24.createElement)(PlusIcon), t("New task"))), -+ byChat.length === 0 && !adding ? (0, import_react24.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react24.createElement)("div", { className: "bt-set-row" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react24.createElement)( ++ } }, (0, import_react22.createElement)(PlusIcon), t("New task"))), ++ byChat.length === 0 && !adding ? (0, import_react22.createElement)("div", { key: "none", className: "bt-set-card" }, (0, import_react22.createElement)("div", { className: "bt-set-row" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("No scheduled tasks yet.")))) : byChat.map(([id, avatar, name, tasks]) => (0, import_react22.createElement)( "section", { key: id, className: "bt-set-section" }, - (0, import_react15.createElement)("h3", { className: "bt-task-head" }, avatar, name), - (0, import_react15.createElement)("div", { className: "bt-set-card" }, tasks.map((task) => (0, import_react15.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish }))) -+ (0, import_react24.createElement)("h3", { className: "bt-task-head" }, avatar, name), -+ (0, import_react24.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) ++ (0, import_react22.createElement)("h3", { className: "bt-task-head" }, avatar, name), ++ (0, import_react22.createElement)(TaskList, { tasks, editing, roster, actions, onEdit: edit, onDone: shown, onCancel: () => setEditing(null) }) )) ]; } +function TaskList({ tasks, editing, roster, actions, onEdit, onDone, onCancel, extra = [] }) { + const open = editing ? tasks.find((task) => task.id === editing.id && task.sessionId === editing.sessionId) : void 0; -+ const [copying, setCopying] = (0, import_react24.useState)(null); ++ const [copying, setCopying] = (0, import_react22.useState)(null); + const source = copying ? tasks.find((task) => task.id === copying.id && task.sessionId === copying.sessionId) : void 0; -+ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react24.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); -+ return (0, import_react24.createElement)( -+ import_react24.Fragment, ++ const rows = tasks.filter((task) => task !== open && task !== source).map((task) => (0, import_react22.createElement)(TaskRow, { key: task.id, task, actions, roster, onView: publish, onEdit, onCopy: actions.scheduleCreate ? setCopying : void 0 })); ++ return (0, import_react22.createElement)( ++ import_react22.Fragment, + null, -+ open ? (0, import_react24.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, -+ source ? (0, import_react24.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { ++ open ? (0, import_react22.createElement)(TaskForm, { key: `edit-${open.id}`, roster, actions, task: open, onDone, onCancel }) : null, ++ source ? (0, import_react22.createElement)(CopyRow, { key: `copy-${source.id}`, task: source, roster, actions, onView: (next) => { + setCopying(null); + onDone(next); + }, onCancel: () => setCopying(null) }) : null, -+ rows.length + extra.length > 0 ? (0, import_react24.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null ++ rows.length + extra.length > 0 ? (0, import_react22.createElement)("div", { className: "bt-set-card" }, ...rows, ...extra) : null + ); +} +function CopyRow({ task, roster, actions, onView, onCancel }) { @@ -14658,9 +13338,9 @@ diff --git a/client.js b/client.js + ...roster.bots.filter((bot) => bot.id !== task.botId).map((bot) => [bot.id, bot.name]), + ...roster.rooms.filter((room2) => room2.id !== task.roomId).map((room2) => [room2.id, room2.name]) + ]; -+ const [target, setTarget] = (0, import_react24.useState)(""); -+ const [busy, setBusy] = (0, import_react24.useState)(false); -+ const [error, setError] = (0, import_react24.useState)(""); ++ const [target, setTarget] = (0, import_react22.useState)(""); ++ const [busy, setBusy] = (0, import_react22.useState)(false); ++ const [error, setError] = (0, import_react22.useState)(""); + const submit = (event) => { + event.preventDefault(); + if (busy || target === "") return; @@ -14671,38 +13351,38 @@ diff --git a/client.js b/client.js + setError(hostText(failure?.message ?? String(failure))); + }); + }; -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( + "form", + { className: "bt-set-row bt-task-copy", onSubmit: submit }, -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-bs-label" }, t("Copy “{name}” to", { name: task.title }))), ++ (0, import_react22.createElement)( + "select", + { className: "bt-select bt-task-bot", value: target, "aria-label": t("Copy to"), onChange: (event) => setTarget(event.target.value) }, -+ (0, import_react24.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), -+ others.map(([id, name]) => (0, import_react24.createElement)("option", { key: id, value: id }, name)) ++ (0, import_react22.createElement)("option", { value: "" }, t("Pick a Bot or group chat")), ++ others.map(([id, name]) => (0, import_react22.createElement)("option", { key: id, value: id }, name)) + ), -+ (0, import_react24.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), -+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ error ? (0, import_react24.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null ++ (0, import_react22.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm", disabled: busy || target === "" }, busy ? t("Copying…") : t("Copy")), ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ error ? (0, import_react22.createElement)("span", { className: "bt-danger bt-set-hint", role: "alert" }, error) : null + ); +} function BotSchedules({ bot, roster, actions }) { const view = useSchedules(actions); -+ const [editing, setEditing] = (0, import_react24.useState)(null); ++ const [editing, setEditing] = (0, import_react22.useState)(null); if (view === null) return null; const tasks = view.available ? view.tasks.filter((task) => task.botId === bot.id) : []; - return (0, import_react15.createElement)( -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( "section", { className: "bt-set-section" }, - (0, import_react15.createElement)("h3", null, t("Scheduled tasks")), - (0, import_react15.createElement)( -+ (0, import_react24.createElement)("h3", null, t("Scheduled tasks")), -+ !view.available ? (0, import_react24.createElement)( ++ (0, import_react22.createElement)("h3", null, t("Scheduled tasks")), ++ !view.available ? (0, import_react22.createElement)( "div", { className: "bt-set-card" }, - !view.available ? (0, import_react15.createElement)( -+ (0, import_react24.createElement)( ++ (0, import_react22.createElement)( "div", { className: "bt-set-row bt-bs-row" }, - (0, import_react15.createElement)("span", { className: "bt-set-copy" }, (0, import_react15.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page in Harness settings."))), @@ -14712,10 +13392,10 @@ diff --git a/client.js b/client.js - (0, import_react15.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react15.createElement)(PlusIcon), t("New task")) - ] - ) -+ (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), -+ (0, import_react24.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) ++ (0, import_react22.createElement)("span", { className: "bt-set-copy" }, (0, import_react22.createElement)("span", { className: "bt-set-hint" }, t("Switch on Automation tasks on the Plugins page."))), ++ (0, import_react22.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openPlugins(actions) }, t("Open Plugins")) + ) -+ ) : (0, import_react24.createElement)(TaskList, { ++ ) : (0, import_react22.createElement)(TaskList, { + tasks, + editing, + roster, @@ -14726,7 +13406,7 @@ diff --git a/client.js b/client.js + setEditing(null); + }, + onCancel: () => setEditing(null), -+ extra: [(0, import_react24.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react24.createElement)(PlusIcon), t("New task"))] ++ extra: [(0, import_react22.createElement)("button", { key: "new", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => actions.openSettings("schedules", bot.id) }, (0, import_react22.createElement)(PlusIcon), t("New task"))] + }) ); } @@ -14734,21 +13414,21 @@ diff --git a/client.js b/client.js const view = useSchedules(actions); const tasks = view?.available ? view.tasks.filter((task) => task.botId === bot.id && task.status === "active") : []; - return (0, import_react15.createElement)( -+ return (0, import_react24.createElement)( ++ return (0, import_react22.createElement)( "div", null, - (0, import_react15.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), - tasks.length === 0 ? (0, import_react15.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page in Harness settings.") : t("No scheduled tasks yet. Add one")) : (0, import_react15.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react15.createElement)( -+ (0, import_react24.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), -+ tasks.length === 0 ? (0, import_react24.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react24.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react24.createElement)( ++ (0, import_react22.createElement)("div", { className: "bt-section-title" }, t("Scheduled tasks")), ++ tasks.length === 0 ? (0, import_react22.createElement)("button", { type: "button", className: "bt-more", onClick: () => actions.openSettings("schedules", bot.id) }, view?.available === false ? t("Switch on Automation tasks on the Plugins page.") : t("No scheduled tasks yet. Add one")) : (0, import_react22.createElement)("div", { className: "bt-task-list" }, tasks.slice(0, 3).map((task) => (0, import_react22.createElement)( "button", { key: task.id, type: "button", className: "bt-task-line", onClick: () => actions.openSettings("schedules") }, - (0, import_react15.createElement)(ClockIcon), - (0, import_react15.createElement)("span", { className: "bt-task-line-title" }, task.title), - (0, import_react15.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) -+ (0, import_react24.createElement)(ClockIcon), -+ (0, import_react24.createElement)("span", { className: "bt-task-line-title" }, task.title), -+ (0, import_react24.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) ++ (0, import_react22.createElement)(ClockIcon), ++ (0, import_react22.createElement)("span", { className: "bt-task-line-title" }, task.title), ++ (0, import_react22.createElement)("span", { className: "bt-task-line-when" }, timingText(task)) ))) ); } @@ -14777,7 +13457,7 @@ diff --git a/client.js b/client.js // src/client/capability-settings.js -var import_react16 = require("react"); -+var import_react25 = require("react"); ++var import_react23 = require("react"); var blank = () => ({ name: "", kind: "mcp", description: "", transport: "stdio", command: "", argsText: "", envText: "{}", url: "", headersText: "{}", content: "", items: [] }); var parseMap = (text, label) => { let value; @@ -14786,13 +13466,13 @@ diff --git a/client.js b/client.js var statusText = (status) => !status ? void 0 : status.state === "ready" ? t("Ready") : status.state === "needs-approval" ? t("Needs approval") : status.state === "error" ? t("Could not connect: {error}", { error: status.error ?? "" }) : t("Connecting…"); function Field({ label, value, onChange, multiline = false, disabled = false, placeholder }) { - return (0, import_react16.createElement)( -+ return (0, import_react25.createElement)( ++ return (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t(label))), - (0, import_react16.createElement)(multiline ? "textarea" : "input", { -+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t(label))), -+ (0, import_react25.createElement)(multiline ? "textarea" : "input", { ++ (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t(label))), ++ (0, import_react23.createElement)(multiline ? "textarea" : "input", { className: multiline ? "bt-input bt-input-area" : "bt-input", value, disabled, @@ -14801,7 +13481,7 @@ diff --git a/client.js b/client.js } function CapabilityForm({ draft, setDraft, items, actions, onClose }) { - const [error, setError] = (0, import_react16.useState)(""); -+ const [error, setError] = (0, import_react25.useState)(""); ++ const [error, setError] = (0, import_react23.useState)(""); const set = (key, value) => setDraft((previous) => ({ ...previous, [key]: value })); const submit = async (event) => { event.preventDefault(); @@ -14819,89 +13499,89 @@ diff --git a/client.js b/client.js }; const available = items.filter((item) => item.name !== draft.name && ["mcp", "skill"].includes(item.kind)); - return (0, import_react16.createElement)( -+ return (0, import_react25.createElement)( ++ return (0, import_react23.createElement)( "form", { className: "bt-connector-form", onSubmit: submit }, - (0, import_react16.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), - (0, import_react16.createElement)( -+ (0, import_react25.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)(Field, { label: "Name", value: draft.name, disabled: draft.originalName !== void 0, onChange: (value) => set("name", value), placeholder: "my-tool" }), ++ (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Type"))), - (0, import_react16.createElement)( -+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Type"))), -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Type"))), ++ (0, import_react23.createElement)( "select", { className: "bt-input", value: draft.kind, disabled: draft.originalName !== void 0, onChange: (event) => set("kind", event.target.value) }, - ["mcp", "skill", "pack"].map((kind) => (0, import_react16.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) -+ ["mcp", "skill", "pack"].map((kind) => (0, import_react25.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) ++ ["mcp", "skill", "pack"].map((kind) => (0, import_react23.createElement)("option", { key: kind, value: kind }, t(kind === "mcp" ? "MCP server" : kind === "skill" ? "Skill" : "Pack"))) ) ), - (0, import_react16.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), - draft.kind === "mcp" ? (0, import_react16.createElement)( -+ (0, import_react25.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), -+ draft.kind === "mcp" ? (0, import_react25.createElement)( ++ (0, import_react23.createElement)(Field, { label: "Description", value: draft.description, onChange: (value) => set("description", value) }), ++ draft.kind === "mcp" ? (0, import_react23.createElement)( "label", { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), - (0, import_react16.createElement)( -+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, t("Connection"))), ++ (0, import_react23.createElement)( "select", { className: "bt-input", value: draft.transport, onChange: (event) => set("transport", event.target.value) }, - (0, import_react16.createElement)("option", { value: "stdio" }, t("Local command")), - (0, import_react16.createElement)("option", { value: "streamable-http" }, t("HTTP server")) -+ (0, import_react25.createElement)("option", { value: "stdio" }, t("Local command")), -+ (0, import_react25.createElement)("option", { value: "streamable-http" }, t("HTTP server")) ++ (0, import_react23.createElement)("option", { value: "stdio" }, t("Local command")), ++ (0, import_react23.createElement)("option", { value: "streamable-http" }, t("HTTP server")) ) ) : null, - draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react16.createElement)( -+ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react25.createElement)( ++ draft.kind === "mcp" && draft.transport === "stdio" ? (0, import_react23.createElement)( "div", null, - (0, import_react16.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), - (0, import_react16.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), - (0, import_react16.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) -+ (0, import_react25.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), -+ (0, import_react25.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), -+ (0, import_react25.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ++ (0, import_react23.createElement)(Field, { label: "Command", value: draft.command, onChange: (value) => set("command", value) }), ++ (0, import_react23.createElement)(Field, { label: "Arguments (one per line)", value: draft.argsText, onChange: (value) => set("argsText", value), multiline: true }), ++ (0, import_react23.createElement)(Field, { label: "Environment (JSON)", value: draft.envText, onChange: (value) => set("envText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ) : null, - draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react16.createElement)( -+ draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react25.createElement)( ++ draft.kind === "mcp" && draft.transport === "streamable-http" ? (0, import_react23.createElement)( "div", null, - (0, import_react16.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), - (0, import_react16.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) -+ (0, import_react25.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), -+ (0, import_react25.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ++ (0, import_react23.createElement)(Field, { label: "URL", value: draft.url, onChange: (value) => set("url", value) }), ++ (0, import_react23.createElement)(Field, { label: "Headers (JSON)", value: draft.headersText, onChange: (value) => set("headersText", value), multiline: true, placeholder: t('{"API_KEY": "$DSH_SECRET_MY_KEY"}') }) ) : null, - draft.kind === "skill" ? (0, import_react16.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, - draft.kind === "pack" ? (0, import_react16.createElement)( -+ draft.kind === "skill" ? (0, import_react25.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, -+ draft.kind === "pack" ? (0, import_react25.createElement)( ++ draft.kind === "skill" ? (0, import_react23.createElement)(Field, { label: "Skill instructions", value: draft.content, onChange: (value) => set("content", value), multiline: true }) : null, ++ draft.kind === "pack" ? (0, import_react23.createElement)( "div", { className: "bt-set-card" }, - available.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react16.createElement)( -+ available.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react25.createElement)( ++ available.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("Save an MCP server or skill first."))) : available.map((item) => (0, import_react23.createElement)( "label", { key: item.name, className: "bt-set-row bt-bs-row" }, - (0, import_react16.createElement)("span", { className: "bt-set-copy" }, (0, import_react16.createElement)("span", { className: "bt-bs-label" }, item.name)), - (0, import_react16.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) -+ (0, import_react25.createElement)("span", { className: "bt-set-copy" }, (0, import_react25.createElement)("span", { className: "bt-bs-label" }, item.name)), -+ (0, import_react25.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) ++ (0, import_react23.createElement)("span", { className: "bt-set-copy" }, (0, import_react23.createElement)("span", { className: "bt-bs-label" }, item.name)), ++ (0, import_react23.createElement)("input", { type: "checkbox", checked: draft.items.includes(item.name), onChange: (event) => set("items", event.target.checked ? [...draft.items, item.name] : draft.items.filter((name) => name !== item.name)) }) )) ) : null, - error ? (0, import_react16.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, - (0, import_react16.createElement)( -+ error ? (0, import_react25.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, -+ (0, import_react25.createElement)( ++ error ? (0, import_react23.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null, ++ (0, import_react23.createElement)( "div", { className: "bt-connector-form-row" }, - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), - (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) -+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), -+ (0, import_react25.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) ++ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onClose }, t("Cancel")), ++ (0, import_react23.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, draft.originalName ? t("Save changes") : t("Add capability")) ) ); } @@ -14909,32 +13589,32 @@ diff --git a/client.js b/client.js const detail = item.kind === "pack" ? `${t("Pack")}: ${(item.items ?? []).join(", ") || t("No items")}` : item.kind === "mcp" ? item.transport === "stdio" ? t("Local command") : t("HTTP server") : t("Skill"); const secondary = item.kind === "pack" ? [item.description, (item.items ?? []).join(", ") || t("No items")].filter(Boolean).join(" · ") : item.description || detail; - return (0, import_react16.createElement)( -+ return (0, import_react25.createElement)( ++ return (0, import_react23.createElement)( "div", { className: "bt-connector" }, - (0, import_react16.createElement)( -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)( "div", { className: "bt-connector-head" }, - (0, import_react16.createElement)( -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)( "span", { className: "bt-connector-text" }, - (0, import_react16.createElement)("span", { className: "bt-connector-name" }, item.name), - (0, import_react16.createElement)("span", { className: "bt-connector-status" }, secondary) -+ (0, import_react25.createElement)("span", { className: "bt-connector-name" }, item.name), -+ (0, import_react25.createElement)("span", { className: "bt-connector-status" }, secondary) ++ (0, import_react23.createElement)("span", { className: "bt-connector-name" }, item.name), ++ (0, import_react23.createElement)("span", { className: "bt-connector-status" }, secondary) ), - (0, import_react16.createElement)( -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)( "span", { className: "bt-connector-actions" }, - item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) -+ item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, -+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), -+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) ++ item.kind === "mcp" && item.transport === "stdio" && !item.approved ? (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onApprove }, t("Allow")) : null, ++ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onEdit }, t("Edit")), ++ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDelete }, confirming ? t("Click again to delete.") : t("Delete")) ) ) ); @@ -14944,9 +13624,9 @@ diff --git a/client.js b/client.js - const [draft, setDraft] = (0, import_react16.useState)(null); - const [confirming, setConfirming] = (0, import_react16.useState)(""); - (0, import_react16.useEffect)(() => { -+ const [draft, setDraft] = (0, import_react25.useState)(null); -+ const [confirming, setConfirming] = (0, import_react25.useState)(""); -+ (0, import_react25.useEffect)(() => { ++ const [draft, setDraft] = (0, import_react23.useState)(null); ++ const [confirming, setConfirming] = (0, import_react23.useState)(""); ++ (0, import_react23.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(""), 4e3); return () => clearTimeout(timer); @@ -14955,48 +13635,49 @@ diff --git a/client.js b/client.js void run(actions.deleteCapability(item.name)); }; - return (0, import_react16.createElement)( -+ return (0, import_react25.createElement)( ++ return (0, import_react23.createElement)( "section", { className: "bt-set-section" }, - (0, import_react16.createElement)("h3", null, t("Shared capabilities")), - (0, import_react16.createElement)( -+ (0, import_react25.createElement)("h3", null, t("Shared capabilities")), -+ (0, import_react25.createElement)( ++ (0, import_react23.createElement)("h3", null, t("Shared capabilities")), ++ (0, import_react23.createElement)( "div", { className: "bt-actions" }, - (0, import_react16.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) -+ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) ++ (0, import_react23.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: startNew }, t("Add capability")) ), - draft ? (0, import_react16.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, - (0, import_react16.createElement)( -+ draft ? (0, import_react25.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, -+ (0, import_react25.createElement)( ++ draft ? (0, import_react23.createElement)(CapabilityForm, { draft, setDraft, items, actions, onClose: () => setDraft(null) }) : null, ++ (0, import_react23.createElement)( "div", { className: "bt-set-card bt-connectors-card" }, - items.length === 0 ? (0, import_react16.createElement)("div", { className: "bt-set-row" }, (0, import_react16.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react16.createElement)(CapabilityRow, { -+ items.length === 0 ? (0, import_react25.createElement)("div", { className: "bt-set-row" }, (0, import_react25.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react25.createElement)(CapabilityRow, { ++ items.length === 0 ? (0, import_react23.createElement)("div", { className: "bt-set-row" }, (0, import_react23.createElement)("span", { className: "bt-set-hint" }, t("No capabilities saved"))) : items.map((item) => (0, import_react23.createElement)(CapabilityRow, { key: item.name, item, confirming: confirming === item.name, -@@ -7359,10 +7359,10 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow +@@ -7359,22 +7359,68 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow const attached = new Set(bot.capabilities ?? []); const statuses = roster.capabilityStatuses?.[bot.id] ?? {}; if (items.length === 0) return null; - return (0, import_react16.createElement)( -+ return (0, import_react25.createElement)( - Card2, - { title: t("Capabilities") }, -- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { -+ items.map((item) => (0, import_react25.createElement)(SwitchRow2, { - key: item.name, - label: item.name, - hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, -@@ -7372,9 +7372,55 @@ function BotCapabilitiesCard({ bot, roster, actions, run, Card: Card2, SwitchRow - ); - } - ++ return (0, import_react23.createElement)( ++ Card2, ++ { title: t("Capabilities") }, ++ items.map((item) => (0, import_react23.createElement)(SwitchRow2, { ++ key: item.name, ++ label: item.name, ++ hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, ++ on: attached.has(item.name), ++ onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled)) ++ })) ++ ); ++} ++ +// src/client/connector-bots.js -+var import_react26 = require("react"); ++var import_react24 = require("react"); +function ownBots(roster) { + const me = roster?.self?.memberId; + return (roster?.bots ?? []).filter((bot) => !bot.remote && (me === void 0 || me === null || bot.ownerId === void 0 || bot.ownerId === me)); @@ -15012,40 +13693,48 @@ diff --git a/client.js b/client.js + return own === 0 ? t("Off for every Bot. Choose which Bots may use it.") : own === bots.length ? t("On for all {total} Bots", { total: bots.length }) : t("On for {count} of {total} Bots", { count: own, total: bots.length }); +} +function BotSwitch({ bot, connector, name, actions, run, SwitchRow: SwitchRow2 }) { -+ return (0, import_react26.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) }); ++ return (0, import_react24.createElement)(SwitchRow2, { label: name, hint: ownHint(bot, connector.id), on: isOn(bot, connector.id), disabled: false, onChange: (on) => void run(actions.setBotConnector(bot.id, connector.id, on)) }); +} +function ConnectorBots({ connector, roster, actions, run, SwitchRow: SwitchRow2 }) { + const bots = ownBots(roster); -+ return (0, import_react26.createElement)( ++ return (0, import_react24.createElement)( + "div", + { className: "bt-connector-bots" }, -+ bots.length === 0 ? (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react26.createElement)( ++ bots.length === 0 ? (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("No Bots")) : bots.map((bot) => (0, import_react24.createElement)( + "div", + { key: bot.id, className: "bt-connector-bot" }, -+ (0, import_react26.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 }) ++ (0, import_react24.createElement)(BotSwitch, { bot, connector, name: bot.name, actions, run, SwitchRow: SwitchRow2 }) + )), -+ (0, import_react26.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.")) ++ (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("On by default for each of your Bots, on every turn: in its own chat, in groups, on schedules and when a friend uses it. A friend's own Bot never gets it.")) + ); +} +function BotConnectorsCard({ bot, roster, actions, run, Card: Card2, SwitchRow: SwitchRow2 }) { + const connected = connectedConnectors(roster); + if (connected.length === 0) return null; -+ return (0, import_react26.createElement)( -+ Card2, ++ return (0, import_react24.createElement)( + Card2, +- { title: t("Capabilities") }, +- items.map((item) => (0, import_react16.createElement)(SwitchRow2, { +- key: item.name, +- label: item.name, +- hint: attached.has(item.name) ? statusText(statuses[item.name]) : void 0, +- on: attached.has(item.name), +- onChange: (enabled) => void run(actions.setBotCapability(bot.id, item.name, enabled)) +- })) + { title: t("Connectors") }, -+ connected.map((connector) => (0, import_react26.createElement)( ++ connected.map((connector) => (0, import_react24.createElement)( + "div", + { key: connector.id, className: "bt-connector-bot" }, -+ (0, import_react26.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 }) ++ (0, import_react24.createElement)(BotSwitch, { bot, connector, name: connector.name, actions, run, SwitchRow: SwitchRow2 }) + )) -+ ); -+} -+ + ); + } + // src/client/memory-panel.js -var import_react17 = require("react"); -var POLL_MS2 = 1500; -+var import_react27 = require("react"); -+var POLL_MS6 = 1500; ++var import_react25 = require("react"); ++var POLL_MS5 = 1500; var MENU_EXIT_MS2 = 120; var counts = /* @__PURE__ */ new Map(); var countListeners = /* @__PURE__ */ new Set(); @@ -15055,8 +13744,8 @@ diff --git a/client.js b/client.js function useMemoryCount(botId, actions) { - const [count, setCount] = (0, import_react17.useState)(counts.get(botId) ?? null); - (0, import_react17.useEffect)(() => { -+ const [count, setCount] = (0, import_react27.useState)(counts.get(botId) ?? null); -+ (0, import_react27.useEffect)(() => { ++ const [count, setCount] = (0, import_react25.useState)(counts.get(botId) ?? null); ++ (0, import_react25.useEffect)(() => { const listener = () => setCount(counts.get(botId) ?? null); countListeners.add(listener); listener(); @@ -15071,21 +13760,21 @@ diff --git a/client.js b/client.js 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_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "div", null, - (0, import_react17.createElement)("div", { className: "bt-section-title" }, t("Memory")), - (0, import_react17.createElement)( -+ (0, import_react27.createElement)("div", { className: "bt-section-title" }, t("Memory")), -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)("div", { className: "bt-section-title" }, t("Memory")), ++ (0, import_react25.createElement)( "button", { type: "button", className: "bt-mem-row", "aria-label": t("Manage {name}'s memory", { name: bot.name }), onClick: () => actions.openMemory(bot.id) }, - (0, import_react17.createElement)(MemoryIcon), - (0, import_react17.createElement)("span", { className: "bt-mem-row-copy" }, label), - (0, import_react17.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react17.createElement)(ChevronRightIcon)) -+ (0, import_react27.createElement)(MemoryIcon), -+ (0, import_react27.createElement)("span", { className: "bt-mem-row-copy" }, label), -+ (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon)) ++ (0, import_react25.createElement)(MemoryIcon), ++ (0, import_react25.createElement)("span", { className: "bt-mem-row-copy" }, label), ++ (0, import_react25.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react25.createElement)(ChevronRightIcon)) ) ); } @@ -15094,63 +13783,63 @@ diff --git a/client.js b/client.js } function Skeleton({ label }) { - return (0, import_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "div", { className: "bt-mem-skel", "aria-busy": true }, - [0, 1].map((block) => (0, import_react17.createElement)( -+ [0, 1].map((block) => (0, import_react27.createElement)( ++ [0, 1].map((block) => (0, import_react25.createElement)( "div", { key: block, className: "bt-mem-skel-block" }, - (0, import_react17.createElement)("span", { className: "bt-mem-skel-head" }), - (0, import_react17.createElement)("span", null), - (0, import_react17.createElement)("span", null), - (0, import_react17.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) -+ (0, import_react27.createElement)("span", { className: "bt-mem-skel-head" }), -+ (0, import_react27.createElement)("span", null), -+ (0, import_react27.createElement)("span", null), -+ (0, import_react27.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) ++ (0, import_react25.createElement)("span", { className: "bt-mem-skel-head" }), ++ (0, import_react25.createElement)("span", null), ++ (0, import_react25.createElement)("span", null), ++ (0, import_react25.createElement)("span", { style: { width: block === 0 ? "62%" : "48%" } }) )), - label ? (0, import_react17.createElement)("div", { className: "bt-mem-skel-label" }, label) : null -+ label ? (0, import_react27.createElement)("div", { className: "bt-mem-skel-label" }, label) : null ++ label ? (0, import_react25.createElement)("div", { className: "bt-mem-skel-label" }, label) : null ); } function Summary({ bot, view, regenerate }) { if (view.entries.length === 0) { - return (0, import_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "div", { className: "bt-mem-empty" }, - (0, import_react17.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react17.createElement)(MemoryIcon)), - (0, import_react17.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), - (0, import_react17.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name })) -+ (0, import_react27.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react27.createElement)(MemoryIcon)), -+ (0, import_react27.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), -+ (0, import_react27.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name })) ++ (0, import_react25.createElement)("div", { className: "bt-mem-empty-mark" }, (0, import_react25.createElement)(MemoryIcon)), ++ (0, import_react25.createElement)("div", { className: "bt-mem-empty-title" }, t("{name} has not saved anything yet", { name: bot.name })), ++ (0, import_react25.createElement)("div", { className: "bt-mem-empty-text" }, t("Bots keep lasting preferences, decisions, and what you ask them to remember. Tell {name} below, or in its chat.", { name: bot.name })) ); } - const failed = view.error && !view.summarizing ? (0, import_react17.createElement)( -+ const failed = view.error && !view.summarizing ? (0, import_react27.createElement)( ++ const failed = view.error && !view.summarizing ? (0, import_react25.createElement)( "div", { className: "bt-mem-failed", role: "alert" }, - (0, import_react17.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })), - (0, import_react17.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) -+ (0, import_react27.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })), -+ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) ++ (0, import_react25.createElement)("span", null, view.summary ? t("The summary is out of date: {reason}", { reason: hostText(view.error) }) : t("The summary could not be written: {reason}", { reason: hostText(view.error) })), ++ (0, import_react25.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: regenerate }, t("Try again")) ) : null; - if (!view.summary) return (0, import_react17.createElement)(import_react17.Fragment, null, failed, failed ? null : (0, import_react17.createElement)(Skeleton, { label: t("Writing the summary…") })); - return (0, import_react17.createElement)( - import_react17.Fragment, -+ if (!view.summary) return (0, import_react27.createElement)(import_react27.Fragment, null, failed, failed ? null : (0, import_react27.createElement)(Skeleton, { label: t("Writing the summary…") })); -+ return (0, import_react27.createElement)( -+ import_react27.Fragment, ++ if (!view.summary) return (0, import_react25.createElement)(import_react25.Fragment, null, failed, failed ? null : (0, import_react25.createElement)(Skeleton, { label: t("Writing the summary…") })); ++ return (0, import_react25.createElement)( ++ import_react25.Fragment, null, failed, - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)( "div", { key: view.summary.at, className: "bt-mem-summary", "data-stale": !view.fresh || void 0 }, - summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react17.createElement)("h3", { key: index }, block.text) : (0, import_react17.createElement)("p", { key: index }, block.text)) -+ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react27.createElement)("h3", { key: index }, block.text) : (0, import_react27.createElement)("p", { key: index }, block.text)) ++ summaryBlocks(view.summary.text).map((block, index) => block.kind === "h" ? (0, import_react25.createElement)("h3", { key: index }, block.text) : (0, import_react25.createElement)("p", { key: index }, block.text)) ) ); } @@ -15161,9 +13850,9 @@ diff --git a/client.js b/client.js - const [confirming, setConfirming] = (0, import_react17.useState)(false); - const [busy, setBusy] = (0, import_react17.useState)(false); - (0, import_react17.useEffect)(() => { -+ const [confirming, setConfirming] = (0, import_react27.useState)(false); -+ const [busy, setBusy] = (0, import_react27.useState)(false); -+ (0, import_react27.useEffect)(() => { ++ const [confirming, setConfirming] = (0, import_react25.useState)(false); ++ const [busy, setBusy] = (0, import_react25.useState)(false); ++ (0, import_react25.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 3e3); return () => clearTimeout(timer); @@ -15172,20 +13861,20 @@ diff --git a/client.js b/client.js if (!await remove(entry)) setBusy(false); }; - return (0, import_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "li", { className: "bt-mem-entry", "data-busy": busy || void 0 }, - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)( "div", { className: "bt-mem-entry-copy" }, - (0, import_react17.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), - meta ? (0, import_react17.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null -+ (0, import_react27.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), -+ meta ? (0, import_react27.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null ++ (0, import_react25.createElement)("span", { className: "bt-mem-entry-text" }, entry.text), ++ meta ? (0, import_react25.createElement)("span", { className: "bt-mem-entry-meta" }, meta) : null ), - (0, import_react17.createElement)("button", { -+ (0, import_react27.createElement)("button", { ++ (0, import_react25.createElement)("button", { type: "button", className: "bt-mem-remove", "data-confirm": confirming || void 0, @@ -15194,7 +13883,7 @@ diff --git a/client.js b/client.js title: confirming ? void 0 : t("Remove from memory"), onClick: click - }, confirming ? t("Remove") : (0, import_react17.createElement)(TrashIcon)) -+ }, confirming ? t("Remove") : (0, import_react27.createElement)(TrashIcon)) ++ }, confirming ? t("Remove") : (0, import_react25.createElement)(TrashIcon)) ); } function Entries({ bot, view, remove }) { @@ -15203,35 +13892,35 @@ diff --git a/client.js b/client.js { scope: "team", title: t("Team memory"), hint: t("Every Bot reads it"), size: view.sizes.team } ]; - return (0, import_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "div", { className: "bt-mem-entries" }, - sections.map((section) => (0, import_react17.createElement)( -+ sections.map((section) => (0, import_react27.createElement)( ++ sections.map((section) => (0, import_react25.createElement)( "section", { key: section.scope, className: "bt-mem-section" }, - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)( "div", { className: "bt-mem-section-head" }, - (0, import_react17.createElement)("h3", null, section.title), - section.hint ? (0, import_react17.createElement)("span", null, section.hint) : null, - (0, import_react17.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) })) -+ (0, import_react27.createElement)("h3", null, section.title), -+ section.hint ? (0, import_react27.createElement)("span", null, section.hint) : null, -+ (0, import_react27.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) })) ++ (0, import_react25.createElement)("h3", null, section.title), ++ section.hint ? (0, import_react25.createElement)("span", null, section.hint) : null, ++ (0, import_react25.createElement)("span", { className: "bt-mem-size", title: t("MEMORY.md, which every conversation reads") }, t("{used} of {limit} characters", { used: section.size.toLocaleString(dateLocale()), limit: view.limits.main.toLocaleString(dateLocale()) })) ), - groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react17.createElement)( - import_react17.Fragment, -+ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react27.createElement)( -+ import_react27.Fragment, ++ groups.some((group) => group.scope === section.scope) ? groups.filter((group) => group.scope === section.scope).map((group) => (0, import_react25.createElement)( ++ import_react25.Fragment, { key: group.key }, - group.topic ? (0, import_react17.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null, - (0, import_react17.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react17.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove }))) - )) : (0, import_react17.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet")) -+ group.topic ? (0, import_react27.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null, -+ (0, import_react27.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react27.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove }))) -+ )) : (0, import_react27.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet")) ++ group.topic ? (0, import_react25.createElement)("div", { className: "bt-mem-topic" }, group.topic) : null, ++ (0, import_react25.createElement)("ul", { className: "bt-mem-list" }, group.entries.map((entry) => (0, import_react25.createElement)(EntryRow, { key: `${entry.topic ?? ""}:${entry.text}`, entry, remove }))) ++ )) : (0, import_react25.createElement)("div", { className: "bt-mem-none" }, t("Nothing saved yet")) )) ); } @@ -15240,33 +13929,33 @@ diff --git a/client.js b/client.js } function Reply({ answer, dismiss }) { - return (0, import_react17.createElement)( -+ return (0, import_react27.createElement)( ++ return (0, import_react25.createElement)( "div", { className: "bt-mem-reply", role: "status" }, - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)( "div", { className: "bt-mem-reply-head" }, - (0, import_react17.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked), - answer.pending ? null : (0, import_react17.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react17.createElement)(CloseIcon)) -+ (0, import_react27.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked), -+ answer.pending ? null : (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react27.createElement)(CloseIcon)) ++ (0, import_react25.createElement)("span", { className: "bt-mem-reply-asked" }, answer.asked), ++ answer.pending ? null : (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Dismiss"), onClick: dismiss }, (0, import_react25.createElement)(CloseIcon)) ), - answer.pending ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react17.createElement)( - import_react17.Fragment, -+ answer.pending ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react27.createElement)( -+ import_react27.Fragment, ++ answer.pending ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text bt-shimmer" }, t("Thinking…")) : answer.error ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text bt-danger" }, hostText(answer.error)) : (0, import_react25.createElement)( ++ import_react25.Fragment, null, - answer.reply ? (0, import_react17.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null, - answer.changes.length > 0 ? (0, import_react17.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react17.createElement)( -+ answer.reply ? (0, import_react27.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null, -+ answer.changes.length > 0 ? (0, import_react27.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react27.createElement)( ++ answer.reply ? (0, import_react25.createElement)("div", { className: "bt-mem-reply-text" }, answer.reply) : null, ++ answer.changes.length > 0 ? (0, import_react25.createElement)("ul", { className: "bt-mem-changes" }, answer.changes.map((change, index) => (0, import_react25.createElement)( "li", { key: index, "data-ok": change.ok || void 0 }, - change.ok ? (0, import_react17.createElement)(CheckIcon) : (0, import_react17.createElement)(CloseIcon), - (0, import_react17.createElement)("span", null, changeLine(change)) -+ change.ok ? (0, import_react27.createElement)(CheckIcon) : (0, import_react27.createElement)(CloseIcon), -+ (0, import_react27.createElement)("span", null, changeLine(change)) ++ change.ok ? (0, import_react25.createElement)(CheckIcon) : (0, import_react25.createElement)(CloseIcon), ++ (0, import_react25.createElement)("span", null, changeLine(change)) ))) : null ) ); @@ -15278,18 +13967,18 @@ diff --git a/client.js b/client.js - const latest = (0, import_react17.useRef)(0); - const alive = (0, import_react17.useRef)(true); - const show = (0, import_react17.useCallback)((next) => { -+ const [view, setView] = (0, import_react27.useState)(null); -+ const [error, setError] = (0, import_react27.useState)(""); -+ const timer = (0, import_react27.useRef)(0); -+ const latest = (0, import_react27.useRef)(0); -+ const alive = (0, import_react27.useRef)(true); -+ const show = (0, import_react27.useCallback)((next) => { ++ const [view, setView] = (0, import_react25.useState)(null); ++ const [error, setError] = (0, import_react25.useState)(""); ++ const timer = (0, import_react25.useRef)(0); ++ const latest = (0, import_react25.useRef)(0); ++ const alive = (0, import_react25.useRef)(true); ++ const show = (0, import_react25.useCallback)((next) => { setView(next); setError(""); noteCount(botId, next.entries.length); }, [botId]); - const load = (0, import_react17.useCallback)(async (flags = {}) => { -+ const load = (0, import_react27.useCallback)(async (flags = {}) => { ++ const load = (0, import_react25.useCallback)(async (flags = {}) => { clearTimeout(timer.current); const ticket = ++latest.current; try { @@ -15297,13 +13986,13 @@ diff --git a/client.js b/client.js if (!alive.current || ticket !== latest.current) return; show(next); - if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS2); -+ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS6); ++ if (next.summarizing) timer.current = setTimeout(() => void load({ summarize: flags.summarize || flags.regenerate }), POLL_MS5); } catch (failure) { if (alive.current && ticket === latest.current) setError(failure?.message ?? String(failure)); } }, [botId]); - (0, import_react17.useEffect)(() => { -+ (0, import_react27.useEffect)(() => { ++ (0, import_react25.useEffect)(() => { alive.current = true; void load({ summarize: true }); return () => { @@ -15315,8 +14004,8 @@ diff --git a/client.js b/client.js - const [now, setNow] = (0, import_react17.useState)(Date.now()); - (0, import_react17.useEffect)(() => { +function useNow3(every) { -+ const [now, setNow] = (0, import_react27.useState)(Date.now()); -+ (0, import_react27.useEffect)(() => { ++ const [now, setNow] = (0, import_react25.useState)(Date.now()); ++ (0, import_react25.useEffect)(() => { const id = setInterval(() => setNow(Date.now()), every); return () => clearInterval(id); }, [every]); @@ -15326,8 +14015,8 @@ diff --git a/client.js b/client.js 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 [page, setPage] = (0, import_react27.useState)("summary"); -+ const [menu, setMenu] = (0, import_react27.useState)(false); ++ const [page, setPage] = (0, import_react25.useState)("summary"); ++ const [menu, setMenu] = (0, import_react25.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); @@ -15336,16 +14025,16 @@ 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(), []); -+ const [text, setText] = (0, import_react27.useState)(""); -+ const [answer, setAnswer] = (0, import_react27.useState)(null); -+ const menuRef = (0, import_react27.useRef)(null); -+ const dotsRef = (0, import_react27.useRef)(null); -+ const inputRef = (0, import_react27.useRef)(null); ++ const [text, setText] = (0, import_react25.useState)(""); ++ const [answer, setAnswer] = (0, import_react25.useState)(null); ++ const menuRef = (0, import_react25.useRef)(null); ++ const dotsRef = (0, import_react25.useRef)(null); ++ const inputRef = (0, import_react25.useRef)(null); + const now = useNow3(3e4); -+ const close = (0, import_react27.useCallback)(() => actions.closeMemory(), []); ++ const close = (0, import_react25.useCallback)(() => actions.closeMemory(), []); useEscape(close); - (0, import_react17.useEffect)(() => { -+ (0, import_react27.useEffect)(() => { ++ (0, import_react25.useEffect)(() => { if (!menu) return void 0; const onDown = (event) => { if (menuRef.current?.contains(event.target) || dotsRef.current?.contains(event.target)) return; @@ -15354,17 +14043,17 @@ diff --git a/client.js b/client.js 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)( ++ return (0, import_react25.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)( -+ (0, import_react27.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)("div", { className: "bt-scrim bt-mem-scrim", onMouseDown: close }), ++ (0, import_react25.createElement)( "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)( ++ (0, import_react25.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, @@ -15374,54 +14063,54 @@ diff --git a/client.js b/client.js - (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), -+ (0, import_react27.createElement)("span", { className: "bt-mem-head-gap" }), -+ (0, import_react27.createElement)("button", { ref: dotsRef, type: "button", className: "bt-icon-btn", "aria-label": t("Memory options"), "aria-haspopup": "menu", "aria-expanded": menu, onClick: () => setMenu((value) => !value) }, (0, import_react27.createElement)(DotsIcon)), -+ (0, import_react27.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react27.createElement)(CloseIcon)), -+ menuShown ? (0, import_react27.createElement)( ++ page === "entries" ? (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to the summary"), onClick: backToSummary }, (0, import_react25.createElement)(ChevronLeftIcon)) : null, ++ (0, import_react25.createElement)("h2", null, page === "entries" ? t("All entries") : t("Memory summary")), ++ (0, import_react25.createElement)("span", { className: "bt-mem-status" }, page === "summary" ? [bot.name, status].filter(Boolean).join(" · ") : status), ++ (0, import_react25.createElement)("span", { className: "bt-mem-head-gap" }), ++ (0, import_react25.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_react25.createElement)(DotsIcon)), ++ (0, import_react25.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close memory"), onClick: close }, (0, import_react25.createElement)(CloseIcon)), ++ menuShown ? (0, import_react25.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)( -+ (0, import_react27.createElement)( ++ (0, import_react25.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_react27.createElement)("span", { className: "bt-menu-label" }, t("All entries")), -+ (0, import_react27.createElement)("span", { className: "bt-menu-hint" }, String(count)) ++ (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("All entries")), ++ (0, import_react25.createElement)("span", { className: "bt-menu-hint" }, String(count)) ), - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.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")) -+ (0, import_react27.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) ++ (0, import_react25.createElement)("span", { className: "bt-menu-label" }, t("Regenerate summary")) ) ) : null ), - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.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 }) -+ 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 }) ++ error ? (0, import_react25.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, hostText(error)) : null, ++ view === null ? error ? null : (0, import_react25.createElement)(Skeleton) : page === "entries" ? (0, import_react25.createElement)(Entries, { bot, view, remove }) : (0, import_react25.createElement)(Summary, { bot, view, regenerate }) ), - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.createElement)( "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)( ++ answer ? (0, import_react25.createElement)(Reply, { answer, dismiss: () => setAnswer(null) }) : null, ++ (0, import_react25.createElement)( "form", { className: "bt-mem-ask", onSubmit: ask }, - (0, import_react17.createElement)("input", { -+ (0, import_react27.createElement)("input", { ++ (0, import_react25.createElement)("input", { ref: inputRef, className: "bt-mem-input", value: text, @@ -15430,11 +14119,11 @@ diff --git a/client.js b/client.js onChange: (event) => setText(event.target.value) }), - (0, import_react17.createElement)( -+ (0, import_react27.createElement)( ++ (0, import_react25.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) -+ busy ? (0, import_react27.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react27.createElement)(ArrowUpIcon) ++ busy ? (0, import_react25.createElement)("span", { className: "bt-mem-spin", "aria-hidden": true }) : (0, import_react25.createElement)(ArrowUpIcon) ) ) ) @@ -15443,7 +14132,7 @@ diff --git a/client.js b/client.js } +// src/client/standing-approvals.js -+var import_react28 = require("react"); ++var import_react26 = require("react"); +var standingOf = (target, roster) => { + const me = roster?.self?.memberId; + return (target?.standingApprovals ?? []).filter((grant) => me === void 0 || grant.ownerId === me); @@ -15452,19 +14141,19 @@ diff --git a/client.js b/client.js + const grants = scope === "room" ? standingOf(target, roster) : target?.standingApprovals ?? []; + if (grants.length === 0) return null; + const hint = scope === "room" ? t("Your Bots in this group do these without asking.") : t("This Bot does these without asking."); -+ return (0, import_react28.createElement)( ++ return (0, import_react26.createElement)( + Card2, + { title: t("Always allowed") }, -+ grants.map((grant) => (0, import_react28.createElement)( ++ grants.map((grant) => (0, import_react26.createElement)( + "div", + { key: grant.kind, className: "bt-set-row bt-bs-row", "data-standing": grant.kind }, -+ (0, import_react28.createElement)( ++ (0, import_react26.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react28.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind), -+ (0, import_react28.createElement)("span", { className: "bt-set-hint" }, hint) ++ (0, import_react26.createElement)("span", { className: "bt-bs-label" }, grant.label || grant.kind), ++ (0, import_react26.createElement)("span", { className: "bt-set-hint" }, hint) + ), -+ (0, import_react28.createElement)("button", { ++ (0, import_react26.createElement)("button", { + type: "button", + className: "bt-soft bt-soft-sm", + "aria-label": t("Remove always allow for {kind}", { kind: grant.label || grant.kind }), @@ -15476,7 +14165,7 @@ diff --git a/client.js b/client.js + // src/client/bot-settings.js -var import_react18 = require("react"); -+var import_react29 = require("react"); ++var import_react27 = 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")); @@ -15486,8 +14175,8 @@ diff --git a/client.js b/client.js 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 [draft, setDraft] = (0, import_react27.useState)(null); ++ const reverting = (0, import_react27.useRef)(false); const commit = () => { const next = draft?.trim(); setDraft(null); @@ -15496,7 +14185,7 @@ diff --git a/client.js b/client.js void onSave(next); }; - return (0, import_react18.createElement)(multiline ? "textarea" : "input", { -+ return (0, import_react29.createElement)(multiline ? "textarea" : "input", { ++ return (0, import_react27.createElement)(multiline ? "textarea" : "input", { className: multiline ? "bt-bs-input bt-bs-area" : "bt-bs-input", value: draft ?? value ?? "", placeholder, @@ -15505,37 +14194,37 @@ diff --git a/client.js b/client.js } function Row({ label, hint, stack = false, children }) { - return (0, import_react18.createElement)( -+ return (0, import_react29.createElement)( ++ return (0, import_react27.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), -+ (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), ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label), hint ? (0, import_react27.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)); -+ 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)); ++ return (0, import_react27.createElement)("section", { className: "bt-set-section" }, title ? (0, import_react27.createElement)("h3", null, title) : null, (0, import_react27.createElement)("div", { className: "bt-set-card" }, children)); } function Switch({ on, label, disabled, onChange }) { - return (0, import_react18.createElement)( -+ return (0, import_react29.createElement)( ++ return (0, import_react27.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 }) -+ (0, import_react29.createElement)("span", { className: "bt-switch-knob", "aria-hidden": true }) ++ (0, import_react27.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)( ++ return (0, import_react27.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 }) -+ (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 }) ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, showLabel ? (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label) : null, hint ? (0, import_react27.createElement)("span", { className: "bt-set-hint" }, hint) : null), ++ (0, import_react27.createElement)(Switch, { on, label, disabled, onChange }) ); } function modelHint(bot, roster) { @@ -15544,13 +14233,13 @@ diff --git a/client.js b/client.js } function ShapeRing({ shape, size }) { - const maskId = (0, import_react18.useRef)(""); -+ const maskId = (0, import_react29.useRef)(""); ++ const maskId = (0, import_react27.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 } }); -+ return (0, import_react29.createElement)("svg", { className: "bt-ring", viewBox: `0 0 ${def.size} ${def.size}`, "aria-hidden": true, dangerouslySetInnerHTML: { __html: markup } }); ++ return (0, import_react27.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))]; @@ -15559,11 +14248,11 @@ diff --git a/client.js b/client.js 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, ++ return (0, import_react27.createElement)( ++ import_react27.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", { -+ (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", { ++ (0, import_react27.createElement)("div", { className: "bt-shapes", role: "group", "aria-label": t("Character shape") }, rows.map((row, index) => (0, import_react27.createElement)("div", { key: index, className: "bt-shape-row" }, row.map((id) => (0, import_react27.createElement)("button", { key: id, type: "button", className: "bt-shape", @@ -15573,8 +14262,8 @@ diff --git a/client.js b/client.js 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", { ++ }, (0, import_react27.createElement)(ShapeRing, { shape: id, size: 36 }), (0, import_react27.createElement)(BotMark, { look: { shape: id, color: look.color }, size: 36 })))))), ++ (0, import_react27.createElement)("div", { className: "bt-colors", role: "group", "aria-label": t("Character color") }, CHARACTER_COLORS.map((color) => (0, import_react27.createElement)("button", { key: color, type: "button", className: "bt-color", @@ -15583,7 +14272,7 @@ diff --git a/client.js b/client.js "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) } })))) ++ }, (0, import_react27.createElement)("span", { style: { background: paintCss(color) } })))) ); } function AvatarEditor({ bot, actions, run, onClose }) { @@ -15592,15 +14281,15 @@ diff --git a/client.js b/client.js - 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 [tab, setTab] = (0, import_react27.useState)(look.image ? "upload" : "bot"); ++ const [over, setOver] = (0, import_react27.useState)(false); ++ const ref = (0, import_react27.useRef)(null); ++ const fileRef = (0, import_react27.useRef)(null); 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)(() => { ++ (0, import_react27.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || ref.current?.contains(event.target) || event.target.closest(".bt-avatar-trigger")) return; onClose(); @@ -15609,7 +14298,7 @@ diff --git a/client.js b/client.js }; }, [onClose]); - (0, import_react18.useEffect)(() => { -+ (0, import_react29.useEffect)(() => { ++ (0, import_react27.useEffect)(() => { if (tab !== "upload") return void 0; const onPaste = (event) => { const file = [...event.clipboardData?.files ?? []].find((item) => item.type.startsWith("image/")); @@ -15618,24 +14307,24 @@ diff --git a/client.js b/client.js const hasPhoto = Boolean(look.image); const glide = useGlide(tab); - return (0, import_react18.createElement)( -+ return (0, import_react29.createElement)( ++ return (0, import_react27.createElement)( "div", { ref, className: "bt-avedit", role: "dialog", "aria-label": t("Avatar editor") }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( "div", { className: "bt-avedit-top" }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.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_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_react27.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ [["bot", "Bot"], ["upload", "Upload"]].map(([id, label]) => (0, import_react27.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", { ++ (0, import_react27.createElement)("button", { type: "button", className: "bt-avedit-tab bt-avedit-reset", title: t("Reset character to default"), @@ -15643,14 +14332,14 @@ diff --git a/client.js b/client.js }, t("Reset")) ), - tab === "bot" ? (0, import_react18.createElement)(CharacterGrid, { -+ tab === "bot" ? (0, import_react29.createElement)(CharacterGrid, { ++ tab === "bot" ? (0, import_react27.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)( -+ tab === "upload" ? (0, import_react29.createElement)( ++ tab === "upload" ? (0, import_react27.createElement)( "div", { className: "bt-dropzone", @@ -15661,18 +14350,18 @@ diff --git a/client.js b/client.js - 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)( ++ hasPhoto ? (0, import_react27.createElement)("img", { src: look.image, alt: "" }) : null, ++ (0, import_react27.createElement)("div", null, t("Drag, drop, or paste an image")), ++ (0, import_react27.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_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_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, hasPhoto ? t("Replace") : t("Browse files")), ++ hasPhoto ? (0, import_react27.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) => { ++ (0, import_react27.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); @@ -15681,17 +14370,17 @@ diff --git a/client.js b/client.js 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)( ++ return (0, import_react27.createElement)( Card, { title: t("Memory") }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.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)) -+ (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)) ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, label)), ++ (0, import_react27.createElement)("span", { className: "bt-mem-row-go" }, t("Manage"), (0, import_react27.createElement)(ChevronRightIcon)) ) ); } @@ -15700,10 +14389,10 @@ diff --git a/client.js b/client.js - 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)(() => { ++ const [editing, setEditing] = (0, import_react27.useState)(false); ++ const [confirming, setConfirming] = (0, import_react27.useState)(false); ++ const closeEditor = (0, import_react27.useCallback)(() => setEditing(false), []); ++ (0, import_react27.useEffect)(() => { if (!confirming) return void 0; const timer = setTimeout(() => setConfirming(false), 4e3); return () => clearTimeout(timer); @@ -15717,141 +14406,123 @@ diff --git a/client.js b/client.js else setConfirming(true); }; - (0, import_react18.useEffect)(() => { -+ (0, import_react29.useEffect)(() => { ++ (0, import_react27.useEffect)(() => { void actions.refreshModels?.(); }, [bot.id]); - return (0, import_react18.createElement)( -+ return (0, import_react29.createElement)( ++ return (0, import_react27.createElement)( "div", { className: "bt-bs" }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( "div", { className: "bt-bs-hero" }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( "div", { className: "bt-avatar-anchor" }, - (0, import_react18.createElement)( -+ (0, import_react29.createElement)( ++ (0, import_react27.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)) -+ (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_react18.createElement)( -+ (0, import_react29.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_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") }, -- (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") }, -- (0, import_react18.createElement)( -+ (0, import_react29.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_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_react27.createElement)(BotMark, { bot, size: 64, live: true }), ++ (0, import_react27.createElement)("span", { className: "bt-avatar-pencil", "aria-hidden": true }, (0, import_react27.createElement)(PencilIcon)) ++ ), ++ editing ? (0, import_react27.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null + ), -+ 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") }, -- (0, import_react18.createElement)( -+ (0, import_react29.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_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") }, -- (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 }, -- (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 }) }) ++ (0, import_react27.createElement)( ++ "div", ++ { className: "bt-bs-who" }, ++ (0, import_react27.createElement)("span", { className: "bt-bs-name" }, bot.name), ++ (0, import_react27.createElement)("span", { className: "bt-bs-sub" }, [bot.role, isMainBot ? t("Main Bot") : ""].filter(Boolean).join(" · ") || t("Bot")) ++ ) ++ ), ++ (0, import_react27.createElement)( ++ Card, ++ { title: t("Profile") }, ++ (0, import_react27.createElement)(Row, { label: t("Name") }, (0, import_react27.createElement)(InlineText, { label: t("Name"), value: bot.name, placeholder: "Bob", required: true, maxLength: 40, onSave: (name) => save({ name }) })), ++ (0, import_react27.createElement)(Row, { label: t("Label"), hint: t("Optional. Shown under the name.") }, (0, import_react27.createElement)(InlineText, { label: t("Label"), value: bot.role, placeholder: t("Research, admin…"), maxLength: 24, onSave: (role) => save({ role }) })) ++ ), ++ (0, import_react27.createElement)( ++ Card, ++ { title: t("Model") }, ++ (0, import_react27.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-set-hint" }, modelHint(bot, roster))), ++ (0, import_react27.createElement)(ModelMenu, { bot, roster, actions, run }) ++ ), ++ bot.model ? (0, import_react27.createElement)(ModelTestRow, { bot, modelName: modelOf(bot, roster)?.name }) : null ++ ), ++ (0, import_react27.createElement)( ++ Card, ++ { title: t("Working folder") }, ++ (0, import_react27.createElement)( ++ "div", ++ { className: "bt-set-row bt-bs-row" }, ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), ++ (0, import_react27.createElement)(FolderMenu, { value: bot.workDir, actions, run, onPick: (workDir) => save({ workDir }) }) ++ ) ++ ), ++ (0, import_react27.createElement)( ++ Card, ++ { title: t("Instructions") }, ++ (0, import_react27.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_react27.createElement)(InlineText, { label: t("Instructions"), value: bot.instructions, multiline: true, placeholder: t("What this Bot is responsible for…"), onSave: (instructions) => save({ instructions }) }) + ) + ), + // Sharing sits near the top with a title like Profile and Model, not at the end of the page (#406). -+ (0, import_react29.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react29.createElement)(Card, { title: t("Sharing") }, children) }), -+ (0, import_react29.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), -+ (0, import_react29.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }), -+ roster.memory === false ? null : (0, import_react29.createElement)(MemoryCard, { bot, actions }), -+ (0, import_react29.createElement)(BotSchedules, { bot, roster, actions }), -+ (0, import_react29.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)(FlyBotDetails, { bot, roster, actions, wrap: (children) => (0, import_react27.createElement)(Card, { title: t("Sharing") }, children) }), ++ (0, import_react27.createElement)(BotCapabilitiesCard, { bot, roster, actions, run, Card, SwitchRow }), ++ (0, import_react27.createElement)(BotConnectorsCard, { bot, roster, actions, run, Card, SwitchRow }), ++ roster.memory === false ? null : (0, import_react27.createElement)(MemoryCard, { bot, actions }), ++ (0, import_react27.createElement)(BotSchedules, { bot, roster, actions }), ++ (0, import_react27.createElement)(StandingApprovalsCard, { scope: "bot", target: bot, roster, actions, run, Card }), ++ (0, import_react27.createElement)( + Card, + { title: t("Main Bot") }, -+ onlyMain ? (0, import_react29.createElement)( ++ onlyMain ? (0, import_react27.createElement)( + "div", + { className: "bt-set-row bt-bs-row" }, -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) ++ (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("The only Main Bot. To hand the role to another Bot, use Main Bot on the Bots page.")) + ), -+ (0, import_react29.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) -+ ) : (0, import_react29.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) ++ (0, import_react27.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("bots") }, t("Change")) ++ ) : (0, import_react27.createElement)(SwitchRow, { label: t("Main Bot"), showLabel: false, hint: t("Can create and change Bots, and runs every group chat."), on: isMainBot, onChange: (on) => run(actions.setMain(bot.id, on)) }) + ), + // The Main Bot tile is always first and always shown, like in the context menu. -+ isTile ? null : (0, import_react29.createElement)( ++ isTile ? null : (0, import_react27.createElement)( + Card, + { title: t("Sidebar") }, -+ (0, import_react29.createElement)(SwitchRow, { label: t("Pin"), hint: t("Kept above the other chats."), on: bot.pinned === true, onChange: (pinned) => run(actions.setFlags(bot.id, { pinned })) }), -+ isMainBot ? null : (0, import_react29.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) ++ (0, import_react27.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_react27.createElement)(SwitchRow, { label: t("Hide from sidebar"), hint: t("Search still finds it."), on: bot.hidden === true, onChange: (hidden) => run(actions.setFlags(bot.id, { hidden })) }) + ), -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( + Card, + { title: t("More") }, -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( + "button", + { type: "button", className: "bt-set-row bt-bs-row", onClick: duplicate }, -+ (0, import_react29.createElement)("span", { className: "bt-set-copy" }, (0, import_react29.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react29.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), -+ (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon)) ++ (0, import_react27.createElement)("span", { className: "bt-set-copy" }, (0, import_react27.createElement)("span", { className: "bt-bs-label" }, t("Duplicate")), (0, import_react27.createElement)("span", { className: "bt-set-hint" }, t("A copy with the same job and model, and fresh memory."))), ++ (0, import_react27.createElement)("span", { className: "bt-chevron" }, (0, import_react27.createElement)(ChevronRightIcon)) + ), -+ isMainBot ? null : (0, import_react29.createElement)( ++ isMainBot ? null : (0, import_react27.createElement)( + "button", + { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, -+ (0, import_react29.createElement)( ++ (0, import_react27.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react29.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), -+ (0, import_react29.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) -+ ), -+ (0, import_react29.createElement)("span", { className: "bt-chevron" }, (0, import_react29.createElement)(ChevronRightIcon)) ++ (0, import_react27.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: bot.name }) : t("Delete Bot")), ++ (0, import_react27.createElement)("span", { className: "bt-set-hint" }, confirming ? t("Click again to delete. Its chats are archived.") : t("Removes the Bot from the team.")) + ), +- editing ? (0, import_react18.createElement)(AvatarEditor, { bot, actions, run, onClose: closeEditor }) : null ++ (0, import_react27.createElement)("span", { className: "bt-chevron" }, (0, import_react27.createElement)(ChevronRightIcon)) + ) + ) + ); @@ -15892,7 +14563,7 @@ diff --git a/client.js b/client.js +`; + +// src/client/group-settings.js -+var import_react30 = require("react"); ++var import_react28 = require("react"); +var ROOM_MODES = [ + ["admin", "Admin leads", "Plain messages go to the admin, who calls others with @Name."], + ["everyone", "Everyone", "Every member answers in turn, admin first."] @@ -15924,9 +14595,9 @@ diff --git a/client.js b/client.js + for (const seat of person?.seats ?? []) { + const bot = faceSeatBot(seat, roster); + if (!bot) continue; -+ marks.push((0, import_react30.createElement)(BotMark, { key: seat.key, bot, size, live: false })); ++ marks.push((0, import_react28.createElement)(BotMark, { key: seat.key, bot, size, live: false })); + } -+ if (marks.length === 0) return [(0, import_react30.createElement)(PersonMark, { key: "person", size, name: person?.name })]; ++ if (marks.length === 0) return [(0, import_react28.createElement)(PersonMark, { key: "person", size, name: person?.name })]; + return marks; +} +function faceRoomCount(room2, roster) { @@ -15940,31 +14611,31 @@ diff --git a/client.js b/client.js +var isLeadSeat = (seat, lead) => Boolean(handleKey(lead) && handleKey(seat.record?.handle) === handleKey(lead)); +function FacePersonRow({ person, onOpenBot, lead, roster, className = "bt-set-row bt-bs-row bt-gs-member", size = 26 }) { + const bots = person.seats.flatMap((seat, index) => { -+ const name = seat.local && onOpenBot ? (0, import_react30.createElement)("button", { key: seat.key, type: "button", className: "bt-face-bot", title: t("Open {name}'s chat", { name: seat.bot }), onClick: () => onOpenBot(seat.local.id) }, seat.bot) : (0, import_react30.createElement)("span", { key: seat.key, className: "bt-face-bot", title: seat.record?.handle || void 0 }, seat.bot); -+ const marked = isLeadSeat(seat, lead) ? [name, (0, import_react30.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; -+ return index === 0 ? marked : [(0, import_react30.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; ++ const name = seat.local && onOpenBot ? (0, import_react28.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_react28.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_react28.createElement)("span", { key: `${seat.key}-lead`, className: "bt-tag", "data-flybot": "face-lead-tag" }, t("Lead"))] : [name]; ++ return index === 0 ? marked : [(0, import_react28.createElement)("span", { key: `${seat.key}-sep` }, t(", ")), ...marked]; + }); -+ return (0, import_react30.createElement)( ++ return (0, import_react28.createElement)( + "div", + { className, role: "listitem", "data-flybot": "face-person", "data-mine": person.mine || void 0 }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "span", + { className: "bt-gs-who bt-face-person" }, -+ (0, import_react30.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)("span", { className: "bt-face-marks", "data-flybot": "face-person-avatars" }, faceMarks(person, roster, size)), ++ (0, import_react28.createElement)( + "span", + { className: "bt-face-person-text" }, -+ (0, import_react30.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)), -+ (0, import_react30.createElement)("span", { className: "bt-face-bots" }, bots) ++ (0, import_react28.createElement)("span", { className: "bt-row-title" }, facePersonLabel(person)), ++ (0, import_react28.createElement)("span", { className: "bt-face-bots" }, bots) + ) + ) + ); +} +function FaceLead({ room: room2, roster, pick: pick2 }) { + const lead = room2.face?.lead; -+ const [open, setOpen] = (0, import_react30.useState)(false); -+ const [busy, setBusy] = (0, import_react30.useState)(false); -+ const [error, setError] = (0, import_react30.useState)(""); ++ const [open, setOpen] = (0, import_react28.useState)(false); ++ const [busy, setBusy] = (0, import_react28.useState)(false); ++ const [error, setError] = (0, import_react28.useState)(""); + const seats = faceSeats(room2, roster); + const current = seats.find((seat) => isLeadSeat(seat, lead?.handle)); + if (!current) return null; @@ -15981,30 +14652,31 @@ diff --git a/client.js b/client.js + setBusy(false); + } + }; -+ return (0, import_react30.createElement)( ++ return (0, import_react28.createElement)( + "div", + { className: "bt-face-lead", "data-flybot": "face-lead" }, -+ (0, import_react30.createElement)("div", { className: "bt-section-title" }, t("Room lead")), -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)("div", { className: "bt-section-title" }, t("Room lead")), ++ (0, import_react28.createElement)( + "div", + { className: "bt-set-row" }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "span", + { className: "bt-set-copy" }, -+ (0, import_react30.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)), -+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined.")) -+ ), -+ canPick ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null ++ (0, import_react28.createElement)("span", { className: "bt-row-title" }, faceSeatLabel(current)), ++ (0, import_react28.createElement)("span", { className: "bt-set-hint" }, lead.picked ? t("Writes the plan and gives each Bot its part.") : t("Default: the first Bot of the room creator that joined.")) + ), +- (0, import_react18.createElement)( ++ canPick ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", "aria-expanded": open, onClick: () => setOpen(!open) }, open ? t("Done") : t("Change")) : null + ), -+ open && canPick ? seats.map((seat) => (0, import_react30.createElement)( ++ open && canPick ? seats.map((seat) => (0, import_react28.createElement)( + "div", + { key: seat.key, className: "bt-set-row" }, -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)), -+ seat === current ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, faceSeatLabel(seat)), ++ seat === current ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) + )) : null, -+ open && canPick && lead.picked ? (0, import_react30.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null, -+ canPick || !lead.supported ? null : (0, import_react30.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), -+ error ? (0, import_react30.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null ++ open && canPick && lead.picked ? (0, import_react28.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_react28.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), ++ error ? (0, import_react28.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null + ); +} +function seatedCount(room2) { @@ -16020,9 +14692,9 @@ diff --git a/client.js b/client.js + return [t(count === 1 ? "{count} member" : "{count} members", { count }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); +} +function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { -+ const [adding, setAdding] = (0, import_react30.useState)(false); -+ const [confirming, setConfirming] = (0, import_react30.useState)(false); -+ (0, import_react30.useEffect)(() => { ++ const [adding, setAdding] = (0, import_react28.useState)(false); ++ const [confirming, setConfirming] = (0, import_react28.useState)(false); ++ (0, import_react28.useEffect)(() => { + if (!confirming) return void 0; + const timer = setTimeout(() => setConfirming(false), 4e3); + return () => clearTimeout(timer); @@ -16060,41 +14732,52 @@ diff --git a/client.js b/client.js + if (done) onDeleted?.(); + }); + }; -+ return (0, import_react30.createElement)( ++ return (0, import_react28.createElement)( + "div", + { className: "bt-bs" }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "div", + { className: "bt-bs-hero" }, -+ (0, import_react30.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), -+ (0, import_react30.createElement)( -+ "div", -+ { className: "bt-bs-who" }, -+ (0, import_react30.createElement)("span", { className: "bt-bs-name" }, room2.name), -+ (0, import_react30.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) -+ ) -+ ), -+ (0, import_react30.createElement)( -+ Card, -+ { title: t("Profile") }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), ++ (0, import_react28.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_react28.createElement)("span", { className: "bt-bs-name" }, room2.name), ++ (0, import_react28.createElement)("span", { className: "bt-bs-sub" }, roomSubtitle(room2, roster)) + ) + ), +- (0, import_react18.createElement)( ++ (0, import_react28.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_react28.createElement)( + Row, + { label: t("Name"), hint: t("Leave it empty to name the group after its members.") }, -+ (0, import_react30.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) ++ (0, import_react28.createElement)(InlineText, { label: t("Group name"), value: room2.named ? room2.name : "", placeholder: t("Name after members"), maxLength: 60, onSave: (name) => update({ name }) }) + ), -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + Row, + { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, -+ (0, import_react30.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) ++ (0, import_react28.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) + ) -+ ), -+ (0, import_react30.createElement)( -+ Card, + ), +- (0, import_react18.createElement)( ++ (0, import_react28.createElement)( + Card, +- { title: t("Model") }, +- (0, import_react18.createElement)( + { title: t("Who answers") }, -+ (0, import_react30.createElement)( -+ "div", ++ (0, import_react28.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 }) + { className: "bt-set-row bt-bs-row bt-bs-stack" }, -+ (0, import_react30.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react30.createElement)("button", { ++ (0, import_react28.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react28.createElement)("button", { + key: id, + type: "button", + "aria-pressed": mode === id, @@ -16104,121 +14787,133 @@ diff --git a/client.js b/client.js + if (mode !== id) void update({ mode: id }); + } + }, t(label)))), -+ (0, import_react30.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) -+ ) -+ ), -+ (0, import_react30.createElement)( -+ Card, -+ { title: t("Working folder") }, -+ (0, import_react30.createElement)( -+ "div", -+ { className: "bt-set-row bt-bs-row" }, -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), -+ (0, import_react30.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) ++ (0, import_react28.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) + ) + ), +- (0, import_react18.createElement)( ++ (0, import_react28.createElement)( + Card, + { title: t("Working folder") }, +- (0, import_react18.createElement)( ++ (0, import_react28.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_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Folder"))), ++ (0, import_react28.createElement)(FolderMenu, { value: room2.workDir, group: true, actions, run, onPick: (workDir) => update({ workDir }) }) ) ), +- (0, import_react18.createElement)( ++ face ? (0, import_react28.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)( -+ face ? (0, import_react30.createElement)( ++ { title: t("Members · {count}", { count: people2.length }) }, ++ people2.map((person) => (0, import_react28.createElement)(FacePersonRow, { key: person.key, person, roster, onOpenBot: openBot, lead: room2.face?.lead?.handle })) ++ ) : null, ++ face ? null : (0, import_react28.createElement)( 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, -+ face ? null : (0, import_react30.createElement)( -+ Card, + { title: t("Members · {count}", { count: members.length + humans.length + remotes.length }) }, -+ humans.map((person) => (0, import_react30.createElement)( ++ humans.map((person) => (0, import_react28.createElement)( "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)( ++ (0, import_react28.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.")) + { className: "bt-gs-who" }, -+ (0, import_react30.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), -+ (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Human")), -+ person.role === "admin" ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null ++ (0, import_react28.createElement)("span", { className: "bt-row-title" }, person.displayName || person.id), ++ (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Human")), ++ person.role === "admin" ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Admin")) : null + ), -+ humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null ++ humans.length + members.length + remotes.length > 2 && person.id !== roster.self?.memberId ? (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: person.displayName || person.id }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "human", id: person.id }] }) }, "×") : null + )), + members.map((member) => { + const leads = room2.admin === member.id; + const boardHere = room2.origin !== "face-to-face" && room2.origin !== "inbound-share"; + const taskLead = room2.taskLead === member.id; -+ return (0, import_react30.createElement)( ++ return (0, import_react28.createElement)( + "div", + { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "button", + { type: "button", className: "bt-gs-who", title: t("Open {name}'s chat", { name: member.name }), onClick: () => openBot(member.id) }, -+ (0, import_react30.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), -+ (0, import_react30.createElement)("span", { className: "bt-row-title" }, member.name), -+ leads ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Admin")) : null, -+ taskLead ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null, -+ (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Chief")) : null ++ (0, import_react28.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: leads, live: false }), ++ (0, import_react28.createElement)("span", { className: "bt-row-title" }, member.name), ++ leads ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Admin")) : null, ++ taskLead ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Task lead")) : null, ++ (roster.humans ?? []).some((person) => person.designatedChiefId === member.id) ? (0, import_react28.createElement)("span", { className: "bt-tag" }, t("Chief")) : null + ), -+ leads ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), -+ boardHere ? taskLead ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react30.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null, -+ members.length > 2 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null ++ leads ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", title: t("Leave the group without an admin"), onClick: () => update({ admin: null }) }, t("Unset")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ admin: member.id }) }, t("Make admin")), ++ boardHere ? taskLead ? (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: null }) }, t("Use the admin")) : (0, import_react28.createElement)("button", { type: "button", className: "bt-mini", onClick: () => update({ taskLead: member.id }) }, t("Make task lead")) : null, ++ members.length > 2 ? (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: member.name }), title: t("Remove from group"), onClick: () => update({ remove: [member.id] }) }, "×") : null + ); + }), -+ remotes.map((person) => (0, import_react30.createElement)( ++ remotes.map((person) => (0, import_react28.createElement)( + "div", + { key: person.handle || person.id, className: "bt-set-row bt-bs-row bt-gs-member" }, -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "span", + { className: "bt-gs-who" }, -+ (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), -+ (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), ++ (0, import_react28.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), ++ (0, import_react28.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), + // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is + // labelled as such; a Bot with no Bot Social identity can never be written to at all. -+ room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) ++ room2.origin === "face-to-face" ? null : (0, import_react28.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 }] }) }, "×") ++ room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react28.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + )), + room2.origin === "face-to-face" ? null : adding ? [ -+ ...outsidePeople.map((person) => (0, import_react30.createElement)( ++ ...outsidePeople.map((person) => (0, import_react28.createElement)( + "button", + { key: person.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { + setAdding(false); + void update({ addParticipants: [{ kind: "human", id: person.id, displayName: person.displayName }] }); + } }, -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)), -+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add person")) ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, person.displayName || person.id)), ++ (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add person")) + )), -+ ...outside.map((bot) => (0, import_react30.createElement)( ++ ...outside.map((bot) => (0, import_react28.createElement)( + "button", + { key: bot.id, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { + setAdding(false); + void update({ add: [bot.id] }); + } }, -+ (0, import_react30.createElement)(BotMark, { bot, size: 22 }), -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, bot.name)), -+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add")) ++ (0, import_react28.createElement)(BotMark, { bot, size: 22 }), ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, bot.name)), ++ (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add")) + )), -+ ...outsideRemotes.map((bot) => (0, import_react30.createElement)( ++ ...outsideRemotes.map((bot) => (0, import_react28.createElement)( + "button", + { key: bot.handle, type: "button", className: "bt-set-row bt-bs-row bt-gs-pick", onClick: () => { + setAdding(false); + void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] }); + } }, -+ (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), -+ (0, import_react30.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) ++ (0, import_react28.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), ++ (0, import_react28.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) + )), -+ (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) -+ ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null, -+ (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)( ++ (0, import_react28.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) ++ ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react28.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react28.createElement)(PlusIcon), t("Add member")) : null, ++ (0, import_react28.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react28.createElement)( + "span", + { className: "bt-set-hint" }, + // The host checks for two members on every change, even a rename. @@ -16226,54 +14921,47 @@ 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 }) ++ room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react28.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)( ++ (0, import_react28.createElement)(StandingApprovalsCard, { scope: "room", target: room2, roster, actions, run, Card }), ++ (0, import_react28.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_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_react28.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_react28.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)( ++ (0, import_react28.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)( + { title: t("More") }, -+ (0, import_react30.createElement)( ++ (0, import_react28.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)( -+ (0, import_react30.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_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.")) - ), +- { 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)) -+ (0, import_react30.createElement)("span", { className: "bt-chevron" }, (0, import_react30.createElement)(ChevronRightIcon)) ++ { type: "button", className: "bt-set-row bt-bs-row bt-bs-danger", "data-confirm": confirming || void 0, onClick: remove }, ++ (0, import_react28.createElement)( ++ "span", ++ { className: "bt-set-copy" }, ++ (0, import_react28.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), ++ (0, import_react28.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_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) + ) + ) + ); +} +function NewGroupForm({ roster, actions, run, onCreated, onCancel }) { -+ const [admin, setAdmin] = (0, import_react30.useState)(null); -+ const [members, setMembers] = (0, import_react30.useState)([]); -+ const [name, setName] = (0, import_react30.useState)(""); -+ const [busy, setBusy] = (0, import_react30.useState)(false); ++ const [admin, setAdmin] = (0, import_react28.useState)(null); ++ const [members, setMembers] = (0, import_react28.useState)([]); ++ const [name, setName] = (0, import_react28.useState)(""); ++ const [busy, setBusy] = (0, import_react28.useState)(false); + const toggle = (id) => setMembers((list) => list.includes(id) ? list.filter((item) => item !== id) : [...list, id]); + const pickAdmin = (id) => { + setAdmin(id); @@ -16289,48 +14977,48 @@ diff --git a/client.js b/client.js + if (done && room2) onCreated(room2); + else setBusy(false); + }; -+ const chip = (bot, on, onClick, role) => (0, import_react30.createElement)("button", { ++ const chip = (bot, on, onClick, role) => (0, import_react28.createElement)("button", { + key: bot.id, + type: "button", + role, + className: "bt-ng-chip", + "aria-checked": on, + onClick -+ }, (0, import_react30.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react30.createElement)(AdminStar) : null); -+ return (0, import_react30.createElement)( ++ }, (0, import_react28.createElement)(BotAvatar, { bot, size: 22, badge: false, live: false }), (0, import_react28.createElement)("span", { className: "bt-ng-name" }, bot.name), on && role === "radio" ? (0, import_react28.createElement)(AdminStar) : null); ++ return (0, import_react28.createElement)( + "div", + { className: "bt-set-card bt-ng" }, -+ (0, import_react30.createElement)( ++ (0, import_react28.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)( ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label bt-ng-label" }, (0, import_react28.createElement)(AdminStar), t("Choose the group admin"))), ++ (0, import_react28.createElement)( + "div", + { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, + roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) + ) + ), -+ admin === null ? null : (0, import_react30.createElement)( ++ admin === null ? null : (0, import_react28.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)( ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Who to chat with…"))), ++ (0, import_react28.createElement)( + "div", + { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, + roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) + ) + ), -+ admin === null || members.length === 0 ? null : (0, import_react30.createElement)( ++ admin === null || members.length === 0 ? null : (0, import_react28.createElement)( + "label", + { className: "bt-set-row bt-bs-row bt-ng-step" }, -+ (0, import_react30.createElement)("span", { className: "bt-set-copy" }, (0, import_react30.createElement)("span", { className: "bt-bs-label" }, t("Name"))), -+ (0, import_react30.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) ++ (0, import_react28.createElement)("span", { className: "bt-set-copy" }, (0, import_react28.createElement)("span", { className: "bt-bs-label" }, t("Name"))), ++ (0, import_react28.createElement)("input", { className: "bt-bs-input", value: name, maxLength: 60, placeholder: t("Name after members"), onChange: (event) => setName(event.target.value) }) + ), -+ (0, import_react30.createElement)( ++ (0, import_react28.createElement)( + "div", + { className: "bt-set-row bt-task-foot" }, -+ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), -+ (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) ++ (0, import_react28.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onCancel }, t("Cancel")), ++ (0, import_react28.createElement)("button", { type: "button", className: "bt-send bt-soft-sm", disabled: busy || admin === null || members.length === 0, onClick: () => void create() }, t("Create group chat")) + ) + ); +} @@ -16366,13 +15054,13 @@ diff --git a/client.js b/client.js +`; + +// src/client/palette.js -+var import_react31 = require("react"); ++var import_react29 = require("react"); +var import_dsh_client_ui_primitives3 = require("@deepseek-ai/dsh-client-ui-primitives"); +var PALETTE_EXIT_MS = 160; +function CommandPalette({ start, token, roster, actions, useSessions }) { -+ const [query, setQuery] = (0, import_react31.useState)(start ?? ""); -+ const [cursor, setCursor] = (0, import_react31.useState)(0); -+ const [leaving, setLeaving] = (0, import_react31.useState)(false); ++ const [query, setQuery] = (0, import_react29.useState)(start ?? ""); ++ const [cursor, setCursor] = (0, import_react29.useState)(0); ++ const [leaving, setLeaving] = (0, import_react29.useState)(false); + const wanted = query.trim().toLowerCase(); + const close = () => { + if (leaving) return; @@ -16408,7 +15096,7 @@ diff --git a/client.js b/client.js + const people2 = [ + ...roster.bots.filter((bot) => isListedRemoteChat(bot) && matchesName(bot.name, bot.role, pickerBotLabel(bot))).map((bot) => ({ + key: bot.id, -+ avatar: (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), ++ avatar: (0, import_react29.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }), + title: pickerBotLabel(bot), + subtitle: [bot.role, isMainOf(roster, bot.id) ? t("Main Bot") : ""].filter(Boolean).join(" · "), + badge: bot.hidden ? t("Hidden") : void 0, @@ -16419,7 +15107,7 @@ diff --git a/client.js b/client.js + })), + ...roster.rooms.filter((room2) => matchesName(room2.name)).map((room2) => ({ + key: room2.id, -+ avatar: (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), ++ avatar: (0, import_react29.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), + title: room2.name, + subtitle: t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) }), + badge: room2.hidden ? t("Hidden") : void 0, @@ -16432,7 +15120,7 @@ diff --git a/client.js b/client.js + if (people2.length) sections.push([t("Bots"), people2]); + const agents = (roster.agents ?? []).filter(() => matchesName("DSH Agent")).map((agent) => ({ + key: agent.id, -+ avatar: (0, import_react31.createElement)("span", { className: "bt-palette-agent" }, (0, import_react31.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), ++ avatar: (0, import_react29.createElement)("span", { className: "bt-palette-agent" }, (0, import_react29.createElement)(import_dsh_client_ui_primitives3.FishLogo, { size: 18 })), + title: "DSH Agent", + subtitle: t("Plain dsh session"), + run: () => actions.openAgent(agent.id) @@ -16445,16 +15133,16 @@ diff --git a/client.js b/client.js + key: `${id}:${turn}`, + title: bot?.name ?? room2?.name ?? t("Bot"), + subtitle: snippet, -+ avatar: bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), ++ avatar: bot ? (0, import_react29.createElement)(BotAvatar, { bot, size: 24, badge: false, live: false }) : (0, import_react29.createElement)(RoomAvatar, { room: room2, roster, size: 24 }), + run: () => actions.openSession(id) + }; + }); + if (messages.length) sections.push([t("Messages"), messages]); + const commands = [ -+ { key: "cmd:new", icon: (0, import_react31.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, -+ { key: "cmd:bot", icon: (0, import_react31.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, -+ { key: "cmd:group", icon: (0, import_react31.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, -+ { key: "cmd:settings", icon: (0, import_react31.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") } ++ { key: "cmd:new", icon: (0, import_react29.createElement)(PlusIcon), title: t("New chat"), run: () => actions.openNewChat("new") }, ++ { key: "cmd:bot", icon: (0, import_react29.createElement)(PlusIcon), title: t("Create Bot"), run: () => actions.openNewChat("create") }, ++ { key: "cmd:group", icon: (0, import_react29.createElement)(PeopleIcon), title: t("Create group chat"), run: () => actions.openNewChat("group") }, ++ { key: "cmd:settings", icon: (0, import_react29.createElement)(GearIcon), title: t("Settings"), run: () => actions.openSettings("general") } + ].filter((command) => matchesName(command.title)); + if (commands.length) sections.push([t("Commands"), commands]); + const rows = sections.flatMap(([, items]) => items); @@ -16464,7 +15152,7 @@ diff --git a/client.js b/client.js +${shown}`, { axis: "y", ...QUICK_EDGES }); + const listRef = glide.box; + useFlip(listRef, shown, "[data-flip]"); -+ (0, import_react31.useEffect)(() => { ++ (0, import_react29.useEffect)(() => { + listRef.current?.querySelector("[aria-selected=true]")?.scrollIntoView({ block: "nearest" }); + }, [active, query]); + const choose = (row) => { @@ -16473,18 +15161,18 @@ diff --git a/client.js b/client.js + close(); + }; + let index = -1; -+ return (0, import_react31.createElement)( ++ return (0, import_react29.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)( ++ (0, import_react29.createElement)("div", { className: "bt-backdrop", onMouseDown: close }), ++ (0, import_react29.createElement)( + "div", + { className: "bt-palette", role: "dialog", "aria-label": t("Search"), "aria-modal": true }, -+ (0, import_react31.createElement)( ++ (0, import_react29.createElement)( + "div", + { className: "bt-palette-head" }, -+ (0, import_react31.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)), -+ (0, import_react31.createElement)("input", { ++ (0, import_react29.createElement)("span", { className: "bt-palette-glyph", "aria-hidden": true }, (0, import_react29.createElement)(SearchIcon)), ++ (0, import_react29.createElement)("input", { + autoFocus: true, + value: query, + placeholder: t("Search Bots, chats and messages"), @@ -16510,29 +15198,39 @@ diff --git a/client.js b/client.js + } + } + }), -+ query ? (0, import_react31.createElement)("button", { type: "button", className: "bt-palette-clear", "aria-label": t("Clear search"), onClick: () => { ++ query ? (0, import_react29.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)( ++ } }, (0, import_react29.createElement)(CloseIcon)) : null + ), +- 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)) ++ (0, import_react29.createElement)( + "div", + { ref: listRef, id: "bt-palette-list", className: "bt-palette-list", role: "listbox", "aria-label": t("Results") }, -+ (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ rows.length === 0 ? (0, import_react31.createElement)( ++ (0, import_react29.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ rows.length === 0 ? (0, import_react29.createElement)( + "div", + { className: "bt-palette-empty" }, -+ (0, import_react31.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react31.createElement)(SearchIcon)), -+ (0, import_react31.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), -+ (0, import_react31.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) -+ ) : sections.map(([title, items]) => (0, import_react31.createElement)( -+ import_react31.Fragment, ++ (0, import_react29.createElement)("span", { className: "bt-palette-empty-icon", "aria-hidden": true }, (0, import_react29.createElement)(SearchIcon)), ++ (0, import_react29.createElement)("span", { className: "bt-palette-empty-label" }, t("No results")), ++ (0, import_react29.createElement)("span", { className: "bt-palette-empty-hint" }, t("Try a Bot name or words from a message")) ++ ) : sections.map(([title, items]) => (0, import_react29.createElement)( ++ import_react29.Fragment, + { key: title }, -+ (0, import_react31.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), ++ (0, import_react29.createElement)("div", { className: "bt-palette-section", role: "presentation", "data-flip": `section:${title}` }, title), + items.map((row) => { + index += 1; + const at = index; -+ return (0, import_react31.createElement)( ++ return (0, import_react29.createElement)( + "button", + { + key: row.key, @@ -16546,14 +15244,14 @@ diff --git a/client.js b/client.js + }, + onClick: () => choose(row) + }, -+ (0, import_react31.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react31.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), -+ (0, import_react31.createElement)( ++ (0, import_react29.createElement)("span", { className: "bt-palette-lead" }, row.avatar ?? (0, import_react29.createElement)("span", { className: "bt-palette-cmd" }, row.icon)), ++ (0, import_react29.createElement)( + "span", + { className: "bt-palette-text" }, -+ (0, import_react31.createElement)("span", { className: "bt-palette-title" }, row.title), -+ row.subtitle ? (0, import_react31.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null ++ (0, import_react29.createElement)("span", { className: "bt-palette-title" }, row.title), ++ row.subtitle ? (0, import_react29.createElement)("span", { className: "bt-palette-sub" }, row.subtitle) : null + ), -+ row.badge ? (0, import_react31.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null ++ row.badge ? (0, import_react29.createElement)("span", { className: "bt-palette-badge" }, row.badge) : null + ); + }) + )) @@ -16628,7 +15326,7 @@ diff --git a/client.js b/client.js // src/client/create-bot.js -var import_react19 = require("react"); -+var import_react32 = require("react"); ++var import_react30 = require("react"); var pick = (list) => list[Math.floor(Math.random() * list.length)]; var randomLook = (avoid = {}) => { const shapes = DEFAULT_SHAPES.filter((shape) => shape !== avoid.shape); @@ -16646,18 +15344,18 @@ diff --git a/client.js b/client.js - const fileRef = (0, import_react19.useRef)(null); - const frame = (0, import_react19.useRef)(null); +function CreateBot({ roster, actions, start, onBack, canGoBack, onCreated, inline = false }) { -+ const [look, setLook] = (0, import_react32.useState)(() => randomLook()); -+ const [image, setImage] = (0, import_react32.useState)(null); -+ const [name, setName] = (0, import_react32.useState)(start?.name ?? ""); -+ const [model, setModel] = (0, import_react32.useState)(() => startModel(roster)); -+ const [effort, setEffort] = (0, import_react32.useState)(void 0); -+ const [busy, setBusy] = (0, import_react32.useState)(false); -+ const [error, setError] = (0, import_react32.useState)(""); -+ const fileRef = (0, import_react32.useRef)(null); -+ const frame = (0, import_react32.useRef)(null); ++ const [look, setLook] = (0, import_react30.useState)(() => randomLook()); ++ const [image, setImage] = (0, import_react30.useState)(null); ++ const [name, setName] = (0, import_react30.useState)(start?.name ?? ""); ++ const [model, setModel] = (0, import_react30.useState)(() => startModel(roster)); ++ const [effort, setEffort] = (0, import_react30.useState)(void 0); ++ const [busy, setBusy] = (0, import_react30.useState)(false); ++ const [error, setError] = (0, import_react30.useState)(""); ++ const fileRef = (0, import_react30.useRef)(null); ++ const frame = (0, import_react30.useRef)(null); useEscape(onBack); - (0, import_react19.useEffect)(() => { -+ (0, import_react32.useEffect)(() => { ++ (0, import_react30.useEffect)(() => { if (inline) frame.current?.scrollIntoView({ block: "start", behavior: "smooth" }); }, []); const fallbackName = freshBotName(roster, t("New Bot")); @@ -16677,32 +15375,32 @@ diff --git a/client.js b/client.js }; const previewLook = image ? { ...look, image } : look; - const card = (0, import_react19.createElement)( -+ const card = (0, import_react32.createElement)( ++ const card = (0, import_react30.createElement)( "div", { className: "bt-create-card" }, - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "div", { className: "bt-create-preview" }, // The key remounts the mark, so every new pick plays the pop. - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "span", { key: `${look.shape}:${look.color}:${image ? "photo" : ""}`, className: "bt-create-pop" }, - (0, import_react19.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true }) -+ (0, import_react32.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true }) ++ (0, import_react30.createElement)(BotMark, { look: previewLook, bot: { id: "new-bot" }, size: 88, live: true, gaze: true, pokeable: true }) ), - (0, import_react19.createElement)("span", { className: "bt-create-name" }, finalName) -+ (0, import_react32.createElement)("span", { className: "bt-create-name" }, finalName) ++ (0, import_react30.createElement)("span", { className: "bt-create-name" }, finalName) ), - (0, import_react19.createElement)("div", { className: "bt-create-step" }, t("Look")), - (0, import_react19.createElement)( -+ (0, import_react32.createElement)("div", { className: "bt-create-step" }, t("Look")), -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)("div", { className: "bt-create-step" }, t("Look")), ++ (0, import_react30.createElement)( "div", { className: "bt-create-looks" }, - (0, import_react19.createElement)(CharacterGrid, { -+ (0, import_react32.createElement)(CharacterGrid, { ++ (0, import_react30.createElement)(CharacterGrid, { look, hasPhoto: Boolean(image), onShape: (shape) => { @@ -16711,20 +15409,20 @@ diff --git a/client.js b/client.js } }), - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "div", { className: "bt-actions bt-create-lookbar" }, - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { ++ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { setImage(null); setLook((current) => randomLook(current)); } }, t("Shuffle")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")), - image ? (0, import_react19.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null, - (0, import_react19.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")), -+ image ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null, -+ (0, import_react32.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { ++ (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => fileRef.current?.click() }, image ? t("Replace photo") : t("Upload photo")), ++ image ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setImage(null) }, t("Remove photo")) : null, ++ (0, import_react30.createElement)("input", { ref: fileRef, type: "file", accept: "image/png,image/jpeg,image/webp,image/gif", hidden: true, onChange: (event) => { const file = event.target.files?.[0]; event.target.value = ""; upload(file); @@ -16732,13 +15430,13 @@ diff --git a/client.js b/client.js ) ), - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "label", { className: "bt-field" }, - (0, import_react19.createElement)("span", { className: "bt-field-label" }, t("Name")), - (0, import_react19.createElement)("input", { -+ (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Name")), -+ (0, import_react32.createElement)("input", { ++ (0, import_react30.createElement)("span", { className: "bt-field-label" }, t("Name")), ++ (0, import_react30.createElement)("input", { className: "bt-input", autoFocus: !inline, value: name, @@ -16747,61 +15445,61 @@ diff --git a/client.js b/client.js } }), - problem ? (0, import_react19.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react19.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null -+ problem ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react32.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null ++ problem ? (0, import_react30.createElement)("span", { className: "bt-note bt-danger" }, hostText(problem)) : clash ? (0, import_react30.createElement)("span", { className: "bt-note bt-danger" }, t("A Bot named {name} already exists", { name: finalName })) : null ), - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "div", { className: "bt-field" }, - (0, import_react19.createElement)("span", { className: "bt-field-label" }, t("Model")), - (0, import_react19.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => { -+ (0, import_react32.createElement)("span", { className: "bt-field-label" }, t("Model")), -+ (0, import_react32.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => { ++ (0, import_react30.createElement)("span", { className: "bt-field-label" }, t("Model")), ++ (0, import_react30.createElement)(ModelMenu, { bot: { id: "new-bot", name: finalName, model: model || void 0, effort }, roster, actions, wide: true, onPick: (ref) => { setModel(ref); setEffort(void 0); }, onPickEffort: setEffort }), - (0, import_react19.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later.")) -+ (0, import_react32.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later.")) ++ (0, import_react30.createElement)("span", { className: "bt-note" }, (roster.models ?? []).length > 0 ? t("The Bot runs on this model. You can change it later in its details.") : t("Add a provider and a model in DeepSeek Harness first, or pick one in the Bot's chat later.")) ), - error ? (0, import_react19.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, - (0, import_react19.createElement)( -+ error ? (0, import_react32.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, -+ (0, import_react32.createElement)( ++ error ? (0, import_react30.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, ++ (0, import_react30.createElement)( "div", { className: "bt-create-foot" }, - (0, import_react19.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create")) + // The dialog's header already has Back and Close (#230); only the inline form needs its own. -+ inline ? (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null, -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create")) ++ inline ? (0, import_react30.createElement)("button", { type: "button", className: "bt-soft", onClick: onBack }, canGoBack ? t("Back") : t("Cancel")) : null, ++ (0, import_react30.createElement)("button", { type: "button", className: "bt-send bt-create-go", disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t("Creating…") : t("Create")) ) ); - if (inline) return (0, import_react19.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card); - return (0, import_react19.createElement)( -+ if (inline) return (0, import_react32.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card); -+ return (0, import_react32.createElement)( ++ if (inline) return (0, import_react30.createElement)("div", { ref: frame, className: "bt-create-inline", role: "group", "aria-label": t("Create Bot") }, card); ++ return (0, import_react30.createElement)( "div", { className: "bt-create", role: "dialog", "aria-label": t("Create Bot") }, - (0, import_react19.createElement)( -+ (0, import_react32.createElement)( ++ (0, import_react30.createElement)( "div", { className: "bt-create-head" }, - canGoBack ? (0, import_react19.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react19.createElement)(ChevronLeftIcon)) : (0, import_react19.createElement)("span", { className: "bt-create-gap" }), - (0, import_react19.createElement)("span", { className: "bt-create-title" }, t("Create Bot")), - (0, import_react19.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react19.createElement)(CloseIcon)) -+ canGoBack ? (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react32.createElement)(ChevronLeftIcon)) : (0, import_react32.createElement)("span", { className: "bt-create-gap" }), -+ (0, import_react32.createElement)("span", { className: "bt-create-title" }, t("Create Bot")), -+ (0, import_react32.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react32.createElement)(CloseIcon)) ++ canGoBack ? (0, import_react30.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to new chat"), onClick: onBack }, (0, import_react30.createElement)(ChevronLeftIcon)) : (0, import_react30.createElement)("span", { className: "bt-create-gap" }), ++ (0, import_react30.createElement)("span", { className: "bt-create-title" }, t("Create Bot")), ++ (0, import_react30.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close"), onClick: () => actions.closeOverlay() }, (0, import_react30.createElement)(CloseIcon)) ), - (0, import_react19.createElement)("div", { className: "bt-create-body" }, card) -+ (0, import_react32.createElement)("div", { className: "bt-create-body" }, card) ++ (0, import_react30.createElement)("div", { className: "bt-create-body" }, card) ); } // src/client/new-chat.js -var import_react20 = require("react"); -var import_dsh_client_ui_primitives4 = require("@deepseek-ai/dsh-client-ui-primitives"); -+var import_react33 = require("react"); ++var import_react31 = require("react"); function NewChat({ mode, roster, actions }) { - const [query, setQuery] = (0, import_react20.useState)(""); - const [picked, setPicked] = (0, import_react20.useState)([]); @@ -16813,20 +15511,20 @@ diff --git a/client.js b/client.js - const [cursor, setCursor] = (0, import_react20.useState)(0); - const [draft, setDraft] = (0, import_react20.useState)(mode === "create" ? { name: "", fromList: false } : null); - const inputRef = (0, import_react20.useRef)(null); -+ const [query, setQuery] = (0, import_react33.useState)(""); -+ const [picked, setPicked] = (0, import_react33.useState)([]); -+ const [group, setGroup] = (0, import_react33.useState)(mode === "group"); -+ const [compose, setCompose] = (0, import_react33.useState)(mode !== "new"); -+ const [admin, setAdmin] = (0, import_react33.useState)(null); -+ const [message, setMessage] = (0, import_react33.useState)(""); -+ const [busy, setBusy] = (0, import_react33.useState)(false); -+ const [error, setError] = (0, import_react33.useState)(""); -+ const [fix, setFix] = (0, import_react33.useState)(null); -+ const [fixNote, setFixNote] = (0, import_react33.useState)(""); -+ const [inviteLink, setInviteLink] = (0, import_react33.useState)(""); -+ const [cursor, setCursor] = (0, import_react33.useState)(0); -+ const [draft, setDraft] = (0, import_react33.useState)(mode === "create" ? { name: "", fromList: false } : null); -+ const inputRef = (0, import_react33.useRef)(null); ++ const [query, setQuery] = (0, import_react31.useState)(""); ++ const [picked, setPicked] = (0, import_react31.useState)([]); ++ const [group, setGroup] = (0, import_react31.useState)(mode === "group"); ++ const [compose, setCompose] = (0, import_react31.useState)(mode !== "new"); ++ const [admin, setAdmin] = (0, import_react31.useState)(null); ++ const [message, setMessage] = (0, import_react31.useState)(""); ++ const [busy, setBusy] = (0, import_react31.useState)(false); ++ const [error, setError] = (0, import_react31.useState)(""); ++ const [fix, setFix] = (0, import_react31.useState)(null); ++ const [fixNote, setFixNote] = (0, import_react31.useState)(""); ++ const [inviteLink, setInviteLink] = (0, import_react31.useState)(""); ++ const [cursor, setCursor] = (0, import_react31.useState)(0); ++ const [draft, setDraft] = (0, import_react31.useState)(mode === "create" ? { name: "", fromList: false } : null); ++ const inputRef = (0, import_react31.useRef)(null); const close = () => actions.closeOverlay(); useEscape(close); const paneLeft = usePaneLeft(); @@ -16928,14 +15626,14 @@ diff --git a/client.js b/client.js - options.push({ key: "new", icon: (0, import_react20.createElement)(PlusIcon), label: t("Create new Bot"), run: createBot }); - options.push({ key: "group", icon: (0, import_react20.createElement)(PeopleIcon), label: t("Create group chat"), run: () => { + if (menuOnly) { -+ options.push({ key: "chat", icon: (0, import_react33.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => { ++ options.push({ key: "chat", icon: (0, import_react31.createElement)(ChatIcon), label: t("New chat"), hint: t("Message one of your Bots"), run: () => { + setCompose(true); + setCursor(0); + setTimeout(() => inputRef.current?.focus()); + } }); + } -+ options.push({ key: "new", icon: (0, import_react33.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot }); -+ options.push({ key: "group", icon: (0, import_react33.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => { ++ options.push({ key: "new", icon: (0, import_react31.createElement)(BotIcon), label: t("New Bot"), hint: t("Set up a Bot with its own role"), run: createBot }); ++ options.push({ key: "group", icon: (0, import_react31.createElement)(PeopleIcon), label: t("New group chat"), hint: t("Chat with several of your Bots"), run: () => { setGroup(true); + setCompose(true); setCursor(0); @@ -16944,7 +15642,7 @@ diff --git a/client.js b/client.js + for (const action of newChatActionRows({ mode, group, picked, query, roster })) { + options.push({ + key: action.key, -+ icon: action.icon ? (0, import_react33.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react33.createElement)(PeopleIcon) }) : (0, import_react33.createElement)(PeopleIcon), ++ icon: action.icon ? (0, import_react31.createElement)(SlotIcon, { id: action.key, icon: action.icon, fallback: (0, import_react31.createElement)(PeopleIcon) }) : (0, import_react31.createElement)(PeopleIcon), + label: t(action.label), + hint: action.hint ? t(action.hint) : void 0, + run: () => { @@ -16957,7 +15655,7 @@ diff --git a/client.js b/client.js + } if ((roster.agents ?? []).length === 0) { - options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { -+ options.push({ key: "agent", icon: (0, import_react33.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { ++ options.push({ key: "agent", icon: (0, import_react31.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { await actions.addAgent(); close(); }) }); @@ -16971,22 +15669,22 @@ diff --git a/client.js b/client.js + } } - if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react20.createElement)(PlusIcon), label: t("Create new Bot “{name}”", { name: query.trim() }), run: createBot }); -+ if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react33.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot }); ++ if (wanted !== "" && !exact && mode !== "search") options.push({ key: "create", icon: (0, import_react31.createElement)(PlusIcon), label: t("New Bot “{name}”", { name: query.trim() }), run: createBot }); const active = Math.min(cursor, Math.max(options.length - 1, 0)); const listed = options.map((option) => option.key).join("\n"); const glide = useGlide(`${active} ${listed}`, { axis: "y", ...QUICK_EDGES }); useFlip(glide.box, listed, "[data-flip]"); - const drop = (0, import_react20.useRef)(null); -+ const drop = (0, import_react33.useRef)(null); ++ const drop = (0, import_react31.useRef)(null); useMorph(drop, listed); if (draft) { - return (0, import_react20.createElement)( -+ return (0, import_react33.createElement)( ++ return (0, import_react31.createElement)( "div", { className: "bt-pane", style: { left: paneLeft } }, - (0, import_react20.createElement)(CreateBot, { -+ (0, import_react33.createElement)(CreateBot, { ++ (0, import_react31.createElement)(CreateBot, { roster, actions, start: draft, @@ -16999,7 +15697,7 @@ diff --git a/client.js b/client.js else if (admin !== null) setAdmin(null); }; - return (0, import_react20.createElement)( -+ return (0, import_react33.createElement)( ++ return (0, import_react31.createElement)( "div", - { className: "bt-pane", style: { left: paneLeft } }, - (0, import_react20.createElement)( @@ -17011,43 +15709,43 @@ diff --git a/client.js b/client.js + if (event.target === event.currentTarget) close(); + } : void 0 + }, -+ (0, import_react33.createElement)( ++ (0, import_react31.createElement)( "div", - { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : t("New chat") }, - (0, import_react20.createElement)( + { className: "bt-newchat", role: "dialog", "aria-label": mode === "search" ? t("Search") : menuOnly ? t("New…") : t("New chat"), "data-menu": menuOnly || void 0 }, + // The popover needs no title row: Escape or a press outside closes it. -+ menuOnly ? null : (0, import_react33.createElement)( ++ menuOnly ? null : (0, import_react31.createElement)( "div", { className: "bt-to" }, - (0, import_react20.createElement)("span", { className: "bt-to-label" }, t("To:")), - adminBot ? (0, import_react20.createElement)( -+ (0, import_react33.createElement)("span", { className: "bt-to-label" }, t("To:")), -+ adminBot ? (0, import_react33.createElement)( ++ (0, import_react31.createElement)("span", { className: "bt-to-label" }, t("To:")), ++ adminBot ? (0, import_react31.createElement)( "span", { key: adminBot.id, className: "bt-token bt-token-admin", title: t("Admin") }, - (0, import_react20.createElement)(BotMark, { bot: adminBot, size: 16 }), -+ (0, import_react33.createElement)(BotMark, { bot: adminBot, size: 16 }), ++ (0, import_react31.createElement)(BotMark, { bot: adminBot, size: 16 }), adminBot.name, - (0, import_react20.createElement)(AdminStar), - (0, import_react20.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×") -+ (0, import_react33.createElement)(AdminStar), -+ (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×") ++ (0, import_react31.createElement)(AdminStar), ++ (0, import_react31.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: adminBot.name }), onClick: () => setAdmin(null) }, "×") ) : null, - choosingAdmin ? (0, import_react20.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react20.createElement)(AdminStar)) : null, - pickedBots.map((bot) => (0, import_react20.createElement)( -+ choosingAdmin ? (0, import_react33.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react33.createElement)(AdminStar)) : null, -+ pickedBots.map((bot) => (0, import_react33.createElement)( ++ choosingAdmin ? (0, import_react31.createElement)("span", { className: "bt-to-star", "aria-hidden": true }, (0, import_react31.createElement)(AdminStar)) : null, ++ pickedBots.map((bot) => (0, import_react31.createElement)( "span", { key: bot.id, className: "bt-token" }, - (0, import_react20.createElement)(BotMark, { bot, size: 16 }), -+ (0, import_react33.createElement)(BotMark, { bot, size: 16 }), ++ (0, import_react31.createElement)(BotMark, { bot, size: 16 }), bot.name, - (0, import_react20.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×") -+ (0, import_react33.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×") ++ (0, import_react31.createElement)("button", { type: "button", "aria-label": t("Remove {name}", { name: bot.name }), onClick: () => setPicked((current) => current.filter((id) => id !== bot.id)) }, "×") )), - (0, import_react20.createElement)("input", { -+ (0, import_react33.createElement)("input", { ++ (0, import_react31.createElement)("input", { ref: inputRef, autoFocus: true, value: query, @@ -17056,7 +15754,7 @@ diff --git a/client.js b/client.js } }), - group && admin !== null ? (0, import_react20.createElement)("button", { -+ group && admin !== null ? (0, import_react33.createElement)("button", { ++ group && admin !== null ? (0, import_react31.createElement)("button", { type: "button", className: "bt-send bt-to-create", disabled: busy || picked.length === 0, @@ -17064,16 +15762,16 @@ diff --git a/client.js b/client.js onClick: createGroup }, t("Create group chat")) : null, - (0, import_react20.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") -+ (0, import_react33.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") ++ (0, import_react31.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Close new chat"), onClick: close }, "×") ), - (0, import_react20.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react20.createElement)( -+ (0, import_react33.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react33.createElement)( ++ (0, import_react31.createElement)("div", { ref: drop, className: "bt-drop" }, (0, import_react31.createElement)( "div", { ref: glide.box, role: "listbox", "aria-label": t("Recipients"), className: "bt-drop-list" }, - (0, import_react20.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), - options.length === 0 ? (0, import_react20.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react20.createElement)( -+ (0, import_react33.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), -+ options.length === 0 ? (0, import_react33.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react33.createElement)( ++ (0, import_react31.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ options.length === 0 ? (0, import_react31.createElement)("div", { className: "bt-hint" }, mode === "search" ? t("No Bots match") : t("Type a name to create a Bot")) : options.map((option, index) => (0, import_react31.createElement)( "button", { key: option.key, @@ -17084,42 +15782,42 @@ diff --git a/client.js b/client.js - option.bot ? (0, import_react20.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react20.createElement)("span", { className: "bt-option-icon" }, option.icon), - (0, import_react20.createElement)("span", { className: "bt-option-label" }, option.label), - option.hint && index === active ? (0, import_react20.createElement)("span", { className: "bt-option-hint" }, option.hint) : null -+ option.bot ? (0, import_react33.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react33.createElement)("span", { className: "bt-option-icon" }, option.icon), ++ option.bot ? (0, import_react31.createElement)(BotMark, { bot: option.bot, size: 22 }) : (0, import_react31.createElement)("span", { className: "bt-option-icon" }, option.icon), + // An action row's description sits under its title and is always there (#283): a long line + // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows. + // A Bot row's short hint stays beside the name and shows on the active row only. -+ option.bot || !option.hint ? (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react33.createElement)("span", { className: "bt-option-copy" }, (0, import_react33.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react33.createElement)("span", { className: "bt-option-sub" }, option.hint)), -+ option.bot && option.hint && index === active ? (0, import_react33.createElement)("span", { className: "bt-option-hint" }, option.hint) : null ++ option.bot || !option.hint ? (0, import_react31.createElement)("span", { className: "bt-option-label" }, option.label) : (0, import_react31.createElement)("span", { className: "bt-option-copy" }, (0, import_react31.createElement)("span", { className: "bt-option-label" }, option.label), (0, import_react31.createElement)("span", { className: "bt-option-sub" }, option.hint)), ++ option.bot && option.hint && index === active ? (0, import_react31.createElement)("span", { className: "bt-option-hint" }, option.hint) : null )) )), - (0, import_react20.createElement)("div", { className: "bt-nc-spacer" }), - error ? (0, import_react20.createElement)("div", { className: "bt-error" }, error) : null, - (0, import_react20.createElement)( -+ menuOnly ? null : (0, import_react33.createElement)("div", { className: "bt-nc-spacer" }), -+ error && fix ? (0, import_react33.createElement)( ++ menuOnly ? null : (0, import_react31.createElement)("div", { className: "bt-nc-spacer" }), ++ error && fix ? (0, import_react31.createElement)( + "div", + { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, -+ (0, import_react33.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), -+ (0, import_react33.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ -+ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react33.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { ++ (0, import_react31.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), ++ (0, import_react31.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ ++ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react31.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { + void copyInvite(bot); + } }, t("Copy invite link for {name}", { name: bot.name })) : null, -+ (0, import_react33.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) ++ (0, import_react31.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) + ])), -+ fixNote ? (0, import_react33.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null, -+ inviteLink ? (0, import_react33.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null -+ ) : error ? (0, import_react33.createElement)("div", { className: "bt-error" }, error) : null, -+ menuOnly ? null : (0, import_react33.createElement)( ++ fixNote ? (0, import_react31.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null, ++ inviteLink ? (0, import_react31.createElement)("input", { className: "bt-input bt-fix-link", type: "text", readOnly: true, value: inviteLink, "aria-label": t("Invite link"), "data-fix-link": "", autoFocus: true, onFocus: (event) => event.target.select(), onClick: (event) => event.target.select() }) : null ++ ) : error ? (0, import_react31.createElement)("div", { className: "bt-error" }, error) : null, ++ menuOnly ? null : (0, import_react31.createElement)( "div", { className: "bt-nc-compose" }, - (0, import_react20.createElement)( -+ (0, import_react33.createElement)( ++ (0, import_react31.createElement)( "div", { className: "bt-nc-card" }, - (0, import_react20.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react20.createElement)(PlusIcon)), - (0, import_react20.createElement)("textarea", { -+ (0, import_react33.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react33.createElement)(PlusIcon)), -+ (0, import_react33.createElement)("textarea", { ++ (0, import_react31.createElement)("span", { className: "bt-nc-plus", "aria-hidden": true }, (0, import_react31.createElement)(PlusIcon)), ++ (0, import_react31.createElement)("textarea", { rows: 1, value: message, placeholder, @@ -17128,7 +15826,7 @@ diff --git a/client.js b/client.js } }), - (0, import_react20.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react20.createElement)(ArrowUpIcon)) -+ (0, import_react33.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react33.createElement)(ArrowUpIcon)) ++ (0, import_react31.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react31.createElement)(ArrowUpIcon)) ) ) ) @@ -17137,13 +15835,13 @@ diff --git a/client.js b/client.js // src/client/exchange.js -var import_react21 = require("react"); -+var import_react34 = require("react"); ++var import_react32 = require("react"); var SHEET_EXIT_MS = 220; function ExchangeDialog({ pair, roster, actions }) { - const [entries2, setEntries] = (0, import_react21.useState)(null); - const [leaving, setLeaving] = (0, import_react21.useState)(false); -+ const [entries2, setEntries] = (0, import_react34.useState)(null); -+ const [leaving, setLeaving] = (0, import_react34.useState)(false); ++ const [entries2, setEntries] = (0, import_react32.useState)(null); ++ const [leaving, setLeaving] = (0, import_react32.useState)(false); const close = () => { if (leaving) return; setLeaving(true); @@ -17151,7 +15849,7 @@ diff --git a/client.js b/client.js }; useEscape(close); - (0, import_react21.useEffect)(() => { -+ (0, import_react34.useEffect)(() => { ++ (0, import_react32.useEffect)(() => { let stop = false; let timer; let seen = null; @@ -17160,63 +15858,63 @@ diff --git a/client.js b/client.js let lastDay = 0; const paneLeft = usePaneLeft(); - return (0, import_react21.createElement)( -+ return (0, import_react34.createElement)( ++ return (0, import_react32.createElement)( "div", { className: "bt-pane bt-exchange", style: { left: paneLeft }, "data-leaving": leaving || void 0 }, - (0, import_react21.createElement)( -+ (0, import_react34.createElement)( ++ (0, import_react32.createElement)( "div", { className: "bt-dialog", role: "dialog", "aria-label": t("Bot exchange") }, - (0, import_react21.createElement)("div", { className: "bt-head" }, (0, import_react21.createElement)( -+ (0, import_react34.createElement)("div", { className: "bt-head" }, (0, import_react34.createElement)( ++ (0, import_react32.createElement)("div", { className: "bt-head" }, (0, import_react32.createElement)( "span", { className: "bt-pill", style: { cursor: "default" } }, - left ? (0, import_react21.createElement)(BotMark, { bot: left, size: 22 }) : null, -+ left ? (0, import_react34.createElement)(BotMark, { bot: left, size: 22 }) : null, ++ left ? (0, import_react32.createElement)(BotMark, { bot: left, size: 22 }) : null, left?.name ?? "?", - (0, import_react21.createElement)("span", { className: "bt-swap" }, "⇄"), - right ? (0, import_react21.createElement)(BotMark, { bot: right, size: 22 }) : null, -+ (0, import_react34.createElement)("span", { className: "bt-swap" }, "⇄"), -+ right ? (0, import_react34.createElement)(BotMark, { bot: right, size: 22 }) : null, ++ (0, import_react32.createElement)("span", { className: "bt-swap" }, "⇄"), ++ right ? (0, import_react32.createElement)(BotMark, { bot: right, size: 22 }) : null, right?.name ?? "?" )), - (0, import_react21.createElement)( -+ (0, import_react34.createElement)( ++ (0, import_react32.createElement)( "div", { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") }, - entries2 === null ? (0, import_react21.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { -+ entries2 === null ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react34.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { ++ entries2 === null ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; const showDay = entry.time - lastDay > 30 * 60 * 1e3; lastDay = entry.time; - return (0, import_react21.createElement)( - import_react21.Fragment, -+ return (0, import_react34.createElement)( -+ import_react34.Fragment, ++ return (0, import_react32.createElement)( ++ import_react32.Fragment, { key: entry.id }, - showDay ? (0, import_react21.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, - (0, import_react21.createElement)( -+ showDay ? (0, import_react34.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, -+ (0, import_react34.createElement)( ++ showDay ? (0, import_react32.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, ++ (0, import_react32.createElement)( "div", { className: "bt-group-msg" }, - (0, import_react21.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), - (0, import_react21.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react21.createElement)("span", { className: "bt-lead" }, (0, import_react21.createElement)(BotMark, { bot, size: 22 })) }) -+ (0, import_react34.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), -+ (0, import_react34.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react34.createElement)("span", { className: "bt-lead" }, (0, import_react34.createElement)(BotMark, { bot, size: 22 })) }) ++ (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), ++ (0, import_react32.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) ) ); }) ), - (0, import_react21.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react21.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat"))) -+ (0, import_react34.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react34.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat"))) ++ (0, import_react32.createElement)("div", { className: "bt-dialog-foot" }, (0, import_react32.createElement)("button", { type: "button", className: "bt-soft", onClick: close }, t("Close Chat"))) ) ); } // src/client/voice.js -var import_react22 = require("react"); -+var import_react35 = require("react"); ++var import_react33 = require("react"); var plainSpeech = (text) => stripHeader(text).replace(/```[\s\S]*?```/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[*_`#>|~-]+/g, " ").replace(/\s+/g, " ").trim(); var VOICE_LABELS = { listening: "Listening", @@ -17226,8 +15924,8 @@ diff --git a/client.js b/client.js function useMicLevel(enabled) { - const [level, setLevel] = (0, import_react22.useState)(0); - (0, import_react22.useEffect)(() => { -+ const [level, setLevel] = (0, import_react35.useState)(0); -+ (0, import_react35.useEffect)(() => { ++ const [level, setLevel] = (0, import_react33.useState)(0); ++ (0, import_react33.useEffect)(() => { if (!enabled || !navigator.mediaDevices?.getUserMedia) return void 0; let stream = null; let audio = null; @@ -17252,19 +15950,19 @@ diff --git a/client.js b/client.js - const [cycle, setCycle] = (0, import_react22.useState)(0); - const waiting = (0, import_react22.useRef)(null); - const reply = (0, import_react22.useRef)(lastReply); -+ const [speech] = (0, import_react35.useState)(() => actions.speech()); -+ const [phase, setPhase] = (0, import_react35.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported"); -+ const [catalog, setCatalog] = (0, import_react35.useState)(null); -+ const [heard, setHeard] = (0, import_react35.useState)(""); -+ const [said, setSaid] = (0, import_react35.useState)(""); -+ const [note, setNote] = (0, import_react35.useState)(""); -+ const [serviceLevel, setServiceLevel] = (0, import_react35.useState)(0); -+ const [cycle, setCycle] = (0, import_react35.useState)(0); -+ const waiting = (0, import_react35.useRef)(null); -+ const reply = (0, import_react35.useRef)(lastReply); ++ const [speech] = (0, import_react33.useState)(() => actions.speech()); ++ const [phase, setPhase] = (0, import_react33.useState)(speech ? "starting" : speechApi() ? "listening" : "unsupported"); ++ const [catalog, setCatalog] = (0, import_react33.useState)(null); ++ const [heard, setHeard] = (0, import_react33.useState)(""); ++ const [said, setSaid] = (0, import_react33.useState)(""); ++ const [note, setNote] = (0, import_react33.useState)(""); ++ const [serviceLevel, setServiceLevel] = (0, import_react33.useState)(0); ++ const [cycle, setCycle] = (0, import_react33.useState)(0); ++ const waiting = (0, import_react33.useRef)(null); ++ const reply = (0, import_react33.useRef)(lastReply); reply.current = lastReply; - const turn = (0, import_react22.useRef)(0); -+ const turn = (0, import_react35.useRef)(0); ++ const turn = (0, import_react33.useRef)(0); const paneLeft = usePaneLeft(); const close = () => { turn.current += 1; @@ -17273,13 +15971,13 @@ diff --git a/client.js b/client.js submit(text); }); - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.useEffect)(() => { if (!note) return void 0; const timer = setTimeout(() => setNote(""), 2600); return () => clearTimeout(timer); }, [note]); - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.useEffect)(() => { if (!speech) return void 0; let live = true; speechCatalog(speech).then(({ catalog: next, usable }) => { @@ -17288,18 +15986,18 @@ diff --git a/client.js b/client.js }, [speech]); const listen = phase === "listening"; - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.useEffect)(() => { if (speech || !listen || dictation.listening) return void 0; const timer = setTimeout(() => dictation.start({ continuous: false, onEnd: () => { } }), 250); return () => clearTimeout(timer); }, [listen, dictation.listening]); - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.useEffect)(() => { if (phase !== "listening") dictation.stop(); }, [phase]); - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.useEffect)(() => { if (!speech || !catalog || !listen) return void 0; const own = turn.current; let capture = null; @@ -17308,7 +16006,7 @@ diff --git a/client.js b/client.js }; }, [speech, catalog, listen, cycle]); - (0, import_react22.useEffect)(() => { -+ (0, import_react35.useEffect)(() => { ++ (0, import_react33.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; @@ -17317,7 +16015,7 @@ diff --git a/client.js b/client.js window.speechSynthesis.speak(utterance); }, [phase, running, lastReply]); - (0, import_react22.useEffect)(() => () => window.speechSynthesis?.cancel(), []); -+ (0, import_react35.useEffect)(() => () => window.speechSynthesis?.cancel(), []); ++ (0, import_react33.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"; @@ -17326,49 +16024,63 @@ diff --git a/client.js b/client.js const caption = phase === "speaking" ? said : heard; const shownNote = note || dictation.note; - return (0, import_react22.createElement)( -+ return (0, import_react35.createElement)( ++ return (0, import_react33.createElement)( "div", { className: "bt-pane bt-voice-pane", style: { left: paneLeft } }, - (0, import_react22.createElement)( -+ (0, import_react35.createElement)( ++ (0, import_react33.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 } }, ++ (0, import_react33.createElement)( + "div", + { className: "bt-voice-orb", style: { "--bt-level": level } }, - (0, import_react22.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), - (0, import_react22.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), - bot ? (0, import_react22.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react22.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null -- ), ++ (0, import_react33.createElement)("span", { className: "bt-voice-ring", "aria-hidden": true }), ++ (0, import_react33.createElement)("span", { className: "bt-voice-ring bt-voice-ring-2", "aria-hidden": true }), ++ bot ? (0, import_react33.createElement)(BotMark, { bot, size: 112, state: markState, live: true, gaze: phase === "listening" }) : room2 ? (0, import_react33.createElement)(RoomAvatar, { room: room2, roster, size: 112 }) : null + ), - (0, import_react22.createElement)("div", { className: "bt-voice-name" }, name), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-status", role: "status" }, ++ (0, import_react33.createElement)("div", { className: "bt-voice-name" }, name), ++ (0, import_react33.createElement)( + "div", + { className: "bt-voice-status", role: "status" }, - phase === "thinking" ? (0, import_react22.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react22.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" -- ), ++ phase === "thinking" ? (0, import_react33.createElement)(Shimmer, null, t("Thinking…")) : phase === "transcribing" ? (0, import_react33.createElement)(Shimmer, null, t("Recognizing…")) : VOICE_LABELS[phase] ? t(VOICE_LABELS[phase]) : "" + ), - (0, import_react22.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), - openSetup ? (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { -- close(); -- openSetup(); -- } }, t("Set up voice input")) : null, ++ (0, import_react33.createElement)("div", { className: "bt-voice-caption", key: `${phase}:${caption.slice(0, 12)}` }, caption), ++ openSetup ? (0, import_react33.createElement)("button", { type: "button", className: "bt-voice-setup", onClick: () => { + close(); + openSetup(); + } }, t("Set up voice input")) : null, - shownNote ? (0, import_react22.createElement)("div", { className: "bt-voice-note" }, shownNote) : null -- ), ++ shownNote ? (0, import_react33.createElement)("div", { className: "bt-voice-note" }, shownNote) : null + ), - (0, import_react22.createElement)( -- "div", -- { className: "bt-voice-bar" }, ++ (0, import_react33.createElement)( + "div", + { className: "bt-voice-bar" }, - (0, import_react22.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_react22.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react22.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), -- (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react22.createElement)(CloseIcon)) -- ) -- ); --} -- ++ (0, import_react33.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_react22.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react22.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), +- (0, import_react22.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react22.createElement)(CloseIcon)) ++ }, (0, import_react33.createElement)(MicIcon, { size: 20 }), phase === "paused" ? (0, import_react33.createElement)("span", { className: "bt-voice-slash", "aria-hidden": true }) : null), ++ (0, import_react33.createElement)("button", { type: "button", className: "bt-voice-btn bt-voice-end", "aria-label": t("End voice chat"), onClick: close }, (0, import_react33.createElement)(CloseIcon)) + ) + ); + } + -// src/client/group-settings.js -var import_react23 = require("react"); -var ROOM_MODES = [ @@ -17379,7 +16091,30 @@ diff --git a/client.js b/client.js -function roomSubtitle(room2, roster) { - const admin = roster.byId[room2.admin]; - return [t("{count} members", { count: room2.members.length }), admin ? t("Admin {name}", { name: admin.name }) : t("No admin")].join(" · "); --} ++// src/client/workflows.js ++var import_react34 = 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 GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { - const [adding, setAdding] = (0, import_react23.useState)(false); - const [confirming, setConfirming] = (0, import_react23.useState)(false); @@ -17397,21 +16132,168 @@ diff --git a/client.js b/client.js - const openBot = (id) => { - actions.closeSettings(); - actions.openSession(id); -- }; ++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 remove = () => { - if (!confirming) { - setConfirming(true); - return; -- } ++} ++var field = (label, value, onChange, options = {}) => (0, import_react34.createElement)( ++ "label", ++ { className: "bt-wf-field", key: label }, ++ (0, import_react34.createElement)("span", { className: "bt-wf-label" }, label), ++ (0, import_react34.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_react34.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_react34.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_react34.createElement)( ++ "g", ++ { key: box.id, className: "bt-wf-box", "data-node": box.id, transform: `translate(${box.x} ${box.y})` }, ++ (0, import_react34.createElement)("rect", { width: box.width, height: box.height, rx: 12 }), ++ (0, import_react34.createElement)("text", { x: 14, y: 26, className: "bt-wf-box-name" }, box.name), ++ (0, import_react34.createElement)("text", { x: 14, y: 48, className: "bt-wf-box-role" }, box.role), ++ (0, import_react34.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_react34.createElement)( ++ "div", ++ { className: "bt-wf-preview", "data-facts": preview.facts }, ++ preview.facts === "unavailable" ? (0, import_react34.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_react34.createElement)( ++ "div", ++ { key: row.id, className: "bt-wf-guard", "data-state": row.state }, ++ (0, import_react34.createElement)("span", { className: "bt-wf-badge", "data-state": row.state }, guardState(row)), ++ (0, import_react34.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_react34.createElement)("span", { className: "bt-wf-guard-line" }, row.sentence) : null ++ )) ++ ); ++} ++function WorkflowsPage({ actions }) { ++ const [list, setList] = (0, import_react34.useState)(null); ++ const [opened, setOpened] = (0, import_react34.useState)(null); ++ const [draft, setDraft] = (0, import_react34.useState)(null); ++ const [node, setNode] = (0, import_react34.useState)(null); ++ const [preview, setPreview] = (0, import_react34.useState)(null); ++ const [error, setError] = (0, import_react34.useState)(""); ++ const [saved, setSaved] = (0, import_react34.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_react34.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; + } - void Promise.resolve(run(actions.deleteRoom(room2.id))).then((done) => { - if (done) onDeleted?.(); -- }); -- }; ++ 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; + }); + }; - return (0, import_react23.createElement)( -- "div", ++ const editable = opened?.workflow.editable === true; ++ const current = draft?.nodes?.find((entry) => entry.id === node) ?? null; ++ return (0, import_react34.createElement)( + "div", - { className: "bt-bs" }, - (0, import_react23.createElement)( -- "div", ++ { className: "bt-wf-page" }, ++ (0, import_react34.createElement)( + "div", - { className: "bt-bs-hero" }, - (0, import_react23.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - (0, import_react23.createElement)( @@ -17434,16 +16316,29 @@ diff --git a/client.js b/client.js - { label: t("Notice"), hint: t("Every member reads it before speaking in this group."), stack: true }, - (0, import_react23.createElement)(InlineText, { label: t("Group notice"), value: room2.notice, multiline: true, maxLength: 2e3, placeholder: t("Shared instructions every member reads before speaking…"), onSave: (notice) => update({ notice }) }) - ) -- ), ++ { className: "bt-wf-head" }, ++ (0, import_react34.createElement)("h3", null, (0, import_react34.createElement)(WorkflowIcon), t("Workflows")), ++ (0, import_react34.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.")) + ), - (0, import_react23.createElement)( - Card, - { title: t("Who answers") }, - (0, import_react23.createElement)( -- "div", ++ error ? (0, import_react34.createElement)("div", { className: "bt-error", role: "alert" }, error) : null, ++ saved ? (0, import_react34.createElement)("div", { className: "bt-wf-saved", role: "status" }, saved) : null, ++ (0, import_react34.createElement)( ++ "div", ++ { className: "bt-wf-list" }, ++ ["presets", "own"].map((group) => (0, import_react34.createElement)( + "div", - { className: "bt-set-row bt-bs-row bt-bs-stack" }, - (0, import_react23.createElement)(Segmented, { label: t("Who answers"), value: mode }, ROOM_MODES.map(([id, label]) => (0, import_react23.createElement)("button", { - key: id, -- type: "button", ++ { key: group, className: "bt-wf-group" }, ++ (0, import_react34.createElement)("span", { className: "bt-wf-group-name" }, group === "presets" ? t("Factory presets") : t("Yours")), ++ (list?.[group] ?? []).map((entry) => (0, import_react34.createElement)("button", { ++ key: `${group}:${entry.id}`, + type: "button", - "aria-pressed": mode === id, - disabled: id === "admin" && !admin, - title: id === "admin" && !admin ? t("Choose an admin first") : void 0, @@ -17453,12 +16348,23 @@ diff --git a/client.js b/client.js - }, t(label)))), - (0, import_react23.createElement)("span", { key: mode, className: "bt-set-hint bt-veil" }, `${t(modeHint)} ${t("@mentions always pick who answers.")}`) - ) -- ), ++ 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_react34.createElement)("span", { className: "bt-wf-default" }, t("project default")) : null)) ++ )), ++ list === null ? (0, import_react34.createElement)("span", { className: "bt-wf-hint" }, t("Reading workflows…")) : null + ), - (0, import_react23.createElement)( - Card, - { title: t("Working folder") }, - (0, import_react23.createElement)( -+ (0, import_react35.createElement)( ++ opened === null ? null : (0, import_react34.createElement)( ++ "div", ++ { className: "bt-wf-doc" }, ++ (0, import_react34.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"))), @@ -17471,7 +16377,19 @@ diff --git a/client.js b/client.js - members.map((member) => { - const leads = room2.admin === member.id; - return (0, import_react23.createElement)( -- "div", ++ { className: "bt-wf-doc-head" }, ++ (0, import_react34.createElement)("strong", null, opened.workflow.name), ++ (0, import_react34.createElement)("span", { className: "bt-wf-path" }, `workflows/${opened.workflow.id}.json`), ++ (0, import_react34.createElement)("button", { type: "button", className: "bt-wf-action", onClick: customize }, t("Customize")), ++ editable ? (0, import_react34.createElement)("button", { type: "button", className: "bt-wf-action", onClick: save }, t("Save")) : null, ++ (0, import_react34.createElement)("button", { type: "button", className: "bt-wf-action", onClick: useDefault }, t("Use for this project")) ++ ), ++ (0, import_react34.createElement)(Flow, { layout: opened.layout }), ++ (0, import_react34.createElement)( ++ "div", ++ { className: "bt-wf-table" }, ++ (0, import_react34.createElement)( + "div", - { key: member.id, className: "bt-set-row bt-bs-row bt-gs-member" }, - (0, import_react23.createElement)( - "button", @@ -17509,24 +16427,7 @@ 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") }, @@ -17538,24 +16439,14 @@ diff --git a/client.js b/client.js - { className: "bt-set-copy" }, - (0, import_react23.createElement)("span", { className: "bt-bs-label" }, confirming ? t("Delete {name}?", { name: room2.name }) : t("Delete group")), - (0, import_react23.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.")) -- ), ++ { className: "bt-wf-row bt-wf-row-head" }, ++ ["id", "name", "role", "inputs", "outputs", "guards", "refusal"].map((key) => (0, import_react34.createElement)("span", { key }, key)) + ), - (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)([]); @@ -17565,42 +16456,7 @@ 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); @@ -17610,104 +16466,7 @@ diff --git a/client.js b/client.js - })); - if (done && room2) onCreated(room2); - else setBusy(false); -+} -+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 chip = (bot, on, onClick, role) => (0, import_react23.createElement)("button", { - key: bot.id, - type: "button", @@ -17717,152 +16476,66 @@ 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 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", +- "div", - { className: "bt-set-card bt-ng" }, - (0, import_react23.createElement)( -+ { className: "bt-wf-page" }, -+ (0, import_react36.createElement)( - "div", +- "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)( -- "div", ++ (draft?.nodes ?? []).map((entry) => (0, import_react34.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_react34.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_react34.createElement)("span", null, entry.name), ++ editable ? field("role", entry.role, (value) => setDraft(applyNodeField(draft, entry.id, "role", value))) : (0, import_react34.createElement)("span", null, entry.role), ++ editable ? field("inputs", formatList(entry.inputs), (value) => setDraft(applyNodeField(draft, entry.id, "inputs", value))) : (0, import_react34.createElement)("span", null, formatList(entry.inputs)), ++ editable ? field("outputs", formatList(entry.outputs), (value) => setDraft(applyNodeField(draft, entry.id, "outputs", value))) : (0, import_react34.createElement)("span", null, formatList(entry.outputs)), ++ editable ? (0, import_react34.createElement)("textarea", { className: "bt-wf-guards-input", value: guardLines(entry.guards), onChange: (event) => setDraft(applyNodeField(draft, entry.id, "guards", event.target.value)) }) : (0, import_react34.createElement)("span", null, guardLines(entry.guards)), ++ editable ? field("refusal", entry.refusal, (value) => setDraft(applyNodeField(draft, entry.id, "refusal", value))) : (0, import_react34.createElement)("span", null, entry.refusal) ++ )) ++ ), ++ (0, import_react34.createElement)( + "div", - { className: "bt-ng-picks", role: "radiogroup", "aria-label": t("Group admin") }, - roster.bots.map((bot) => chip(bot, admin === bot.id, () => pickAdmin(bot.id), "radio")) - ) -+ { 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)( -+ 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", +- "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-wf-list" }, -+ ["presets", "own"].map((group) => (0, import_react36.createElement)( ++ { className: "bt-wf-edges" }, ++ (0, import_react34.createElement)("span", { className: "bt-wf-group-name" }, t("Edges")), ++ (draft?.edges ?? []).map((edge) => (0, import_react34.createElement)("span", { key: `${edge.from}->${edge.to}`, className: "bt-wf-edge-row" }, `${edge.from} → ${edge.to}`)) ++ ), ++ (0, import_react34.createElement)( "div", - { className: "bt-ng-picks", role: "group", "aria-label": t("Members") }, - roster.bots.filter((bot) => bot.id !== admin).map((bot) => chip(bot, members.includes(bot.id), () => toggle(bot.id), "checkbox")) -- ) ++ { className: "bt-wf-node" }, ++ (0, import_react34.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_react34.createElement)("span", { className: "bt-wf-hint" }, t("This node has no guards, so nothing refuses it but its inputs.")) : (0, import_react34.createElement)(Preview, { preview }) + ) - ), - admin === null || members.length === 0 ? null : (0, import_react23.createElement)( - "label", - { 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) }) -+ { 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)( -+ opened === null ? null : (0, import_react36.createElement)( - "div", +- "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-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 }) -+ ) ) ); } @@ -17911,7 +16584,7 @@ diff --git a/client.js b/client.js // src/client/usage-page.js -var import_react24 = require("react"); -+var import_react37 = require("react"); ++var import_react35 = require("react"); var HEAT_WEEKS = 53; var TOP_SERIES = 7; var RING_R = 42; @@ -17927,9 +16600,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_react37.useState)(0); -+ const from = (0, import_react37.useRef)(0); -+ (0, import_react37.useEffect)(() => { ++ const [shown, setShown] = (0, import_react35.useState)(0); ++ const from = (0, import_react35.useRef)(0); ++ (0, import_react35.useEffect)(() => { if (reducedMotion() || typeof requestAnimationFrame !== "function") { from.current = value; setShown(value); @@ -17938,10 +16611,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_react37.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react37.createElement)(icon3)); ++var glyphBox = (size, icon3) => (0, import_react35.createElement)("span", { className: "bt-us-glyph", style: { width: size, height: size } }, (0, import_react35.createElement)(icon3)); function ModelGlyph({ name, paint, size }) { - return (0, import_react24.createElement)("span", { -+ return (0, import_react37.createElement)("span", { ++ return (0, import_react35.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))` } @@ -17950,7 +16623,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_react37.createElement)(BotAvatar, { bot, size, badge: false, live: false }) ++ glyph: (size) => (0, import_react35.createElement)(BotAvatar, { bot, size, badge: false, live: false }) }; } if (owner.kind === "room") { @@ -17959,7 +16632,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_react37.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) ++ glyph: (size) => room2 ? (0, import_react35.createElement)(RoomAvatar, { room: room2, roster, size }) : glyphBox(size, ChatsIcon) }; } if (owner.kind === "agent") { @@ -17968,13 +16641,13 @@ diff --git a/client.js b/client.js const glide = useGlide(`${value} ${names}`, { box: boxRef }); - return (0, import_react24.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react35.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_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)) ++ (0, import_react35.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ options.map(([id, text, icon3]) => (0, import_react35.createElement)("button", { key: id, type: "button", "aria-pressed": value === id, onClick: () => onChange(id) }, icon3 ? (0, import_react35.createElement)(icon3) : null, text)) ); } function Stats({ range, total, previous }) { @@ -17982,25 +16655,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_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)( ++ const cell = (label, value) => (0, import_react35.createElement)("div", { className: "bt-us-stat" }, (0, import_react35.createElement)("span", { className: "bt-us-stat-label" }, label), (0, import_react35.createElement)("span", { className: "bt-us-stat-value" }, value)); ++ return (0, import_react35.createElement)( "section", { className: "bt-us-stats", style: { "--i": 0 } }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), -+ (0, import_react37.createElement)( ++ (0, import_react35.createElement)("span", { className: "bt-us-stat-label" }, lastText(range)), ++ (0, import_react35.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_react37.createElement)("small", null, t("tokens")), -+ change === null ? null : (0, import_react37.createElement)( ++ (0, import_react35.createElement)("small", null, t("tokens")), ++ change === null ? null : (0, import_react35.createElement)( "span", { className: "bt-us-delta", title: compareText(range) }, `${change >= 0 ? "↑" : "↓"} ${Math.abs(change)}%` @@ -18009,7 +16682,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_react37.useState)(null); ++ const [hover, setHover] = (0, import_react35.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])); @@ -18017,77 +16690,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_react37.createElement)( ++ return (0, import_react35.createElement)( "section", { className: "bt-us-card bt-us-chart", style: { "--i": 1 } }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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_react35.createElement)("h3", null, t("Tokens over time"), (0, import_react35.createElement)("span", null, unitLabel(range.unit))), ++ onBy ? (0, import_react35.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_react37.createElement)( ++ (0, import_react35.createElement)( "div", { className: "bt-us-chart-body" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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)( ++ [1, 0.5].map((level) => (0, import_react35.createElement)("div", { key: level, className: "bt-us-grid", style: { bottom: `${level * 100}%` } }, (0, import_react35.createElement)("span", null, compact(top * level)))), ++ (0, import_react35.createElement)("div", { className: "bt-us-grid bt-us-base", style: { bottom: 0 } }, (0, import_react35.createElement)("span", null, "0")), ++ (0, import_react35.createElement)( "div", { className: "bt-us-cols" }, - columns.map((column, at) => (0, import_react24.createElement)("div", { -+ columns.map((column, at) => (0, import_react37.createElement)("div", { ++ columns.map((column, at) => (0, import_react35.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_react37.createElement)( ++ }, column.total > 0 ? (0, import_react35.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_react37.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) -+ ) : (0, import_react37.createElement)("div", { className: "bt-us-stub" }))) ++ column.parts.map(([key, value]) => value > 0 ? (0, import_react35.createElement)("i", { key, style: { flexGrow: value, background: fillOf.get(key) } }) : null) ++ ) : (0, import_react35.createElement)("div", { className: "bt-us-stub" }))) ), - tip ? (0, import_react24.createElement)( -+ tip ? (0, import_react37.createElement)( ++ tip ? (0, import_react35.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_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)( ++ (0, import_react35.createElement)("div", { className: "bt-us-tip-title" }, tip.title), ++ (0, import_react35.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_react35.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_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)) ++ (0, import_react35.createElement)("i", { className: "bt-us-dot", style: { background: tipOf.get(key)?.fill } }), ++ (0, import_react35.createElement)("span", null, tipOf.get(key)?.name), ++ (0, import_react35.createElement)("b", null, compact(value)) )) ) : null ), - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)( ++ columns.map((column, at) => (0, import_react35.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 : "" @@ -18095,23 +16768,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_react37.createElement)( ++ series.length > 1 || series[0]?.pick ? (0, import_react35.createElement)( "div", { className: "bt-us-legend" }, - series.map((entry) => (0, import_react24.createElement)(entry.pick ? "button" : "span", { -+ series.map((entry) => (0, import_react37.createElement)(entry.pick ? "button" : "span", { ++ series.map((entry) => (0, import_react35.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_react37.createElement)("i", { className: "bt-us-dot", style: { background: entry.fill } }), entry.name)) ++ }, (0, import_react35.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_react37.createElement)( ++ return (0, import_react35.createElement)( onClick ? "button" : "div", { className: "bt-us-row", @@ -18121,77 +16794,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_react37.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, -+ (0, import_react37.createElement)( ++ glyph ? (0, import_react35.createElement)("span", { className: "bt-us-row-glyph" }, glyph) : null, ++ (0, import_react35.createElement)( "span", { className: "bt-us-row-body" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("span", { className: "bt-us-row-name" }, name), -+ sub ? (0, import_react37.createElement)("span", { className: "bt-us-row-sub" }, sub) : null ++ (0, import_react35.createElement)("span", { className: "bt-us-row-name" }, name), ++ sub ? (0, import_react35.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_react37.createElement)("span", { className: "bt-us-track" }, (0, import_react37.createElement)("span", { style: { width: `${width}%`, background: fill2 } })) ++ (0, import_react35.createElement)("span", { className: "bt-us-track" }, (0, import_react35.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_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)}%`)) ++ (0, import_react35.createElement)("span", { className: "bt-us-row-num" }, (0, import_react35.createElement)("b", null, compact(sum.total)), (0, import_react35.createElement)("small", null, `${percent(sum.total, whole)}%`)) ); } function ListCard({ title, count, index, children }) { - return (0, import_react24.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react35.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_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) ++ (0, import_react35.createElement)("div", { className: "bt-us-card-head" }, (0, import_react35.createElement)("h3", null, title, count === void 0 ? null : (0, import_react35.createElement)("span", null, count))), ++ (0, import_react35.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_react37.createElement)( ++ return (0, import_react35.createElement)( "section", { className: "bt-us-profile" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.createElement)( "span", { className: "bt-us-ring" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("defs", null, (0, import_react37.createElement)( ++ face.stops ? (0, import_react35.createElement)("defs", null, (0, import_react35.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_react37.createElement)("stop", { key: offset, offset, stopColor: color })) ++ face.stops.map(([offset, color]) => (0, import_react35.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_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_react35.createElement)("circle", { className: "bt-us-ring-track", cx: 48, cy: 48, r: RING_R }), ++ arc > 0 ? (0, import_react35.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_react37.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ++ (0, import_react35.createElement)("span", { className: "bt-us-ring-face" }, face.glyph(64)) ), - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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) })) ++ (0, import_react35.createElement)("h3", null, face.name), ++ face.sub ? (0, import_react35.createElement)("span", { className: "bt-us-profile-sub" }, face.sub) : null, ++ (0, import_react35.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) })) ) ); } @@ -18199,17 +16872,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_react37.useRef)(null); ++ const box = (0, import_react35.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_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 [right, setRight] = (0, import_react35.useState)(() => Math.max(addMonths(oldest, 1), monthOf(hourMs(span?.to ?? today)))); ++ const [anchor, setAnchor] = (0, import_react35.useState)(null); ++ const [hover, setHover] = (0, import_react35.useState)(null); ++ (0, import_react35.useEffect)(() => { const onDown = (event) => { if (![box, near].some((ref) => ref.current?.contains(event.target))) onClose(); }; @@ -18218,19 +16891,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_react37.useMemo)(() => Array.from({ length: 7 }, (_, at) => dates({ weekday: "narrow" }).format(Date.UTC(2024, 0, 1 + at))), []); ++ const weekdays = (0, import_react35.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_react37.createElement)("span", { key: `b${at}` })); ++ const cells = Array.from({ length: lead }, (_, at) => (0, import_react35.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_react37.createElement)("button", { ++ cells.push((0, import_react35.createElement)("button", { key, type: "button", className: "bt-us-day", @@ -18239,20 +16912,20 @@ diff --git a/client.js b/client.js onMouseEnter: () => setHover(key), onClick: () => press(key) - }, (0, import_react24.createElement)("b", null, day))); -+ }, (0, import_react37.createElement)("b", null, day))); ++ }, (0, import_react35.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_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)( ++ const nav = side === "left" ? (0, import_react35.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_react35.createElement)(ChevronLeftIcon)) : (0, import_react35.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_react35.createElement)(ChevronRightIcon)); ++ return (0, import_react35.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_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) ++ (0, import_react35.createElement)("div", { className: "bt-us-month-head" }, nav, (0, import_react35.createElement)("span", null, dates({ year: "numeric", month: "long" }).format(start))), ++ (0, import_react35.createElement)("div", { className: "bt-us-weekdays", "aria-hidden": true }, weekdays.map((name, at) => (0, import_react35.createElement)("span", { key: at }, name))), ++ (0, import_react35.createElement)("div", { className: "bt-us-days", onMouseLeave: () => setHover(null) }, cells) ); }; const shortcuts = [ @@ -18261,15 +16934,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_react37.createElement)( ++ return (0, import_react35.createElement)( "div", { ref: box, className: "bt-us-pick", role: "dialog", "aria-label": t("Custom range") }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("button", { ++ shortcuts.map(([id, label, from, to]) => (0, import_react35.createElement)("button", { key: id, type: "button", "aria-pressed": span?.from === from && span?.to === to, @@ -18278,20 +16951,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_react37.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), -+ (0, import_react37.createElement)( ++ (0, import_react35.createElement)("div", { className: "bt-us-pick-months" }, month(addMonths(right, -1), "left"), month(right, "right")), ++ (0, import_react35.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_react37.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), -+ low === null ? null : (0, import_react37.createElement)("span", null, (0, import_react37.createElement)("b", null, spanText(low, high)), ` · ${daysText(count)}`) ++ (0, import_react35.createElement)("span", null, anchor ? t("Pick the last day") : t("Pick the first day")), ++ low === null ? null : (0, import_react35.createElement)("span", null, (0, import_react35.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_react37.useState)(null); ++ const [hover, setHover] = (0, import_react35.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); @@ -18300,14 +16973,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_react37.createElement)("span", { key: week, style: { gridColumn: week + 1 } }, dates({ month: "short" }).format(monday))); ++ months.push((0, import_react35.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_react37.createElement)("span", { ++ cells.push((0, import_react35.createElement)("span", { key: ms, className: "bt-us-cell", style: { "--w": week }, @@ -18316,7 +16989,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_react37.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); ++ }, level > 0 ? (0, import_react35.createElement)("i", { style: { background: soften(paint, levels[level - 1]) } }) : null)); } } const tipSide = hover ? { @@ -18325,63 +16998,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_react37.createElement)( ++ return (0, import_react35.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_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)( ++ (0, import_react35.createElement)("div", { className: "bt-us-card-head" }, (0, import_react35.createElement)("h3", null, t("Activity")), (0, import_react35.createElement)("span", { className: "bt-us-head-note" }, summary)), ++ (0, import_react35.createElement)("div", { className: "bt-us-heat-months", "aria-hidden": true }, months), ++ (0, import_react35.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_react37.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), -+ hover ? (0, import_react37.createElement)( ++ (0, import_react35.createElement)("div", { className: "bt-us-heat-grid", role: "img", "aria-label": summary }, cells), ++ hover ? (0, import_react35.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_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) })) ++ (0, import_react35.createElement)("div", { className: "bt-us-tip-title" }, dates({ month: "long", day: "numeric", weekday: "short" }).format(hover.ms)), ++ (0, import_react35.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_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react37.createElement)("i", { style: { background: soften(paint, level) } }))), ++ levels.map((level) => (0, import_react35.createElement)("span", { key: level, className: "bt-us-cell" }, (0, import_react35.createElement)("i", { style: { background: soften(paint, level) } }))), t("Heavy") ) ); } function Skeleton2() { - return (0, import_react24.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react35.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_react37.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), -+ (0, import_react37.createElement)( ++ (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 32, width: 320, borderRadius: 999 } }), ++ (0, import_react35.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_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)( ++ (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 94 } }), ++ (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 318 } }), ++ (0, import_react35.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_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), -+ (0, import_react37.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) ++ (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }), ++ (0, import_react35.createElement)("div", { className: "bt-us-skel", style: { height: 200 } }) ) ) ); @@ -18395,16 +17068,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_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 [view, setView] = (0, import_react35.useState)(null); ++ const [error, setError] = (0, import_react35.useState)(""); ++ const [rangeId, setRangeId] = (0, import_react35.useState)("7d"); ++ const [span, setSpan] = (0, import_react35.useState)(null); ++ const [picking, setPicking] = (0, import_react35.useState)(false); ++ const [by, setBy] = (0, import_react35.useState)("bot"); ++ const tabs = (0, import_react35.useRef)(null); const focus = focusOf(focusId); - (0, import_react24.useEffect)(() => { -+ (0, import_react37.useEffect)(() => { ++ (0, import_react35.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(() => { @@ -18413,7 +17086,7 @@ diff --git a/client.js b/client.js await resized; if (stopped) return; - (0, import_react24.startTransition)(() => setView(value)); -+ (0, import_react37.startTransition)(() => setView(value)); ++ (0, import_react35.startTransition)(() => setView(value)); setError(""); timer = setTimeout(load, value.scanning ? 3e3 : 2e4); } catch (failure) { @@ -18422,7 +17095,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_react37.useMemo)(() => { ++ const shaped = (0, import_react35.useMemo)(() => { if (!view) return null; const faces2 = view.owners.map((owner) => ownerFace(owner, roster)); const lifetime = /* @__PURE__ */ new Map(); @@ -18431,7 +17104,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_react37.createElement)(ModelGlyph, { name, paint, size }) }; ++ return { kind: "model", name, sub: model.providerName ?? model.provider, paint, glyph: (size) => (0, import_react35.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; @@ -18441,29 +17114,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_react37.useRef)(null); -+ (0, import_react37.useEffect)(() => { ++ const root = (0, import_react35.useRef)(null); ++ (0, import_react35.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_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react37.createElement)(Skeleton2); ++ return error ? (0, import_react35.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : (0, import_react35.createElement)(Skeleton2); } if (view.rows.length === 0) { - return (0, import_react24.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react35.createElement)( "div", { className: "bt-us" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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.")) ++ (0, import_react35.createElement)(BotMark, { look: MAIN_LOOK, size: 56, live: true }), ++ (0, import_react35.createElement)("b", null, view.scanning ? t("Reading your chats…") : t("No usage yet.")), ++ (0, import_react35.createElement)("span", null, t("Token usage shows up here after a model is called.")) ) ); } @@ -18472,7 +17145,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_react37.createElement)(Row2, { ++ const ownerRow = ([at, sum], index, total = whole) => (0, import_react35.createElement)(Row2, { key: `o${at}`, kind: faces[at].kind, glyph: faces[at].glyph(32), @@ -18481,7 +17154,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_react37.createElement)(Row2, { ++ const modelRow = ([at, sum], index) => (0, import_react35.createElement)(Row2, { key: `m${at}`, kind: "model", glyph: models[at].glyph(32), @@ -18490,7 +17163,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_react37.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); ++ const nothing = (0, import_react35.createElement)("div", { className: "bt-us-none" }, t("Nothing in this range.")); let body; if (face) { const kinds = ranked(part.kinds); @@ -18499,30 +17172,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_react37.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), -+ (0, import_react37.createElement)( ++ (0, import_react35.createElement)(Profile, { key: "profile", face, range, share: all.total.total > 0 ? whole / all.total.total : 0 }), ++ (0, import_react35.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_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)( ++ (0, import_react35.createElement)(Stats, { key: "stats", range, total: part.total, previous: part.previous }), ++ (0, import_react35.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, onPick: pick2 }), ++ (0, import_react35.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_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)( ++ ownerAt >= 0 ? (0, import_react35.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 2 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) : (0, import_react35.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_react35.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_react37.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_react35.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_react37.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ++ (0, import_react35.createElement)(Heat, { key: "heat", view, keep, paint: face.paint, index: 4 }) ) ]; } else { @@ -18531,52 +17204,52 @@ diff --git a/client.js b/client.js const usedModels = ranked(all.models); body = [ - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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)( ++ (0, import_react35.createElement)(Stats, { key: "stats", range, total: all.total, previous: all.previous }), ++ (0, import_react35.createElement)(Chart, { key: "chart", range, columns, series, tipSeries, by, onBy: setBy, onPick: pick2 }), ++ (0, import_react35.createElement)( "div", { key: "pair", className: "bt-us-pair" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_react37.createElement)("div", { className: "bt-us-split", role: "separator" }) : null, ++ bots.length > 0 && rest.length > 0 ? (0, import_react35.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_react37.createElement)(ListCard, { title: t("Models"), count: usedModels.length, index: 3 }, usedModels.length > 0 ? usedModels.map(modelRow) : nothing) ++ (0, import_react35.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_react37.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ++ (0, import_react35.createElement)(Heat, { key: "heat", view, keep, paint: "var(--bt-accent)", index: 4 }) ) ]; } - return (0, import_react24.createElement)( -+ return (0, import_react37.createElement)( ++ return (0, import_react35.createElement)( "div", { ref: root, className: "bt-us", key: focusId ?? "all" }, - (0, import_react24.createElement)( -+ (0, import_react37.createElement)( ++ (0, import_react35.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_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 ++ (0, import_react35.createElement)(Tabs, { label: t("Time range"), value: rangeId, onChange: chooseRange, options: rangeOptions, boxRef: tabs }), ++ view.scanning ? (0, import_react35.createElement)("span", { className: "bt-us-note", role: "status" }, t("Still reading older chats; totals may grow.")) : null, ++ picking ? (0, import_react35.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_react37.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, ++ error ? (0, import_react35.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null, body ); } @@ -18593,7 +17266,7 @@ diff --git a/client.js b/client.js // src/client/connectors.js -var import_react25 = require("react"); -+var import_react38 = require("react"); ++var import_react36 = 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/"; @@ -18628,21 +17301,21 @@ diff --git a/client.js b/client.js + ["youtube", "YouTube"] +]; +var appName = (slug) => APPS.find(([id]) => id === slug)?.[1] ?? slug; -+var GitHubMark = () => (0, import_react38.createElement)( ++var GitHubMark = () => (0, import_react36.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_react38.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) -+); -+var FeishuMark = () => (0, import_react38.createElement)( -+ "svg", -+ { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, -+ (0, import_react38.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), -+ (0, import_react38.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), -+ (0, import_react38.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) ++ (0, import_react36.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 MARKS = { github: GitHubMark }; -var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; ++var FeishuMark = () => (0, import_react36.createElement)( ++ "svg", ++ { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, ++ (0, import_react36.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_react36.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_react36.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) ++); +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; +var ABOUT = { + github: () => t("Repositories, issues, and pull requests"), @@ -18666,11 +17339,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_react38.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); -+ return (0, import_react38.createElement)( ++ if (credential.masked) return (0, import_react36.createElement)(MaskedInput, { value, onChange, onEnter, label, autoFocus }); ++ return (0, import_react36.createElement)( + "label", + { className: "bt-q-field" }, -+ (0, import_react38.createElement)("input", { ++ (0, import_react36.createElement)("input", { + className: "bt-input", + type: "text", + value, @@ -18686,31 +17359,31 @@ diff --git a/client.js b/client.js + ); +} +function AllowedSenders({ connector, actions, run, busy }) { -+ const [openId, setOpenId] = (0, import_react38.useState)(""); ++ const [openId, setOpenId] = (0, import_react36.useState)(""); + const allowed = connector.allowed ?? []; + const refused = connector.inbound?.refusedSenders ?? []; + const allow = (id) => void run(actions.connectorAllow(connector.id, id)); -+ return (0, import_react38.createElement)( ++ return (0, import_react36.createElement)( + "div", + { className: "bt-connector-allowed" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), -+ allowed.length === 0 && refused.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, -+ allowed.map((entry) => (0, import_react38.createElement)( ++ (0, import_react36.createElement)("span", { className: "bt-connector-person-title" }, t("Who may talk to the Bots")), ++ allowed.length === 0 && refused.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("Nobody yet. A message from anyone else starts no turn.")) : null, ++ allowed.map((entry) => (0, import_react36.createElement)( + "div", + { key: entry.openId, className: "bt-connector-person" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) ++ (0, import_react36.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorBlock(connector.id, entry.openId)) }, t("Block")) + )), -+ refused.map((entry) => (0, import_react38.createElement)( ++ refused.map((entry) => (0, import_react36.createElement)( + "div", + { key: entry.openId, className: "bt-connector-person" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) ++ (0, import_react36.createElement)("span", { className: "bt-connector-person-name" }, entry.name ?? entry.openId), ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => allow(entry.openId) }, t("Allow")) + )), -+ (0, import_react38.createElement)( ++ (0, import_react36.createElement)( + "div", + { className: "bt-connector-form-row" }, -+ (0, import_react38.createElement)("input", { ++ (0, import_react36.createElement)("input", { + className: "bt-input", + type: "text", + value: openId, @@ -18725,7 +17398,7 @@ diff --git a/client.js b/client.js + } + } + }), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || openId.trim() === "", onClick: () => { + allow(openId.trim()); + setOpenId(""); + } }, t("Allow")) @@ -18735,20 +17408,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_react38.createElement)( ++ return (0, import_react36.createElement)( + "button", + { type: "button", className: "bt-state bt-app-chip", "aria-pressed": true, "data-status": status ?? "unknown", title: t("Remove {app}", { app: name }), onClick: onRemove }, + name, -+ label ? (0, import_react38.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null ++ label ? (0, import_react36.createElement)("span", { className: "bt-app-status" }, ` · ${label}`) : null + ); +} +function AppPicker({ apps, onChange, search, statuses = {} }) { -+ const [query, setQuery] = (0, import_react38.useState)(""); -+ const [found, setFound] = (0, import_react38.useState)(null); -+ const [failed, setFailed] = (0, import_react38.useState)(null); -+ const names = (0, import_react38.useRef)(new Map(APPS)); -+ const asked = (0, import_react38.useRef)(0); -+ (0, import_react38.useEffect)(() => { ++ const [query, setQuery] = (0, import_react36.useState)(""); ++ const [found, setFound] = (0, import_react36.useState)(null); ++ const [failed, setFailed] = (0, import_react36.useState)(null); ++ const names = (0, import_react36.useRef)(new Map(APPS)); ++ const asked = (0, import_react36.useRef)(0); ++ (0, import_react36.useEffect)(() => { + const text = query.trim(); + if (text === "" || search === void 0) { + setFound(null); @@ -18775,13 +17448,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_react38.createElement)( ++ return (0, import_react36.createElement)( + "div", + { className: "bt-connector-apps" }, -+ apps.length > 0 ? (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react38.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, -+ (0, import_react38.createElement)("input", { className: "bt-input", type: "search", value: query, spellCheck: false, "aria-label": t("Search apps"), placeholder: t("Search Composio apps"), onChange: (event) => setQuery(event.target.value) }), -+ failed ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, -+ (0, import_react38.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react38.createElement)("button", { key: app.slug, type: "button", className: "bt-state", "aria-pressed": false, onClick: () => { ++ apps.length > 0 ? (0, import_react36.createElement)("div", { className: "bt-states", "aria-label": t("Picked apps") }, apps.map((slug) => (0, import_react36.createElement)(AppChip, { key: slug, name: nameOf(slug), status: statuses[slug], onRemove: () => onChange(apps.filter((app) => app !== slug)) }))) : null, ++ (0, import_react36.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_react36.createElement)("span", { className: "bt-set-hint" }, t("Could not search Composio: {error}", { error: failed })) : null, ++ (0, import_react36.createElement)("div", { className: "bt-states", "aria-label": t("Apps to add") }, results.length === 0 ? (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No apps match")) : results.map((app) => (0, import_react36.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))) @@ -18789,22 +17462,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_react38.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react38.createElement)( ++ return (0, import_react36.createElement)("ul", { className: "bt-connector-app-list", "aria-label": t("App status") }, apps.map((slug) => (0, import_react36.createElement)( + "li", + { key: slug, className: "bt-connector-app", "data-status": statuses[slug] ?? "unknown" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), -+ (0, import_react38.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") ++ (0, import_react36.createElement)("span", { className: "bt-connector-app-name" }, appName(slug)), ++ (0, import_react36.createElement)("span", { className: "bt-app-status" }, APP_STATUS[statuses[slug]]?.() ?? "") + ))); +} +function ConnectorRow({ connector, roster, actions }) { -+ const [form, setForm] = (0, import_react38.useState)(false); -+ const [bots, setBots] = (0, import_react38.useState)(false); -+ const [token, setToken] = (0, import_react38.useState)(""); -+ const [apps, setApps] = (0, import_react38.useState)(() => connector.apps ?? []); -+ const [picking, setPicking] = (0, import_react38.useState)(false); -+ const [busy, setBusy] = (0, import_react38.useState)(false); -+ const [error, setError] = (0, import_react38.useState)(null); -+ const [message, setMessage] = (0, import_react38.useState)(""); ++ const [form, setForm] = (0, import_react36.useState)(false); ++ const [bots, setBots] = (0, import_react36.useState)(false); ++ const [token, setToken] = (0, import_react36.useState)(""); ++ const [apps, setApps] = (0, import_react36.useState)(() => connector.apps ?? []); ++ const [picking, setPicking] = (0, import_react36.useState)(false); ++ const [busy, setBusy] = (0, import_react36.useState)(false); ++ const [error, setError] = (0, import_react36.useState)(null); ++ const [message, setMessage] = (0, import_react36.useState)(""); const run = (task, after) => { setBusy(true); setError(null); @@ -18816,15 +17489,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_react38.useState)(() => connector.mode ?? modes[0]?.id); -+ const [values, setValues] = (0, import_react38.useState)({}); ++ const [mode, setMode] = (0, import_react36.useState)(() => connector.mode ?? modes[0]?.id); ++ const [values, setValues] = (0, import_react36.useState)({}); + const chosen = modes.find((entry) => entry.id === mode) ?? modes[0]; + const field2 = (name) => values[name] ?? ""; + const setField = (name, value) => setValues((current) => ({ ...current, [name]: value })); + const filled = chosen !== void 0 && chosen.credentials.every((credential) => field2(credential.name).trim() !== ""); -+ const [statuses, setStatuses] = (0, import_react38.useState)({}); ++ const [statuses, setStatuses] = (0, import_react36.useState)({}); + const appsKey = (connector.apps ?? []).join(","); -+ (0, import_react38.useEffect)(() => { ++ (0, import_react36.useEffect)(() => { + if (!gateway || connector.state !== "ready" || appsKey === "" || actions.connectorAppStatus === void 0) { + setStatuses({}); + return void 0; @@ -18839,7 +17512,7 @@ diff --git a/client.js b/client.js + live = false; + }; + }, [gateway, connector.state, appsKey, picking]); -+ const search = (0, import_react38.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); ++ const search = (0, import_react36.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), () => { @@ -18878,63 +17551,60 @@ 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_react38.useRef)(null); ++ const row = (0, import_react36.useRef)(null); + useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); -+ return (0, import_react38.createElement)( ++ return (0, import_react36.createElement)( "div", { ref: row, className: "bt-connector", "data-state": connector.state }, - (0, import_react25.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react36.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_react38.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react38.createElement)(Mark) : null), -+ (0, import_react38.createElement)( ++ (0, import_react36.createElement)("span", { className: "bt-connector-mark" }, Mark ? (0, import_react36.createElement)(Mark) : null), ++ (0, import_react36.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_react38.createElement)("span", { className: "bt-connector-name" }, connector.name), -+ (0, import_react38.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ++ (0, import_react36.createElement)("span", { className: "bt-connector-name" }, connector.name), ++ (0, import_react36.createElement)("span", { className: "bt-connector-status" }, statusText2(connector)) ), - (0, import_react25.createElement)( -+ (0, import_react38.createElement)( ++ (0, import_react36.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_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: () => { ++ connector.state === "error" ? (0, import_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { + setApps(connector.apps ?? []); + setPicking(true); + } }, t("Choose apps")) : null, -+ on && perBot ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", "aria-expanded": bots, onClick: () => setBots((value) => !value) }, bots ? t("Done") : t("Choose Bots")) : null, -+ on ? (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorDisconnect(connector.id)) }, t("Disconnect")) : form ? null : (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) - ) - ), -- !on && form ? (0, import_react25.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_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)( ++ on && perBot ? (0, import_react36.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_react36.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_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setForm(true) }, t("Connect")) ++ ) ++ ), ++ on && gateway && !picking ? (0, import_react36.createElement)(AppStatusList, { apps: connector.apps ?? [], statuses }) : null, ++ on && perBot ? (0, import_react36.createElement)("span", { className: "bt-connector-uses" }, usageText(roster, connector.id)) : null, ++ on && bots ? (0, import_react36.createElement)(ConnectorBots, { connector, roster, actions, run, SwitchRow }) : null, ++ on && channel && connector.mode === "app" ? (0, import_react36.createElement)(AllowedSenders, { connector, actions, run, busy }) : null, ++ on && canPost ? (0, import_react36.createElement)( ++ "div", ++ { className: "bt-connector-form" }, ++ (0, import_react36.createElement)("label", { className: "bt-q-field" }, (0, import_react36.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_react36.createElement)( + "div", + { className: "bt-connector-form-row" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-link" }), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) ++ (0, import_react36.createElement)("span", { className: "bt-connector-link" }), ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || message.trim() === "" || actions.connectorSend === void 0, onClick: () => void run(actions.connectorSend(connector.id, message), () => setMessage("")) }, t("Send")) + ) + ) : null, -+ !on && form ? (0, import_react38.createElement)( ++ !on && form ? (0, import_react36.createElement)( + "div", + { className: "bt-connector-form" }, -+ channel ? (0, import_react38.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react38.createElement)("button", { ++ channel ? (0, import_react36.createElement)("div", { className: "bt-states", role: "group", "aria-label": t("How to connect") }, modes.map((entry) => (0, import_react36.createElement)("button", { + key: entry.id, + type: "button", + className: "bt-state", @@ -18945,77 +17615,83 @@ diff --git a/client.js b/client.js + setError(null); + } + }, t(entry.label)))) : null, -+ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react38.createElement)(CredentialField, { ++ channel ? (chosen?.credentials ?? []).map((credential, index) => (0, import_react36.createElement)(CredentialField, { + key: credential.name, + credential, + value: field2(credential.name), + onChange: (value) => setField(credential.name, value), + onEnter: connect, + autoFocus: index === 0 -+ })) : (0, import_react38.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react38.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), -+ gateway ? (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, -+ (0, import_react38.createElement)( - "div", - { className: "bt-connector-form-row" }, -- (0, import_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_react38.createElement)( ++ })) : (0, import_react36.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react36.createElement)(MaskedInput, { value: token, onChange: setToken, onEnter: connect, label: gateway ? t("Composio API key") : t("GitHub token"), autoFocus: true })), ++ gateway ? (0, import_react36.createElement)(AppPicker, { apps, onChange: setApps, search: token.trim() === "" ? void 0 : search }) : null, ++ (0, import_react36.createElement)( ++ "div", ++ { className: "bt-connector-form-row" }, ++ (0, import_react36.createElement)( + "a", + { className: "bt-connector-link", href: channel ? chosen?.link ?? FEISHU_WEBHOOK_URL : gateway ? COMPOSIO_KEY_URL : TOKEN_URL, target: "_blank", rel: "noopener noreferrer" }, + channel ? t(chosen?.linkLabel ?? "Get a webhook URL in Feishu") : gateway ? t("Get a Composio API key") : t("Create a token on GitHub") + ), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { - setForm(false); - setToken(""); ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ setForm(false); ++ setToken(""); + setValues({}); + setError(null); + } }, t("Cancel")), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) -+ ) ++ (0, import_react36.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || (channel ? !filled : token.trim() === ""), onClick: connect }, busy ? t("Connecting…") : t("Connect")) + ) +- ), +- !on && form ? (0, import_react25.createElement)( + ) : null, -+ on && gateway && picking ? (0, import_react38.createElement)( -+ "div", -+ { className: "bt-connector-form" }, -+ (0, import_react38.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), -+ (0, import_react38.createElement)("span", { className: "bt-set-hint" }, t("The first time a Bot uses an app, it shows a sign-in link in the chat.")), -+ (0, import_react38.createElement)( -+ "div", -+ { className: "bt-connector-form-row" }, -+ (0, import_react38.createElement)("span", { className: "bt-connector-link" }), -+ (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { ++ on && gateway && picking ? (0, import_react36.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_react36.createElement)(AppPicker, { apps, onChange: setApps, search, statuses }), ++ (0, import_react36.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_react36.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: () => { +- setForm(false); +- setToken(""); ++ (0, import_react36.createElement)("span", { className: "bt-connector-link" }), ++ (0, import_react36.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_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy || apps.length === 0, onClick: saveApps }, busy ? t("Connecting…") : t("Save")) ++ (0, import_react36.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_react38.createElement)("div", { className: "bt-connector-error", role: "alert" }, error) : null ++ error ? (0, import_react36.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_react38.createElement)( ++ return (0, import_react36.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_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 }))) ++ (0, import_react36.createElement)("div", { className: "bt-set-card bt-connectors-card" }, list.length === 0 ? (0, import_react36.createElement)("div", { className: "bt-set-row" }, (0, import_react36.createElement)("span", { className: "bt-set-hint" }, t("No connectors"))) : list.map((connector) => (0, import_react36.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_react39 = require("react"); -+var FeedbackIcon = () => (0, import_react39.createElement)( ++var import_react37 = require("react"); ++var FeedbackIcon = () => (0, import_react37.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_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" }) ++ (0, import_react37.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_react37.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" })); @@ -19039,17 +17715,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_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 [what, setWhat] = (0, import_react37.useState)(""); ++ const [report, setReport] = (0, import_react37.useState)(null); ++ const [preview, setPreview] = (0, import_react37.useState)(false); ++ const [note, setNote] = (0, import_react37.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_react39.useEffect)(() => { ++ (0, import_react37.useEffect)(() => { void run(load()); }, []); const copyReport = () => run(fetchReport().then(async (next) => { @@ -19058,7 +17734,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_react39.useState)(null); ++ const [issueLink, setIssueLink] = (0, import_react37.useState)(null); const openIssue = () => { const win = window.open("", "_blank"); if (win !== null) win.opener = null; @@ -19067,83 +17743,83 @@ diff --git a/client.js b/client.js const shown = entries2.slice(0, 8); return [ - (0, import_react26.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react37.createElement)( Section, { key: "report", title: t("Report a problem") }, - (0, import_react26.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react37.createElement)( "div", { className: "bt-set-row bt-set-paste bt-fb-what" }, - (0, import_react26.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react37.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_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_react37.createElement)("span", null, t("What happened?")), ++ (0, import_react37.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_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_react37.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_react39.createElement)( ++ (0, import_react37.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_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")) ++ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => setPreview(!preview), "aria-expanded": preview }, preview ? t("Hide") : t("Show")), ++ (0, import_react37.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_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)( ++ preview ? (0, import_react37.createElement)("div", { className: "bt-set-row bt-fb-preview" }, (0, import_react37.createElement)("pre", { tabIndex: 0, "aria-label": t("Diagnostics") }, report?.markdown || t("Loading…"))) : null, ++ (0, import_react37.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_react39.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ++ (0, import_react37.createElement)("button", { type: "button", className: "bt-fb-primary", onClick: openIssue }, t("Report on GitHub")) ) ), - note ? (0, import_react26.createElement)( -+ note ? (0, import_react39.createElement)( ++ note ? (0, import_react37.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_react39.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ++ issueLink ? [" ", (0, import_react37.createElement)("a", { key: "link", href: issueLink, target: "_blank", rel: "noopener noreferrer" }, t("Open the issue"))] : null ) : null, - (0, import_react26.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react37.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_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)( ++ shown.length === 0 ? (0, import_react37.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_react37.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_react39.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), -+ (0, import_react39.createElement)( ++ (0, import_react37.createElement)("span", { className: "bt-fb-dot", "data-level": entry.level, "aria-hidden": true }), ++ (0, import_react37.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_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(" · ")) ++ (0, import_react37.createElement)("span", { className: "bt-fb-text" }, entrySummary(entry)), ++ (0, import_react37.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_react39.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null ++ entry.code ? (0, import_react37.createElement)("span", { className: "bt-tag bt-fb-code" }, entry.status ? `${entry.code} ${entry.status}` : entry.code) : null )), - (0, import_react26.createElement)( -+ (0, import_react39.createElement)( ++ (0, import_react37.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_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")) ++ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: downloadLog }, t("Download")), ++ (0, import_react37.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: clearLog, disabled: !report || total === 0 }, t("Clear")) ) ) ]; @@ -19153,8 +17829,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_react39.useState)(false); -+ (0, import_react39.useEffect)(() => { ++ const [leaving, setLeaving] = (0, import_react37.useState)(false); ++ (0, import_react37.useEffect)(() => { if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); @@ -19164,19 +17840,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_react39.createElement)( ++ return (0, import_react37.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_react39.createElement)("span", { className: "bt-toast-text" }, toast.text), -+ info ? null : (0, import_react39.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { ++ (0, import_react37.createElement)("span", { className: "bt-toast-text" }, toast.text), ++ info ? null : (0, import_react37.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_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)) ++ (0, import_react37.createElement)("button", { type: "button", className: "bt-icon-btn bt-toast-close", "aria-label": t("Dismiss"), onClick: () => actions.dismissToast(toast.token) }, (0, import_react37.createElement)(CloseIcon)) ); } var FEEDBACK_CSS = ` @@ -19201,7 +17877,7 @@ diff --git a/client.js b/client.js // src/client/about.js -var import_react27 = require("react"); -var CHEER_MS = 1800; -+var import_react40 = require("react"); ++var import_react38 = 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…"); @@ -19244,9 +17920,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_react40.useState)(false); -+ const [stars, setStars] = (0, import_react40.useState)(null); -+ (0, import_react40.useEffect)(() => { ++ const [checking, setChecking] = (0, import_react38.useState)(false); ++ const [stars, setStars] = (0, import_react38.useState)(null); ++ (0, import_react38.useEffect)(() => { let live = true; actions.repoStars().then((count) => { if (live && Number.isInteger(count)) setStars(count); @@ -19278,14 +17954,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_react40.createElement)( ++ const botControl = phase === "installed" ? null : update.available || phase === "installing" ? (0, import_react38.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_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)( ++ ) : (0, import_react38.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_react38.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_react38.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") @@ -19293,22 +17969,22 @@ diff --git a/client.js b/client.js const repo = update.repoUrl; return [ - (0, import_react27.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react38.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_react40.createElement)("span", { className: "bt-about-mark" }, (0, import_react40.createElement)(FlyBotMark, { size: 52 })), -+ (0, import_react40.createElement)( ++ (0, import_react38.createElement)("span", { className: "bt-about-mark" }, (0, import_react38.createElement)(FlyBotMark, { size: 52 })), ++ (0, import_react38.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_react40.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), -+ update.current ? (0, import_react40.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null ++ (0, import_react38.createElement)("span", { className: "bt-about-name" }, t("FlyBot")), ++ update.current ? (0, import_react38.createElement)("span", { className: "bt-about-version" }, versionLine(update)) : null ), - repo ? (0, import_react27.createElement)( -+ repo ? (0, import_react40.createElement)( ++ repo ? (0, import_react38.createElement)( "a", { className: "bt-star", @@ -19322,31 +17998,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_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)) ++ (0, import_react38.createElement)("span", { className: "bt-star-main" }, (0, import_react38.createElement)("span", { className: "bt-star-icon" }, (0, import_react38.createElement)(StarLineIcon)), "Star"), ++ stars === null ? null : (0, import_react38.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_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)( ++ repo ? (0, import_react38.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_react38.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_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_react38.createElement)(Row3, { label: t("FlyBot core"), hint: botHint(update) }, botControl, update.available && update.notesUrl ? (0, import_react38.createElement)("a", linkProps(update.notesUrl), t("What's new")) : null), ++ dsh?.current ? (0, import_react38.createElement)(Row3, { label: t("DeepSeek Harness (underlying)"), hint: dshHint(dsh, dshShown) }, dshControl) : null ), - (0, import_react27.createElement)( -+ (0, import_react40.createElement)( ++ (0, import_react38.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_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"))) ++ repo ? (0, import_react38.createElement)(Row3, { label: t("Source code"), hint: repo.replace(/^https:\/\//, "") }, (0, import_react38.createElement)("a", linkProps(repo), (0, import_react38.createElement)(GitHubMark), t("Open"))) : null, ++ update.notesUrl ? (0, import_react38.createElement)(Row3, { label: t("Release notes") }, (0, import_react38.createElement)("a", linkProps(update.notesUrl), t("Open"))) : null, ++ (0, import_react38.createElement)(Row3, { label: t("Report a problem") }, (0, import_react38.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => actions.openSettings("feedback") }, t("Open"))) ) ]; } @@ -19366,7 +18042,7 @@ diff --git a/client.js b/client.js // src/client/settings.js -var import_react28 = require("react"); -+var import_react41 = require("react"); ++var import_react39 = 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 PAGE_ICONS = { ...SETTINGS_ICONS, connectors: ConnectorIcon, capabilities: PlugIcon, schedules: ClockIcon, secrets: KeyIcon, feedback: FeedbackIcon, about: InfoIcon }; @@ -19374,17 +18050,17 @@ diff --git a/client.js b/client.js +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_react41.createElement)("section", { className: "bt-set-section" }, (0, import_react41.createElement)("h3", null, title), (0, import_react41.createElement)("div", { className: "bt-set-card" }, children)); ++ return (0, import_react39.createElement)("section", { className: "bt-set-section" }, (0, import_react39.createElement)("h3", null, title), (0, import_react39.createElement)("div", { className: "bt-set-card" }, children)); } function SetRow({ label, hint, children }) { - return (0, import_react28.createElement)( -+ return (0, import_react41.createElement)( ++ return (0, import_react39.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_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 ++ (0, import_react39.createElement)("div", { className: "bt-set-copy" }, (0, import_react39.createElement)("span", null, label), hint ? (0, import_react39.createElement)("span", { className: "bt-set-hint" }, hint) : null), ++ children ? (0, import_react39.createElement)("div", { className: "bt-set-control" }, children) : null ); } +var NOTIFY_KINDS = [ @@ -19399,9 +18075,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_react41.useState)(prefs.accent ?? "#4d6bfe"); -+ const timer = (0, import_react41.useRef)(0); -+ (0, import_react41.useEffect)(() => () => clearTimeout(timer.current), []); ++ const [custom, setCustom] = (0, import_react39.useState)(prefs.accent ?? "#4d6bfe"); ++ const timer = (0, import_react39.useRef)(0); ++ (0, import_react39.useEffect)(() => () => clearTimeout(timer.current), []); const pickCustom = (value) => { setCustom(value); clearTimeout(timer.current); @@ -19409,33 +18085,33 @@ diff --git a/client.js b/client.js }; return [ - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( SetSection, { key: "look", title: t("Appearance") }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( SetRow, { label: t("Theme"), hint: t("Colors for the whole team, on every device.") }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_react41.createElement)("option", { key: id, value: id }, t(theme.label))) ++ Object.entries(themes).map(([id, theme]) => (0, import_react39.createElement)("option", { key: id, value: id }, t(theme.label))) ) ), - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( SetRow, { label: t("Accent"), hint: t("Unread marks, badges, and the brand mark.") }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_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", { ++ (0, import_react39.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_react39.createElement)("button", { key: ink, type: "button", className: "bt-swatch", @@ -19444,18 +18120,18 @@ diff --git a/client.js b/client.js onClick: () => run(actions.setPrefs({ accent: INKS[ink][0] })) })), - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_react41.createElement)("input", { type: "color", value: custom, "aria-label": t("Custom accent"), onChange: (event) => pickCustom(event.target.value) }) ++ (0, import_react39.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_react41.createElement)( ++ (0, import_react39.createElement)( SetSection, - { key: "mode", title: t("Mode") }, - (0, import_react28.createElement)( @@ -19464,14 +18140,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_react41.createElement)(SwitchRow, { ++ (0, import_react39.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_react41.createElement)(SwitchRow, { ++ NOTIFY_KINDS.map(([kind, label, hint]) => (0, import_react39.createElement)(SwitchRow, { + key: kind, + label: t(label), + hint: hint ? t(hint) : void 0, @@ -19489,40 +18165,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_react41.createElement)( ++ return (0, import_react39.createElement)( SetSection, { title: t("Main Bot") }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_react41.createElement)("select", { ++ (0, import_react39.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_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ++ }, first ? null : (0, import_react39.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react39.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ), - others.map((bot) => (0, import_react28.createElement)( -+ others.map((bot) => (0, import_react41.createElement)( ++ others.map((bot) => (0, import_react39.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_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")) ++ (0, import_react39.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_react39.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_react41.createElement)( ++ candidates.length > 0 ? (0, import_react39.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_react41.createElement)("select", { ++ (0, import_react39.createElement)("select", { className: "bt-select", "aria-label": t("Add a Main Bot"), value: "", @@ -19530,7 +18206,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_react41.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react41.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ++ }, (0, import_react39.createElement)("option", { value: "" }, t("Choose a Bot…")), candidates.map((bot) => (0, import_react39.createElement)("option", { key: bot.id, value: bot.id }, bot.name))) ) : null ); } @@ -19538,15 +18214,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_react41.createElement)( ++ return (0, import_react39.createElement)( SetSection, { title: t("Animation") }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_react41.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react41.createElement)("button", { ++ (0, import_react39.createElement)(Segmented, { label: t("Bot animation"), value: motion, className: "bt-motion-seg" }, MOTIONS.map(([id, label]) => (0, import_react39.createElement)("button", { key: id, type: "button", "aria-pressed": motion === id, @@ -19555,30 +18231,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_react41.useState)(false); ++ const [creating, setCreating] = (0, import_react39.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_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)( ++ (0, import_react39.createElement)(MainBotsSection, { key: "main", roster, actions, run }), ++ (0, import_react39.createElement)(MotionSection, { key: "motion", roster, actions, run }), ++ creating ? (0, import_react39.createElement)(CreateBot, { key: "create", roster, actions, inline: true, onBack: () => setCreating(false), onCreated: (bot) => actions.openSettings("bots", bot.id) }) : (0, import_react39.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_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_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-with-icon", onClick: () => setCreating(true) }, (0, import_react39.createElement)(PlusIcon), t("New Bot")) ), - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_react41.createElement)( ++ roster.bots.filter((bot) => !bot.remote).map((bot) => (0, import_react39.createElement)( "button", { key: bot.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("bots", bot.id) }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react28.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), @@ -19586,42 +18262,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_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_react39.createElement)(BotAvatar, { bot, size: 28, badge: false, live: false }), ++ (0, import_react39.createElement)("span", { className: "bt-row-title" }, bot.name), ++ bot.role ? (0, import_react39.createElement)("span", { className: "bt-tag" }, bot.role) : null, ++ isMainOf(roster, bot.id) ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Main Bot")) : null, ++ bot.hidden && !isMainOf(roster, bot.id) ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ), - (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) ++ (0, import_react39.createElement)("span", { className: "bt-chevron" }, (0, import_react39.createElement)(ChevronRightIcon)) )) ) ]; } function GroupsPage({ roster, actions, run }) { - const [creating, setCreating] = (0, import_react28.useState)(false); -+ const [creating, setCreating] = (0, import_react41.useState)(false); ++ const [creating, setCreating] = (0, import_react39.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_react41.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react41.createElement)( ++ creating ? (0, import_react39.createElement)(NewGroupForm, { key: "create", roster, actions, run, onCancel: () => setCreating(false), onCreated: (room2) => actions.openSettings("groups", room2.id) }) : (0, import_react39.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_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_react39.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_react39.createElement)(PlusIcon), t("Create group chat")) ), - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_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) => { ++ roster.rooms.length === 0 ? (0, import_react39.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_react41.createElement)( ++ return (0, import_react39.createElement)( "button", { key: room2.id, type: "button", className: "bt-set-row", onClick: () => actions.openSettings("groups", room2.id) }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( "span", { className: "bt-set-bot" }, - (0, import_react28.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), @@ -19629,14 +18305,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_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_react39.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), ++ (0, import_react39.createElement)("span", { className: "bt-row-title" }, room2.name), ++ (0, import_react39.createElement)("span", { className: "bt-tag" }, t(seatedCount(room2) === 1 ? "{count} member" : "{count} members", { count: seatedCount(room2) })), ++ admin ? (0, import_react39.createElement)("span", { className: "bt-tag bt-tag-admin" }, (0, import_react39.createElement)(AdminStar), admin.name) : null, ++ room2.hidden ? (0, import_react39.createElement)("span", { className: "bt-tag" }, t("Hidden")) : null ), - (0, import_react28.createElement)("span", { className: "bt-chevron" }, (0, import_react28.createElement)(ChevronRightIcon)) -+ (0, import_react41.createElement)("span", { className: "bt-chevron" }, (0, import_react41.createElement)(ChevronRightIcon)) ++ (0, import_react39.createElement)("span", { className: "bt-chevron" }, (0, import_react39.createElement)(ChevronRightIcon)) ); }) ) @@ -19645,8 +18321,8 @@ 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_react41.useState)(""); -+ const close = (0, import_react41.useCallback)(() => actions.closeSettings(), []); ++ const [error, setError] = (0, import_react39.useState)(""); ++ const close = (0, import_react39.useCallback)(() => actions.closeSettings(), []); useEscape(close); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); @@ -19667,34 +18343,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_react41.useRef)({ key: pageKey, depth, move: "up" }); ++ const last = (0, import_react39.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_react41.useState)(shown === "usage"); ++ const [usageOpened, setUsageOpened] = (0, import_react39.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_react41.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; -+ return (0, import_react41.createElement)( ++ const errorLine = error ? (0, import_react39.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; ++ return (0, import_react39.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_react41.createElement)("div", { className: "bt-scrim", onMouseDown: close }), -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)("div", { className: "bt-scrim", onMouseDown: close }), ++ (0, import_react39.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_react41.createElement)( ++ (0, import_react39.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_react41.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), -+ pages.map(([key, text]) => (0, import_react41.createElement)("button", { ++ (0, import_react39.createElement)("span", { ref: nav.thumb, className: "bt-thumb", "aria-hidden": true }), ++ pages.map(([key, text]) => (0, import_react39.createElement)("button", { key, type: "button", className: "bt-settings-item", @@ -19702,10 +18378,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_react41.createElement)(extra.find((item) => item.key === key)?.entry.icon ?? PAGE_ICONS[key] ?? ConnectorIcon), t(text))), ++ }, (0, import_react39.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_react41.createElement)("button", { ++ (0, import_react39.createElement)("button", { type: "button", className: "bt-settings-item bt-settings-shell", - title: t("Models, providers, and the rest of DeepSeek Harness"), @@ -19716,23 +18392,23 @@ diff --git a/client.js b/client.js openHarnessSettings(); } - }, (0, import_react28.createElement)(SlidersIcon), t("Harness settings")) -+ }, (0, import_react41.createElement)(SlidersIcon), t("Harness settings"), (0, import_react41.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ++ }, (0, import_react39.createElement)(SlidersIcon), t("Harness settings"), (0, import_react39.createElement)("span", { className: "bt-settings-out", "aria-hidden": true }, "↗")) ), - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.createElement)( "div", { className: "bt-settings-main" }, - (0, import_react28.createElement)( -+ (0, import_react41.createElement)( ++ (0, import_react39.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_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) ++ back ? (0, import_react39.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react39.createElement)(ChevronLeftIcon)) : null, ++ (0, import_react39.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_react41.createElement)( ++ shown === "usage" ? null : (0, import_react39.createElement)( "div", { key: pageKey, className: "bt-settings-scroll bt-settings-page", "data-move": last.current.move }, errorLine, @@ -19747,42 +18423,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_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 ++ shown === "general" ? (0, import_react39.createElement)(GeneralPage, { roster, actions, run }) : null, ++ bot ? (0, import_react39.createElement)("div", { className: "bt-settings-form" }, (0, import_react39.createElement)(BotSettingsForm, { key: bot.id, bot, roster, actions, run })) : null, ++ shown === "bots" && !bot ? (0, import_react39.createElement)(BotsPage, { roster, actions, run }) : null, ++ room2 ? (0, import_react39.createElement)("div", { className: "bt-settings-form" }, (0, import_react39.createElement)(GroupSettingsForm, { key: room2.id, room: room2, roster, actions, run })) : null, ++ shown === "groups" && !room2 ? (0, import_react39.createElement)(GroupsPage, { roster, actions, run }) : null, ++ shown === "connectors" ? (0, import_react39.createElement)(ConnectorsPage, { roster, actions }) : null, ++ shown === "workflows" ? (0, import_react39.createElement)(WorkflowsPage, { actions }) : null, ++ pluginPage ? (0, import_react39.createElement)(SettingsPageSlot, { entry: pluginPage, props: { roster, actions, call: call2, run, t, Section: SetSection, Row: SetRow } }) : null, ++ shown === "capabilities" ? (0, import_react39.createElement)(CapabilitiesPage, { roster, actions, run }) : null, ++ shown === "schedules" ? (0, import_react39.createElement)(SchedulesPage, { roster, actions, start: id ?? void 0 }) : null, ++ shown === "secrets" ? (0, import_react39.createElement)(SecretsPage, { roster, actions, run, Section: SetSection }) : null, ++ shown === "feedback" ? (0, import_react39.createElement)(FeedbackPage, { actions, run, Section: SetSection, Row: SetRow }) : null, ++ shown === "about" ? (0, import_react39.createElement)(AboutPage, { useUpdate, actions, run, Section: SetSection, Row: SetRow }) : null ), - usageOpened ? (0, import_react28.createElement)( -+ usageOpened ? (0, import_react41.createElement)( ++ usageOpened ? (0, import_react39.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_react41.createElement)(UsagePage, { roster, actions, focusId: usageFocus }) ++ (0, import_react39.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_react41.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ++ (0, import_react39.createElement)("button", { type: "button", className: "bt-settings-close", "aria-label": t("Close settings"), onClick: close }, (0, import_react39.createElement)(CloseIcon)) ) ); } // src/client/context-menu.js -var import_react29 = require("react"); -+var import_react42 = require("react"); ++var import_react40 = require("react"); function ContextMenu({ menu, roster, actions, leaving = false }) { useEscape(() => actions.closeMenu()); - (0, import_react29.useEffect)(() => { -+ (0, import_react42.useEffect)(() => { ++ (0, import_react40.useEffect)(() => { const onDown = (event) => { if (!(event.target instanceof Element) || !event.target.closest(".bt-menu")) actions.closeMenu(); }; @@ -19793,15 +18469,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_react42.useRef)(null); -+ const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y }); -+ (0, import_react42.useLayoutEffect)(() => { ++ const ref = (0, import_react40.useRef)(null); ++ const [place, setPlace] = (0, import_react40.useState)({ left: menu.x, top: menu.y }); ++ (0, import_react40.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_react42.createElement)(AgentMenu, { menu, roster, actions, leaving }); ++ if (menu.kind === "agent" || menu.kind === "agent-session") return (0, import_react40.createElement)(AgentMenu, { menu, roster, actions, leaving }); const bot = roster.byId[menu.id]; const room2 = roster.roomsById[menu.id]; const entry = bot ?? room2; @@ -19844,18 +18520,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_react42.createElement)( ++ return (0, import_react40.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_react42.createElement)( -+ import_react42.Fragment, ++ shown.map((group, index) => (0, import_react40.createElement)( ++ import_react40.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_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", { ++ index > 0 ? (0, import_react40.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, ++ group.map(([label, Icon, run, danger]) => (0, import_react40.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -19864,7 +18540,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_react42.createElement)(Icon), label)) ++ }, (0, import_react40.createElement)(Icon), label)) )) ); } @@ -19872,9 +18548,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_react42.useRef)(null); -+ const [place, setPlace] = (0, import_react42.useState)({ left: menu.x, top: menu.y }); -+ (0, import_react42.useLayoutEffect)(() => { ++ const ref = (0, import_react40.useRef)(null); ++ const [place, setPlace] = (0, import_react40.useState)({ left: menu.x, top: menu.y }); ++ (0, import_react40.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)) }); @@ -19883,18 +18559,18 @@ diff --git a/client.js b/client.js ] ]; - return (0, import_react29.createElement)( -+ return (0, import_react42.createElement)( ++ return (0, import_react40.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_react42.createElement)( -+ import_react42.Fragment, ++ groups.map((group, index) => (0, import_react40.createElement)( ++ import_react40.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_react42.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, -+ group.map(([label, Icon, run, danger]) => (0, import_react42.createElement)("button", { ++ index > 0 ? (0, import_react40.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, ++ group.map(([label, Icon, run, danger]) => (0, import_react40.createElement)("button", { key: label, type: "button", role: "menuitem", @@ -19903,7 +18579,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_react42.createElement)(Icon), label)) ++ }, (0, import_react40.createElement)(Icon), label)) )) ); } @@ -19931,13 +18607,10 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -10985,286 +10985,61 @@ var STORAGE = { - browsers: "ds-bot.browser.v2" - }; - var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"]; --var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; --var LEGACY_THEMES = { grok: "mono" }; --function legacyKeys(key) { +@@ -10988,283 +10988,58 @@ var LEGACY_PREFIXES = ["dsh-bot:", "dsh-bot-team:"]; + var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; + var LEGACY_THEMES = { grok: "mono" }; + function legacyKeys(key) { - if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; - const name = key.slice(key.indexOf(":") + 1); - return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); @@ -20170,9 +18843,6 @@ diff --git a/client.js b/client.js - entry.here ? (0, import_react30.createElement)("button", { type: "button", className: "bt-mw-stop", disabled: stopping, onClick: stop }, t("Stop")) : null - ) - ); -+var LEGACY_BROWSER_KEYS = ["ds-bot.browser.v1", "dsh-bot.browser.v1"]; -+var LEGACY_THEMES = { grok: "mono" }; -+function legacyKeys(key) { + if (key === STORAGE.browsers) return LEGACY_BROWSER_KEYS; + const name = key.slice(key.indexOf(":") + 1); + return LEGACY_PREFIXES.map((prefix) => `${prefix}${name}`); @@ -20265,7 +18935,7 @@ diff --git a/client.js b/client.js // src/client/details-drawer.js -var import_react31 = require("react"); -+var import_react43 = require("react"); ++var import_react41 = 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 ""; @@ -20274,11 +18944,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_react43.useState)(null); ++ const [pending, setPending] = (0, import_react41.useState)(null); const catalog = roster.permissions; const saved = bot.permission ?? catalog?.defaultPreset; - (0, import_react31.useEffect)(() => { -+ (0, import_react43.useEffect)(() => { ++ (0, import_react41.useEffect)(() => { if (pending === saved) setPending(null); }, [pending, saved]); if (!catalog?.options?.length) return null; @@ -20287,17 +18957,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_react43.createElement)( ++ return (0, import_react41.createElement)( "div", { className: "bt-drawer-permission" }, - (0, import_react31.createElement)("div", { className: "bt-section-title" }, t("Permissions")), - (0, import_react31.createElement)( -+ (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Permissions")), -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Permissions")), ++ (0, import_react41.createElement)( Segmented, { label: t("Permissions"), value: current }, - catalog.options.map((option) => (0, import_react31.createElement)("button", { -+ catalog.options.map((option) => (0, import_react43.createElement)("button", { ++ catalog.options.map((option) => (0, import_react41.createElement)("button", { key: option.value, type: "button", "aria-pressed": option.value === current, @@ -20306,56 +18976,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_react43.useEffect)(() => { ++ (0, import_react41.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_react43.createElement)("div", { className: "bt-muted" }, t("The browser is in the DSH Desktop app.")); ++ return (0, import_react41.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_react43.createElement)( ++ return (0, import_react41.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_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")) ++ (0, import_react41.createElement)(BotMark, { bot, size: 40, state: "idle", live: true }), ++ (0, import_react41.createElement)("div", { className: "bt-browser-empty-title" }, t("{name}'s browser", { name: bot.name })), ++ (0, import_react41.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_react41.createElement)("button", { type: "button", className: "bt-browser-retry", onClick: () => actions.openBrowser(sessionId) }, t("Open browser")) ); } - return (0, import_react31.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react41.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_react43.createElement)( ++ return (0, import_react41.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_react43.createElement)( ++ (0, import_react41.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_react43.createElement)( ++ page.excerpt ? (0, import_react41.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_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_react41.createElement)("span", { className: "bt-bcard-ptitle" }, label), ++ (0, import_react41.createElement)("span", { className: "bt-bcard-excerpt" }, page.excerpt) ++ ) : (0, import_react41.createElement)("span", { className: "bt-bcard-blank" }, (0, import_react41.createElement)(GlobeIcon), (0, import_react41.createElement)("span", null, label)) ), - (0, import_react31.createElement)("button", { -+ (0, import_react43.createElement)("button", { ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), @@ -20364,43 +19034,43 @@ diff --git a/client.js b/client.js } }, "×"), - (0, import_react31.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react41.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_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_react41.createElement)(Favicon, { src: page.favicon, className: "bt-bcard-fav" }), ++ (0, import_react41.createElement)("span", { className: "bt-bcard-title" }, label), ++ page.url ? (0, import_react41.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_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")) ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-bcard-new", style: { animationDelay: `${state.ids.length * 30}ms` }, onClick: () => actions.browserNewTab(sessionId) }, (0, import_react41.createElement)(PlusIcon), t("New tab")) ); } function About({ text, title = "About" }) { - const [open, setOpen] = (0, import_react31.useState)(false); -+ const [open, setOpen] = (0, import_react43.useState)(false); ++ const [open, setOpen] = (0, import_react41.useState)(false); const long = text.split("\n").length > 5 || text.length > 240; - const body = (0, import_react31.useRef)(null); -+ const body = (0, import_react43.useRef)(null); ++ const body = (0, import_react41.useRef)(null); useMorph(body, open); - return (0, import_react31.createElement)( -+ return (0, import_react43.createElement)( ++ return (0, import_react41.createElement)( + "div", + null, -+ (0, import_react43.createElement)("div", { className: "bt-section-title" }, t(title)), -+ (0, import_react43.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), -+ long ? (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: () => setOpen((value) => !value) }, open ? t("Show less") : t("Show more")) : null ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t(title)), ++ (0, import_react41.createElement)("div", { ref: body, className: "bt-about", "data-open": open || void 0 }, text), ++ long ? (0, import_react41.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_react43.useState)(room2.pendingApprovals ?? []); -+ const [busy, setBusy] = (0, import_react43.useState)(""); ++ const [items, setItems] = (0, import_react41.useState)(room2.pendingApprovals ?? []); ++ const [busy, setBusy] = (0, import_react41.useState)(""); + const face = room2.origin === "face-to-face"; -+ (0, import_react43.useEffect)(() => { ++ (0, import_react41.useEffect)(() => { + const haveItems = Array.isArray(room2.pendingApprovals) && room2.pendingApprovals.length; + if (haveItems) setItems(room2.pendingApprovals); + if (room2.viewerRole !== "invited-owner" && !face) return void 0; @@ -20426,31 +19096,31 @@ diff --git a/client.js b/client.js + setBusy(""); + } + }; -+ return (0, import_react43.createElement)( ++ return (0, import_react41.createElement)( + "div", + { key: "approvals", className: "bt-shared-wait", "data-flybot": "room-pending-approvals" }, -+ (0, import_react43.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react43.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), -+ items.map((item) => (0, import_react43.createElement)( ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Waiting for your approval"), (0, import_react41.createElement)("span", { className: "bt-chip" }, t("{count} to approve", { count: items.length }))), ++ items.map((item) => (0, import_react41.createElement)( + "div", + { key: item.id, className: "bt-set-row" }, -+ (0, import_react43.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), -+ (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), -+ (0, import_react43.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) ++ (0, import_react41.createElement)("span", { className: "bt-set-copy" }, item.text || item.summary || item.to || t("Outbound reply")), ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + )) + ); +} +function FaceMembers({ room: room2, roster, actions }) { + const people2 = facePeople(room2, roster); + const pick2 = (bot) => call("social/face-lead", { roomId: room2.id, bot }); -+ return (0, import_react43.createElement)( ++ return (0, import_react41.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_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, { ++ (0, import_react41.createElement)(FaceLead, { room: room2, roster, pick: pick2 }), ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: people2.length })), ++ (0, import_react41.createElement)("div", { className: "bt-members", role: "list" }, people2.map((person) => (0, import_react41.createElement)(FacePersonRow, { + key: person.key, + person, + roster, @@ -20476,31 +19146,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_react43.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), -+ invited && room2.sharedFrom && !face ? (0, import_react43.createElement)( ++ (0, import_react41.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), ++ invited && room2.sharedFrom && !face ? (0, import_react41.createElement)( + "div", + { key: "from", className: "bt-shared-from" }, -+ (0, import_react43.createElement)("div", { className: "bt-section-title" }, title), -+ (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), -+ room2.sharedFrom.invitedAt ? (0, import_react43.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, -+ (0, import_react43.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) -+ ) : face ? null : room2.notice ? (0, import_react43.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react43.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), -+ face ? null : (0, import_react43.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), -+ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react43.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react43.createElement)( ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, title), ++ (0, import_react41.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_react41.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, ++ (0, import_react41.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) ++ ) : face ? null : room2.notice ? (0, import_react41.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react41.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), ++ face ? null : (0, import_react41.createElement)(SharedRoomApprovals, { key: "approvals", room: room2 }), ++ face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react41.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react41.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_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)( ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Members · {count}", { count: members.length + humans.length + remotes.length })), ++ (0, import_react41.createElement)("div", { className: "bt-members", role: "list" }, [ ++ ...humans.map((person) => (0, import_react41.createElement)( "div", - { key: member.id, role: "listitem" }, - (0, import_react31.createElement)( + { key: person.id, role: "listitem" }, -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)( "div", { className: "bt-member" }, - (0, import_react31.createElement)( @@ -20508,70 +19178,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_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 ++ (0, import_react41.createElement)("span", { className: "bt-member-name" }, (0, import_react41.createElement)("span", null, person.displayName || person.id)), ++ (0, import_react41.createElement)("span", { className: "bt-chip" }, t("Human")), ++ person.role === "admin" ? (0, import_react41.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + ) + )), -+ ...remotes.map((person) => (0, import_react43.createElement)( ++ ...remotes.map((person) => (0, import_react41.createElement)( + "div", + { key: person.handle || person.id, role: "listitem" }, -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)( + "span", + { className: "bt-member-name" }, -+ (0, import_react43.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), -+ (0, import_react43.createElement)("span", { title: person.handle || void 0 }, person.label || remoteBotTitle(person)) ++ (0, import_react41.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), ++ (0, import_react41.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_react43.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ++ face ? null : (0, import_react41.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + ) + )), + ...members.map((member) => { + const entry = entries2.find((item) => item.id === member.id); -+ return (0, import_react43.createElement)( ++ return (0, import_react41.createElement)( + "div", + { key: member.id, role: "listitem" }, -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)( + "button", + { type: "button", className: "bt-member-name", title: t("Open {name}'s chat", { name: member.name }), onClick: () => actions.openSession(member.id) }, -+ (0, import_react43.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), -+ (0, import_react43.createElement)("span", null, member.name) ++ (0, import_react41.createElement)(BotAvatar, { bot: member, size: 26, main: isMainOf(roster, member.id), admin: room2.admin === member.id, live: false }), ++ (0, import_react41.createElement)("span", null, member.name) + ), -+ room2.admin === member.id ? (0, import_react43.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null ++ room2.admin === member.id ? (0, import_react41.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null + ), -+ entry ? (0, import_react43.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null ++ entry ? (0, import_react41.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null + ); + }) + ]) ), - (0, import_react31.createElement)( -+ face ? null : (0, import_react43.createElement)( ++ face ? null : (0, import_react41.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_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"))) ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Who answers")), ++ (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, t(ROOM_MODES.find(([id]) => id === shownMode(room2, roster))?.[1] ?? "Everyone"))) ), - usage ? (0, import_react31.createElement)( -+ face || !usage ? null : (0, import_react43.createElement)( ++ face || !usage ? null : (0, import_react41.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_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)) ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Usage")), ++ (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, room2.name), (0, import_react41.createElement)("span", null, usage)) + ) ]; } @@ -20580,21 +19250,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_react43.useState)("details"); ++ const [tab, setTab] = (0, import_react41.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_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 [view, setView] = (0, import_react41.useState)(startTab === "settings" && (owner || room2) ? "settings" : "overview"); ++ const [editing, setEditing] = (0, import_react41.useState)(Boolean(startRenaming && (roster.byId[sessionId] || roster.roomsById[sessionId]))); ++ const [draftName, setDraftName] = (0, import_react41.useState)(roster.byId[sessionId]?.name ?? (roster.roomsById[sessionId]?.named ? roster.roomsById[sessionId].name : "")); ++ const [copied, setCopied] = (0, import_react41.useState)(false); ++ const [error, setError] = (0, import_react41.useState)(""); const glide = useGlide(tab); - const last = (0, import_react31.useRef)({ view, tab, move: void 0 }); -+ const last = (0, import_react43.useRef)({ view, tab, move: void 0 }); ++ const last = (0, import_react41.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; @@ -20604,56 +19274,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_react43.createElement)("div", { className: "bt-error", role: "alert", style: { padding: 0 } }, error) : null; ++ const errorLine = error ? (0, import_react41.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_react43.createElement)( ++ (0, import_react41.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_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_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Back to details"), onClick: () => setView("overview") }, (0, import_react41.createElement)(ChevronLeftIcon)), ++ (0, import_react41.createElement)("span", { className: "bt-subhead-title" }, bot ? t("Settings") : t("Group settings")), ++ hasStrip ? null : (0, import_react41.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close details"), onClick: close }, (0, import_react41.createElement)(CloseIcon)) ), - (0, import_react31.createElement)( -+ (0, import_react43.createElement)( ++ (0, import_react41.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_react43.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react43.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ++ bot ? (0, import_react41.createElement)(BotSettingsForm, { bot, roster, actions, run }) : (0, import_react41.createElement)(GroupSettingsForm, { room: room2, roster, actions, run, onDeleted: close }) ) ]; } - const top = (0, import_react31.createElement)( -+ const top = (0, import_react43.createElement)( ++ const top = (0, import_react41.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_react43.createElement)("button", { ++ bot || room2 ? (0, import_react41.createElement)("button", { + type: "button", + className: "bt-round", + "aria-label": bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), + title: bot ? bot.remote ? t("Bot settings") : t("Edit Bot") : t("Group settings"), + "data-info-row": "settings", + onClick: () => bot?.remote ? actions.openSettings("bots", bot.id) : setView("settings") -+ }, (0, import_react43.createElement)(GearIcon)) : null, -+ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Open browser"), title: t("Open this Bot's browser"), onClick: () => actions.openBrowser(sessionId) }, (0, import_react43.createElement)(GlobeIcon)) : null, -+ bot || room2 ? (0, import_react43.createElement)("button", { type: "button", className: "bt-round", "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)) ++ }, (0, import_react41.createElement)(GearIcon)) : null, ++ bot && !bot.remote && roster.browser === true && actions.openBrowser ? (0, import_react41.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_react41.createElement)(GlobeIcon)) : null, ++ bot || room2 ? (0, import_react41.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_react41.createElement)("span", { key: "copied", className: "bt-pop-in" }, "✓") : (0, import_react41.createElement)(ShareIcon)) : null, ++ hasStrip ? null : (0, import_react41.createElement)("button", { type: "button", className: "bt-round", "aria-label": t("Close details"), onClick: close }, (0, import_react41.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_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."))) ++ (0, import_react41.createElement)(import_react41.Fragment, { key: "top" }, top), ++ (0, import_react41.createElement)("div", { key: "body", className: "bt-drawer-body" }, (0, import_react41.createElement)("div", { className: "bt-muted" }, t("This conversation is not part of your Bot team."))) ]; } const current = bot?.name ?? room2.name; @@ -20663,14 +19333,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_react43.createElement)(import_react43.Fragment, { key: "top" }, top), -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)(import_react41.Fragment, { key: "top" }, top), ++ (0, import_react41.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_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", { ++ bot ? (0, import_react41.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react41.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), ++ bot?.remote ? (0, import_react41.createElement)("div", { className: "bt-drawer-name" }, current) : room2?.origin === "face-to-face" ? (0, import_react41.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react41.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, @@ -20679,82 +19349,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_react43.createElement)("button", { type: "button", className: "bt-drawer-name", "aria-label": t("Rename {name}", { name: current }), title: t("Rename"), onClick: () => { ++ }) : (0, import_react41.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_react43.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react43.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ++ subtitle ? (0, import_react41.createElement)("span", { className: "bt-drawer-role" }, subtitle) : bot?.remote ? (0, import_react41.createElement)("span", { className: "bt-drawer-role" }, t("Friend's Bot")) : null ), - (0, import_react31.createElement)( -+ bot?.remote ? null : (0, import_react43.createElement)( ++ bot?.remote ? null : (0, import_react41.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_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_react41.createElement)("span", { ref: glide.thumb, className: "bt-thumb", "aria-hidden": true }), ++ tabs.map((key) => (0, import_react41.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_react43.createElement)( ++ (0, import_react41.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_react43.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, ++ room2 && tab === "details" ? (0, import_react41.createElement)(GroupOverview, { room: room2, roster, actions, usage, useWork, openSettings: () => setView("settings") }) : null, + bot && bot.remote && tab === "details" ? [ -+ bot.remote.handle ? (0, import_react43.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, -+ (0, import_react43.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) ++ bot.remote.handle ? (0, import_react41.createElement)("div", { key: "handle", className: "bt-muted" }, bot.remote.handle) : null, ++ (0, import_react41.createElement)(FlyBotDetails, { key: "social", bot, roster, actions }) + ] : null, + bot && !bot.remote && tab === "details" ? [ -+ (0, import_react43.createElement)( ++ (0, import_react41.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_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_react41.createElement)("div", { className: "bt-section-title" }, t("Model")), ++ (0, import_react41.createElement)(ModelMenu, { bot, roster, actions, run, wide: true }), ++ modelOf(bot, roster) ? null : (0, import_react41.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_react43.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), -+ (0, import_react43.createElement)( ++ (0, import_react41.createElement)(PermissionRow, { key: "permission", bot, roster, actions, run }), ++ (0, import_react41.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_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 })) }) ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Working folder")), ++ (0, import_react41.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_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)( ++ bot.instructions ? (0, import_react41.createElement)(About, { key: "about", text: bot.instructions }) : (0, import_react41.createElement)("button", { key: "about", type: "button", className: "bt-more", onClick: () => setView("settings") }, t("Add instructions in Bot settings")), ++ roster.memory === false ? null : (0, import_react41.createElement)(MemoryRow, { key: "memory", bot, actions }), ++ usage ? (0, import_react41.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_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)) ++ (0, import_react41.createElement)("div", { className: "bt-section-title" }, t("Usage")), ++ (0, import_react41.createElement)("div", { className: "bt-kv" }, (0, import_react41.createElement)("span", null, bot.name), (0, import_react41.createElement)("span", null, usage)) ) : null, - (0, import_react31.createElement)(ScheduleSummary, { key: "routines", bot, actions }) -+ (0, import_react43.createElement)(ScheduleSummary, { key: "routines", bot, actions }) ++ (0, import_react41.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_react43.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ++ bot && !bot.remote && tab === "browser" ? (0, import_react41.createElement)(BrowserCards, { bot, sessionId, roster, actions, useBrowserTabs, useBrowserPages }) : null ) ]; } // src/client/browser-pane.js -var import_react32 = require("react"); -+var import_react44 = require("react"); ++var import_react42 = require("react"); var import_react_dom8 = require("react-dom"); var PANEL_LINGER_MS = 300; var WIDTH_KEY = STORAGE.browserWidth; @@ -20771,11 +19441,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_react44.createElement)( ++ return (0, import_react42.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_react44.createElement)("path", { key: index, d })) ++ paths.map((d, index) => (0, import_react42.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"]); @@ -20798,9 +19468,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_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) }); ++ const [failed, setFailed] = (0, import_react42.useState)(null); ++ if (!src || failed === src) return (0, import_react42.createElement)("span", { className: `${className} bt-ptab-globe` }, (0, import_react42.createElement)(GlobeIcon)); ++ return (0, import_react42.createElement)("img", { className, src, alt: "", "aria-hidden": true, onError: () => setFailed(src) }); } var savedWidth = () => { try { @@ -20823,11 +19493,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_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 wrap = (0, import_react42.useRef)(null); ++ const ind = (0, import_react42.useRef)(null); ++ const spot = (0, import_react42.useRef)(null); ++ const under = (0, import_react42.useRef)(null); ++ (0, import_react42.useLayoutEffect)(() => { const box = wrap.current; const capsule = ind.current; if (!box || !capsule) return void 0; @@ -20836,7 +19506,7 @@ diff --git a/client.js b/client.js }; }); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { const box = wrap.current; if (!box) return void 0; const observer = new ResizeObserver(() => { @@ -20845,15 +19515,15 @@ diff --git a/client.js b/client.js return () => observer.disconnect(); }, []); - return (0, import_react32.createElement)( -+ return (0, import_react44.createElement)( ++ return (0, import_react42.createElement)( "div", { className: "bt-ptabs", role: "tablist", "aria-label": t("Panel tabs") }, - (0, import_react32.createElement)( -+ (0, import_react44.createElement)( ++ (0, import_react42.createElement)( "div", { className: "bt-ptabs-in", ref: wrap }, - (0, import_react32.createElement)( -+ (0, import_react44.createElement)( ++ (0, import_react42.createElement)( "button", { type: "button", @@ -20863,14 +19533,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_react44.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), -+ view === "details" ? (0, import_react44.createElement)("span", { className: "bt-ptab-name" }, bot.name) : null ++ (0, import_react42.createElement)(BotMark, { bot, size: 20, state: reading ? "searching" : "idle" }), ++ view === "details" ? (0, import_react42.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_react44.createElement)( ++ return (0, import_react42.createElement)( "button", { key: tabId, @@ -20881,9 +19551,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_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", { ++ (0, import_react42.createElement)(Favicon, { src: page.favicon, className: "bt-ptab-fav" }), ++ (0, import_react42.createElement)("span", { className: "bt-ptab-title" }, tabLabel(page)), ++ (0, import_react42.createElement)("span", { className: "bt-ptab-x", role: "button", "aria-label": t("Close tab"), @@ -20892,13 +19562,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_react44.createElement)("span", { ref: ind, className: "bt-ptab-ind", "aria-hidden": true }) ++ (0, import_react42.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_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)) ++ (0, import_react42.createElement)("button", { type: "button", className: "bt-ptab-new", "aria-label": t("New tab"), title: t("New tab"), onClick: () => actions.browserNewTab(subject) }, (0, import_react42.createElement)(PlusTabIcon)), ++ (0, import_react42.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_react42.createElement)(CloseIcon2)) ); } function BrowserView({ bot, tabId, leaving, landed, actions, useBrowserPages }) { @@ -20906,12 +19576,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_react44.useRef)(null); -+ const input = (0, import_react44.useRef)(null); -+ const [draft, setDraft] = (0, import_react44.useState)(null); ++ const frame = (0, import_react42.useRef)(null); ++ const input = (0, import_react42.useRef)(null); ++ const [draft, setDraft] = (0, import_react42.useState)(null); const showGuest = landed && !leaving && Boolean(page.url) && !page.error; - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react44.useLayoutEffect)(() => { ++ (0, import_react42.useLayoutEffect)(() => { const element = frame.current; if (!element || leaving || !tabId) { void actions.browserShow(null); @@ -20920,7 +19590,7 @@ diff --git a/client.js b/client.js }; }, [tabId, showGuest, leaving]); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { setDraft(null); if (landed && !page.url) input.current?.focus(); }, [tabId, landed]); @@ -20929,19 +19599,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_react44.createElement)( ++ return (0, import_react42.createElement)( "div", { className: "bt-panel-view bt-panel-browser", "aria-label": t("{name}'s browser", { name: bot.name }) }, - (0, import_react32.createElement)( -+ (0, import_react44.createElement)( ++ (0, import_react42.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_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", { ++ (0, import_react42.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_react42.createElement)(BackIcon)), ++ (0, import_react42.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_react42.createElement)(ForwardIcon)), ++ (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", disabled: !page.url, @@ -20950,14 +19620,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_react44.createElement)(page.loading ? StopIcon : ReloadIcon)), -+ (0, import_react44.createElement)( ++ }, (0, import_react42.createElement)(page.loading ? StopIcon : ReloadIcon)), ++ (0, import_react42.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_react44.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react44.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), -+ (0, import_react44.createElement)("input", { ++ (0, import_react42.createElement)("span", { className: "bt-browser-scheme", "aria-hidden": true }, (0, import_react42.createElement)(secure && !editing ? LockIcon2 : GlobeIcon)), ++ (0, import_react42.createElement)("input", { ref: input, type: "text", spellCheck: false, @@ -20966,34 +19636,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_react44.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ++ page.loading ? (0, import_react42.createElement)("span", { className: "bt-browser-progress", "aria-hidden": true }) : null ), - (0, import_react32.createElement)( -+ (0, import_react44.createElement)( ++ (0, import_react42.createElement)( "div", { ref: frame, className: "bt-browser-frame", "data-guest": showGuest || void 0 }, - page.error ? (0, import_react32.createElement)( -+ page.error ? (0, import_react44.createElement)( ++ page.error ? (0, import_react42.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_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: () => { ++ (0, import_react42.createElement)("div", { className: "bt-browser-empty-title" }, t("This page didn’t load")), ++ (0, import_react42.createElement)("div", { className: "bt-browser-empty-text" }, t(page.error)), ++ page.url ? (0, import_react42.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_react44.createElement)( ++ ) : !page.url ? (0, import_react42.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_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 })) ++ (0, import_react42.createElement)(BotMark, { bot, size: 48, 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-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 ) ); @@ -21009,36 +19679,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_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 root = (0, import_react42.useRef)(null); ++ const fly = (0, import_react42.useRef)(null); ++ const [landed, setLanded] = (0, import_react42.useState)(false); ++ const [settling, setSettling] = (0, import_react42.useState)(false); ++ const [width, setWidth] = (0, import_react42.useState)(savedWidth); ++ const [viewport, setViewport] = (0, import_react42.useState)(() => window.innerWidth); ++ const [dragging, setDragging] = (0, import_react42.useState)(false); ++ const [resizing, setResizing] = (0, import_react42.useState)(false); const instant = dragging || resizing; - const [reading, setReading] = (0, import_react32.useState)(false); -+ const [reading, setReading] = (0, import_react44.useState)(false); ++ const [reading, setReading] = (0, import_react42.useState)(false); const panelWidth = fitWidth(width ?? (view === "browser" ? DEFAULT_WIDTH : DETAILS_WIDTH), widthRange(view, viewport)); - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react44.useLayoutEffect)(() => { ++ (0, import_react42.useLayoutEffect)(() => { if (view === "browser" && width === null) setWidth(DEFAULT_WIDTH); }, [view, width]); - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react44.useLayoutEffect)(() => { ++ (0, import_react42.useLayoutEffect)(() => { chatRoom.hold(instant); }, [instant]); - const [last, setLast] = (0, import_react32.useState)({ view, width: panelWidth }); -+ const [last, setLast] = (0, import_react44.useState)({ view, width: panelWidth }); ++ const [last, setLast] = (0, import_react42.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_react44.useRef)(view); -+ (0, import_react44.useLayoutEffect)(() => { ++ const shownView = (0, import_react42.useRef)(view); ++ (0, import_react42.useLayoutEffect)(() => { const switched = settling && shownView.current !== view; shownView.current = view; if (switched) chatRoom.hold(true); @@ -21046,12 +19716,12 @@ diff --git a/client.js b/client.js if (switched) chatRoom.hold(false); }, [view, panelWidth, leaving]); - (0, import_react32.useEffect)(() => () => { -+ (0, import_react44.useEffect)(() => () => { ++ (0, import_react42.useEffect)(() => () => { chatRoom.set(null); chatRoom.hold(false); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { let timer; const onResize = () => { setResizing(true); @@ -21060,34 +19730,34 @@ diff --git a/client.js b/client.js }; }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.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_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { const timer = setTimeout(() => setLanded(true), 360); return () => clearTimeout(timer); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { if (canBrowse) actions.browserEnsure(bot.id); }, [bot?.id, canBrowse]); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { if (view === "browser" && (active === null || !tabState.ids.includes(active))) actions.setPanelView("details"); }, [view, active, tabState.ids.length]); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { if (view !== "browser" || leaving) void actions.browserShow?.(null); }, [view, leaving]); - (0, import_react32.useEffect)(() => () => { -+ (0, import_react44.useEffect)(() => () => { ++ (0, import_react42.useEffect)(() => () => { void actions.browserShow?.(null); }, []); - (0, import_react32.useEffect)(() => { -+ (0, import_react44.useEffect)(() => { ++ (0, import_react42.useEffect)(() => { if (!page.readAt || Date.now() - page.readAt > READ_FLASH_MS) return void 0; setReading(true); const timer = setTimeout(() => setReading(false), READ_FLASH_MS); @@ -21096,7 +19766,7 @@ diff --git a/client.js b/client.js }; } - (0, import_react32.useLayoutEffect)(() => { -+ (0, import_react44.useLayoutEffect)(() => { ++ (0, import_react42.useLayoutEffect)(() => { const move = fly.current; if (!move) return; fly.current = null; @@ -21105,7 +19775,7 @@ diff --git a/client.js b/client.js }; const hasStrip = canBrowse && tabState.ids.length > 0; - return (0, import_react32.createElement)( -+ return (0, import_react44.createElement)( ++ return (0, import_react42.createElement)( "aside", { ref: root, @@ -21114,19 +19784,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_react44.createElement)("div", { className: "bt-panel-grip", role: "separator", "aria-orientation": "vertical", "aria-label": t("Resize panel"), onPointerDown: startResize }), ++ leaving ? null : (0, import_react42.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_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)( ++ dragging ? (0, import_react_dom8.createPortal)((0, import_react42.createElement)("div", { className: "bt-drag-shield", "aria-hidden": true }), document.body) : null, ++ hasStrip ? (0, import_react42.createElement)(PanelStrip, { bot, subject: pane.id, view, ids: tabState.ids, active, reading, onView: switchView, actions: panelActions, useBrowserPages }) : null, ++ view === "browser" && bot ? (0, import_react42.createElement)(BrowserView, { key: `browser:${active}`, bot, tabId: active, leaving, landed: landed && !settling, actions, useBrowserPages }) : (0, import_react42.createElement)( "div", { key: `details:${pane.id}:${pane.renaming ? "rename" : ""}:${pane.tab ?? ""}`, className: "bt-panel-view" }, - (0, import_react32.createElement)(DetailsDrawer, { -+ (0, import_react44.createElement)(DetailsDrawer, { ++ (0, import_react42.createElement)(DetailsDrawer, { sessionId: pane.id, startRenaming: pane.renaming, startTab: pane.tab, @@ -21135,7 +19805,7 @@ diff --git a/client.js b/client.js `; +// src/client/remote-bot-settings.js -+var import_react45 = require("react"); ++var import_react43 = 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} @@ -21159,8 +19829,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_react45.useRef)(null); -+ (0, import_react45.useEffect)(() => { ++ const dialog = (0, import_react43.useRef)(null); ++ (0, import_react43.useEffect)(() => { + const before = document.activeElement; + dialog.current?.focus?.({ preventScroll: true }); + return () => { @@ -21175,30 +19845,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_react45.createElement)( ++ return (0, import_react43.createElement)( + "div", + { className: "bt-remote-sheet-layer", "data-leaving": leaving || void 0, "data-flybot": "remote-bot-sheet" }, -+ (0, import_react45.createElement)("div", { className: "bt-scrim", onMouseDown: close }), -+ (0, import_react45.createElement)( ++ (0, import_react43.createElement)("div", { className: "bt-scrim", onMouseDown: close }), ++ (0, import_react43.createElement)( + "div", + { className: "bt-remote-sheet", role: "dialog", "aria-modal": true, "aria-label": t("Bot settings"), tabIndex: -1, ref: dialog, onKeyDown }, -+ (0, import_react45.createElement)( ++ (0, import_react43.createElement)( + "div", + { className: "bt-remote-sheet-head" }, -+ (0, import_react45.createElement)(BotMark, { bot, size: 40, live: true }), -+ (0, import_react45.createElement)( ++ (0, import_react43.createElement)(BotMark, { bot, size: 40, live: true }), ++ (0, import_react43.createElement)( + "div", + { className: "bt-remote-sheet-who" }, -+ (0, import_react45.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), -+ (0, import_react45.createElement)("span", { className: "bt-remote-sheet-sub" }, who) ++ (0, import_react43.createElement)("span", { className: "bt-remote-sheet-name" }, bot.name), ++ (0, import_react43.createElement)("span", { className: "bt-remote-sheet-sub" }, who) + ), -+ (0, import_react45.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react45.createElement)(CloseIcon)) ++ (0, import_react43.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": t("Close settings"), onClick: close }, (0, import_react43.createElement)(CloseIcon)) + ), -+ (0, import_react45.createElement)( ++ (0, import_react43.createElement)( + "div", + { className: "bt-remote-sheet-body" }, -+ bot.remote.handle ? (0, import_react45.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, -+ (0, import_react45.createElement)(FlyBotDetails, { bot, roster, actions }) ++ bot.remote.handle ? (0, import_react43.createElement)("div", { className: "bt-muted", style: { marginBottom: 8, fontSize: 12 } }, bot.remote.handle) : null, ++ (0, import_react43.createElement)(FlyBotDetails, { bot, roster, actions }) + ) + ) + ); @@ -21206,7 +19876,7 @@ diff --git a/client.js b/client.js + // src/client/overlays.js -var import_react33 = require("react"); -+var import_react46 = require("react"); ++var import_react44 = require("react"); var import_react_dom9 = require("react-dom"); var DIALOG_EXIT_MS = 160; var MENU_EXIT_MS3 = 120; @@ -21216,8 +19886,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_react46.useRef)(current); -+ (0, import_react46.useEffect)(() => { ++ const previous = (0, import_react44.useRef)(current); ++ (0, import_react44.useEffect)(() => { if (previous.current === current) return; previous.current = current; if (current) actions.followPanel?.(current); @@ -21225,8 +19895,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_react46.useState)(open); -+ (0, import_react46.useEffect)(() => { ++ const [kept, setKept] = (0, import_react44.useState)(open); ++ (0, import_react44.useEffect)(() => { if (open) { setKept(open); return void 0; @@ -21242,8 +19912,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_react46.createElement)( -+ import_react46.Fragment, ++ return (0, import_react44.createElement)( ++ import_react44.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, @@ -21253,17 +19923,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_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 ++ pane ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(NewChat, { key: ui2.newChat, mode: ui2.newChat, roster, actions }), document.body) : null, ++ ui2.palette ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(ExchangeDialog, { pair: ui2.exchange, roster, actions }), document.body) : null, ++ ui2.voice ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(VoiceMode, { key: ui2.voice, sessionId: ui2.voice, roster, actions, useSessions, useSessionStatus }), document.body) : null, ++ menu ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(SettingsDialog, { page: settings.page, id: settings.id, roster, actions, useUpdate, leaving: settingsLeaving }), document.body) : null, ++ remoteBotSettings ? (0, import_react_dom9.createPortal)((0, import_react44.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_react44.createElement)(MemoryDialog, { key: memory, bot: roster.byId[memory], actions, leaving: memoryLeaving }), document.body) : null, ++ inbox ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(InboxDialog, { roster, useWork, useSessions, actions, leaving: inboxLeaving }), document.body) : null, ++ board ? (0, import_react_dom9.createPortal)((0, import_react44.createElement)(KanbanDialog, { roster, actions, leaving: boardLeaving }), document.body) : null ); } @@ -21471,7 +20141,7 @@ diff --git a/client.js b/client.js // src/client/user-message.js -var import_react34 = require("react"); -+var import_react47 = require("react"); ++var import_react45 = require("react"); var ui = __toESM(require("@deepseek-ai/dsh-client-ui-primitives"), 1); var canDrawUserMessages = () => typeof ui.projectUserText === "function"; function contentParts(content) { @@ -21558,37 +20228,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_react47.createElement)(BotMark, { bot: part.bot.draw ?? part.bot, size: 16 }), `@${part.bot.mentionLabel ?? part.label}`]; ++ const chip = (part) => [(0, import_react45.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_react47.createElement)(import_react47.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); ++ if (!part.bot) return (0, import_react45.createElement)(import_react45.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)); + const title = `@${part.bot.name}`; -+ 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)); ++ return typeof part.bot.id === "string" ? (0, import_react45.createElement)("button", { key: index, type: "button", className: "bt-umention", title, onClick: () => actions.openSession(part.bot.id) }, chip(part)) : (0, import_react45.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_react47.createElement)("div", { className: "bt-uattach" }, attachments.map((block, index) => { ++ return (0, import_react45.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_react47.createElement)(import_react47.Fragment, { key: index }, renderMessageImages({ images: [{ attachment: block.attachment }], align: "end", compact: compact2 })) : null; ++ return renderMessageImages ? (0, import_react45.createElement)(import_react45.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_react47.createElement)( ++ return (0, import_react45.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_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) ++ ui.FileTypeIcon ? (0, import_react45.createElement)(ui.FileTypeIcon, { path: file.name, className: "bt-ufile-icon" }) : null, ++ (0, import_react45.createElement)("span", { className: "bt-ufile-text" }, (0, import_react45.createElement)("span", { className: "bt-ufile-name" }, file.name), meta ? (0, import_react45.createElement)("span", { className: "bt-ufile-meta" }, meta) : null) ); })); } function CopyButton({ text }) { - const [copied, setCopied] = (0, import_react34.useState)(false); -+ const [copied, setCopied] = (0, import_react47.useState)(false); ++ const [copied, setCopied] = (0, import_react45.useState)(false); const copy2 = () => { void navigator.clipboard?.writeText(text).then(() => { setCopied(true); @@ -21596,7 +20266,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_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)); ++ return (0, import_react45.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_react45.createElement)(CheckIcon) : (0, import_react45.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 }) { @@ -21612,7 +20282,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_react47.createElement)( ++ return (0, import_react45.createElement)( "div", - { className: "bt-umsg", "data-steering": steering || void 0 }, - (0, import_react34.createElement)( @@ -21624,28 +20294,28 @@ diff --git a/client.js b/client.js + "data-author-kind": author?.authorKind, + "data-author-name": author?.name + }, -+ 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)( ++ person ? (0, import_react45.createElement)("span", { className: "bt-lead", title: author.name }, (0, import_react45.createElement)(PersonMark, { name: author.name })) : null, ++ faceMine ? (0, import_react45.createElement)("span", { className: "bt-lead", title: myName || void 0 }, (0, import_react45.createElement)(PersonMark, { name: myName || void 0 })) : null, ++ (0, import_react45.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_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)( ++ faceMine && myName ? (0, import_react45.createElement)("span", { className: "bt-uauthor" }, myName) : author && (incoming || multiHuman) ? (0, import_react45.createElement)("span", { className: "bt-uauthor" }, author.name, author.ownerName ? (0, import_react45.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) : null) : null, ++ attachments.length > 0 ? (0, import_react45.createElement)(Attachments, { attachments, renderMessageImages }) : null, ++ text !== "" || rest.length > 0 ? (0, import_react45.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_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) ++ (0, import_react45.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_react45.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_react47.createElement)("div", { className: "bt-urefs" }, t("Referenced {labels}", { labels: referenceLabels.join(t(", ")) })) : null ++ referenceLabels.length > 0 ? (0, import_react45.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_react47.createElement)("div", { className: "bt-utools" }, (0, import_react47.createElement)(CopyButton, { text })) ++ text === "" ? null : (0, import_react45.createElement)("div", { className: "bt-utools" }, (0, import_react45.createElement)(CopyButton, { text })) ); } var USER_MESSAGE_CSS = ` @@ -21747,7 +20417,7 @@ diff --git a/client.js b/client.js +} + +// src/client/stats-dock.js -+var import_react48 = require("react"); ++var import_react46 = require("react"); +var STATS_DOCK = "conversation.composer.dock"; +var STATS_ID = "stats"; +var STATS_IDS = [STATS_ID, "activity"]; @@ -21763,17 +20433,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_react48.useMemo)(() => typeof props.t === "function" ? pluralStats(props.t) : props.t, [props.t]); ++ const t2 = (0, import_react46.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_react48.createElement)(entry.component, { ...props, t: t2 }); ++ return (0, import_react46.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_react49 = require("react"); ++var import_react47 = require("react"); var inject = ["sessions", "uiWorkspace", "uiConversation", "slots", "theme", "layout"]; function apply(ctx) { + const flybot = createClientApi2(); @@ -21989,7 +20659,7 @@ diff --git a/client.js b/client.js stopMemberWork: (roomId, botId) => after(call("stop-member-work", { roomId, botId })), // Values go out in these requests and never come back in a response. secretSet: (payload) => after(call("secret-set", payload)), -@@ -13819,15 +13819,33 @@ function apply(ctx) { +@@ -13819,15 +13819,31 @@ function apply(ctx) { secretCancel: (requestId) => after(call("secret-cancel", { requestId })), secretUpdate: (name, scope) => after(call("secret-update", { name, scope })), secretDelete: (name) => after(call("secret-delete", { name })), @@ -22011,8 +20681,6 @@ diff --git a/client.js b/client.js + setBotConnector: (botId, connector, enabled) => after(call("bot-connectors", { botId, connector, enabled })), setBotCapability: (botId, name, attached) => after(call("bot-capability", { botId, name, attached })), + tasks: (payload) => call("tasks", payload ?? {}), -+ deliveryBoard: (payload) => call("delivery-board", payload ?? {}), -+ deliveryProbe: (payload) => call("delivery-probe", payload ?? {}), + taskUpdate: (payload) => call("task-update", payload), + taskCreate: (payload) => call("task-create", payload), schedules: () => call("schedules"), @@ -22200,7 +20868,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_react49.createElement)(TurnFailures, { ...props, work }) }); ++ const off = registry().register("flybot.session.dock", { id: "flybot-turn-failures", order: 20, component: (props) => (0, import_react47.createElement)(TurnFailures, { ...props, work }) }); + return () => { + off(); + style.remove(); diff --git a/patches/ds-bot/files/scripts/delivery-probe.mjs b/patches/ds-bot/files/scripts/delivery-probe.mjs deleted file mode 100644 index ef03a659a..000000000 --- a/patches/ds-bot/files/scripts/delivery-probe.mjs +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node -// The delivery board's diagnostic entry, as a shell (#1211): the same `probeGitHub()` the view's -// 诊断 button calls, print-only. It asks the five reads the board makes and prints one line each, -// so an instance that "does not come up" can be explained by pasting five lines instead of guessing -// which permission is missing. There is no second implementation here on purpose. -// -// Which repositories it reads is the host's own question (#1250, #1265), answered by the same -// `resolveRepos()` the board installs: `--repo` (repeatable), else the `origin` of `--workspace` -// (default the current folder), else the package's own `repository.url`. Every repository the board -// would read is probed under its own name and source, because a status without its repository -// cannot be acted on. -// -// node core/scripts/delivery-probe.mjs # repo from cwd's git remote, token from GITHUB_TOKEN -// node core/scripts/delivery-probe.mjs --repo o/r --token-env MY_TOKEN -// node core/scripts/delivery-probe.mjs --repo o/r --repo acme/widget -// node core/scripts/delivery-probe.mjs --workspace ../my-checkout -import { probeGitHub } from '../src/host/delivery-board.js' -import { NO_REPO_MESSAGE, resolveRepos } from '../src/host/delivery-repo.js' -import { repoSlug } from '../src/host/update.js' - -const argv = process.argv.slice(2) -const argOf = (name, fallback = null) => { - const index = argv.indexOf(name) - return index === -1 ? fallback : argv[index + 1] ?? fallback -} -const allOf = (name) => { - const values = [] - for (let index = 0; index < argv.length; index += 1) { - if (argv[index] === name && argv[index + 1] !== undefined) values.push(argv[index + 1]) - } - return values -} - -const repos = resolveRepos({ - setting: allOf('--repo'), - workspace: argOf('--workspace', process.cwd()), - packageSlug: repoSlug(), -}) -if (repos.length === 0) { - console.error(`delivery-probe: ${NO_REPO_MESSAGE} (pass --repo owner/name, or --workspace with a GitHub origin)`) - process.exit(2) -} -const tokenEnv = argOf('--token-env', 'GITHUB_TOKEN') -const token = process.env[tokenEnv] ?? null -let answered = true -for (const found of repos) { - const report = await probeGitHub({ repo: found.repo, token }) - console.log(`delivery-probe ${found.repo} · ${found.source} · ${report.at}${token === null ? ` · no ${tokenEnv}` : ''}`) - for (const row of report.rows) { - const status = row.status === null ? '—' : String(row.status) - console.log(`${row.ok ? 'ok ' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind}${row.message === null ? '' : `: ${row.message}`}`}`) - } - console.log(report.ok ? 'all five answer' : 'one or more of the five is refused; the FAIL line names which') - answered = answered && report.ok -} -process.exit(answered ? 0 : 1) diff --git a/patches/ds-bot/files/src/client/board-card.js b/patches/ds-bot/files/src/client/board-card.js index ae1bcbd64..33332de55 100644 --- a/patches/ds-bot/files/src/client/board-card.js +++ b/patches/ds-bot/files/src/client/board-card.js @@ -1,29 +1,17 @@ -// One card component behind both boards (#1265, step 1). A delivery card and a task card draw the -// same pieces — an identity line, a title, what it wears, who has it, what it is doing — so they -// render through this one component with the `source` their card model carries. -// -// `skin` is the calling board's class vocabulary. Two skins, not a third look: the delivery board -// keeps its stylesheet and the task board keeps its own while the shape is shared, and moving the -// columns into one set (step 2) no longer has to touch either stylesheet's card markup. +// The task board's card frame. A card says `source: 'task'` and carries `data-key`, and the +// board passes `TASK_SKIN` so the markup stays on this board's class names. import { createElement as h } from 'react' -import { t } from './i18n.js' - -export const DELIVERY_SKIN = { - card: 'bt-del-card', top: 'bt-del-card-top', id: 'bt-del-id', badge: 'bt-del-badge', - title: 'bt-del-title', labels: 'bt-del-labels', label: 'bt-del-label', owner: 'bt-del-owner', - linked: 'bt-del-linked', sha: 'bt-del-sha', -} export const TASK_SKIN = { card: 'bt-board-card', top: 'bt-board-card-top', id: 'bt-board-id', badge: 'bt-board-badge', title: 'bt-board-title', labels: 'bt-board-labels', label: 'bt-board-label', owner: 'bt-board-who', - linked: 'bt-board-linked', sha: 'bt-board-sha', note: 'bt-board-note', + note: 'bt-board-note', } -// `attrs` is the board's own extra attributes (a task's `data-column`, a card's `data-number`), so -// the existing selectors and tests keep reading the same elements. +// `attrs` is the board's own extra attributes (a task's `data-column`), so the existing +// selectors and tests keep reading the same elements. export function BoardCard({ - card, skin = DELIVERY_SKIN, attrs = {}, idText = '', topExtra = null, badge = '', + card, skin = TASK_SKIN, attrs = {}, idText = '', topExtra = null, badge = '', meta = null, note = '', footer = null, actions = null, emptyTitle = '', draggable = false, dragValue = '', onDragStart = null, onDragEnd = null, }) { @@ -52,11 +40,8 @@ export function BoardCard({ ? h('span', { className: skin.labels }, labels.map(name => h('span', { key: name, className: skin.label, 'data-label': name }, name))) : null, card?.owner ? h('span', { className: skin.owner, 'data-kind': card.ownerKind ?? 'human' }, card.owner) : null, - note ? h('span', { className: skin.note ?? skin.linked }, note) : null, + note ? h('span', { className: skin.note }, note) : null, meta, - card?.linked ? h('span', { className: skin.linked }, t('linked to #{number}', { number: card.linked })) : null, - card?.sha ? h('span', { className: skin.sha }, card.sha) : null, - card?.url ? h('a', { className: skin.sha, href: card.url, target: '_blank', rel: 'noreferrer' }, t('open on GitHub')) : null, footer, actions) } diff --git a/patches/ds-bot/files/src/client/delivery-chart.js b/patches/ds-bot/files/src/client/delivery-chart.js deleted file mode 100644 index e9ff2131c..000000000 --- a/patches/ds-bot/files/src/client/delivery-chart.js +++ /dev/null @@ -1,243 +0,0 @@ -// Delivery trend charts (#1212): one 7-day line per countable metric, drawn from `metrics.daily`. -// The host half builds the series (Asia/Shanghai days, the last entry carries `partial: true` until -// the day is over); this module only draws it, and a snapshot without the series says so instead of -// guessing. A day the host could not read breaks the line — an unknown is never drawn as zero. -import { createElement as h } from 'react' -import { t } from './i18n.js' - -export const CHART_WIDTH = 168 -export const CHART_HEIGHT = 28 -const PAD = 3 -const DOT_RADIUS = 2 -// The dot that is not a measurement yet (today, still running) is drawn a touch larger so the -// hollow ring stays visible at this size. -const HOLLOW_RADIUS = 2.5 -// The most days a chart draws; the host sends 7 today and a longer series is cut to its end. -export const CHART_DAYS = 7 - -// Keys line up with the rows `metricRows()` builds, in that order: the first five are countable and -// carry a line, the two medians after them cannot be added up and get no chart at all (#1212). -// The five countable metrics draw a line; the CI share draws one too but is a ratio, so it never -// takes a Σ total. Ties to the rows `metricRows()` builds, by key. -export const CHART_KEYS = ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci'] -export const COUNTING_KEYS = ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests'] - -// The days to draw, or null when the snapshot carries no series at all (host half not landed yet, -// or an older host) — that is the empty state, not seven zeros. -export function chartDays(daily) { - if (!Array.isArray(daily)) return null - const days = daily.filter(day => day && typeof day === 'object') - return days.length ? days.slice(-CHART_DAYS) : null -} - -// True while today is still running: the last day says `partial: true`. -export function todayPartial(daily) { - const days = chartDays(daily) - return Boolean(days && days.length && days[days.length - 1].partial === true) -} - -// One day's number for one metric; null means the host did not get it, which is not a zero. -export function dayValue(day, key) { - if (!day || typeof day !== 'object') return null - if (key === 'ci') { - const failed = day.ci?.failed - const counted = day.ci?.counted - const total = day.ci?.total - // A superseded run is not in the denominator when the host counted them (#1212 contract). - 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)) - } - const value = day[key] - return Number.isFinite(value) ? value : null -} - -// The calendar date the day stands for, in the zone the host cuts days by (Asia/Shanghai, UTC+8, -// no DST). A start the host did not send leaves the date out of the title, never invents one. -export function dayLabel(day) { - // The contract is a plain date: 'YYYY-MM-DD'; an older answer carried an ISO start, read the - // same way so a host mid-upgrade still labels its days. - if (typeof day?.date === 'string' && /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(day.date)) return day.date - const at = Date.parse(day?.start ?? '') - if (!Number.isFinite(at)) return '' - return new Date(at + 8 * 3_600_000).toISOString().slice(0, 10) -} - -// `2026-10-26 · 12`, and `2026-10-26 · 未取到` for a day the host could not read. -export function pointTitle(day, key) { - const date = dayLabel(day) - const value = dayValue(day, key) - const shown = value === null - ? t('not available') - : (key === 'ci' ? `${Math.round(value * 100)}%` : String(value)) - return date ? `${date} · ${shown}` : shown -} - -// How many of the drawn days carry a number for this metric; fewer than the window means the total -// is incomplete and wears the star (#1212). -export function knownDays(daily, key) { - const days = chartDays(daily) ?? [] - return days.filter(day => dayValue(day, key) !== null).length -} - -const round = value => Math.round(value * 100) / 100 - -// The SVG geometry for one metric: one x per day, a y for each known day, and the path split at -// every unknown day. Pure, so the shape can be asserted without React (#1212). -export function chartGeometry(daily, key, { width = CHART_WIDTH, height = CHART_HEIGHT } = {}) { - const days = chartDays(daily) - if (!days) return null - const values = days.map(day => dayValue(day, key)) - const known = values.filter(value => value !== null) - const top = known.length ? Math.max(...known, 0) : 0 - // All-zero days still sit on the floor; a flat line on the baseline is the honest picture, and a - // filled dot there is how a real zero stays apart from a missing day. - const span = top > 0 ? top : 1 - const step = days.length > 1 ? (width - PAD * 2) / (days.length - 1) : 0 - const dots = days.map((day, i) => { - const value = values[i] - const last = i === days.length - 1 - const state = value === null ? 'missing' : (last && day.partial === true ? 'partial' : 'full') - return { - index: i, - x: round(PAD + step * i), - y: value === null ? null : round(PAD + (height - PAD * 2) * (1 - value / span)), - today: last, - // Only the day that is not over yet is drawn hollow; a finished day is a filled dot. - hollow: state === 'partial', - partial: state === 'partial', - state, - title: pointTitle(day, key), - value, - } - }) - const segments = [] - let run = null - for (const dot of dots) { - if (dot.y === null) { - if (run) { segments.push(run); run = null } - continue - } - if (!run) run = { dots: [] } - run.dots.push(dot) - } - if (run) segments.push(run) - return { - width, - height, - dots, - known: known.length, - segments: segments.map(segment => ({ - d: segment.dots.map((dot, i) => `${i ? 'L' : 'M'}${dot.x} ${dot.y}`).join(' '), - // Only the stretch that ends today is the day that is not over yet. - dashed: segment.dots.some(dot => dot.partial), - today: segment.dots.some(dot => dot.today), - })), - } -} - -// One metric's line: 168×28, 1.5px stroke, r=2 dots, a `title` on every point. Each dot says its -// own state (`full` / `partial`); a missing day has no dot at all, so a break can never be read as -// a zero, and a real zero is a filled dot on the baseline (#1212). -export function DeliveryChart({ daily, metricKey, label }) { - const geo = chartGeometry(daily, metricKey) - if (!geo || !geo.known) return null - const name = label ? t('{label} over the last 7 days', { label }) : t('Last 7 days') - return h('svg', { - className: 'bt-del-chart', - 'data-flybot': 'delivery-chart', - 'data-metric': metricKey, - width: geo.width, - height: geo.height, - viewBox: `0 0 ${geo.width} ${geo.height}`, - role: 'img', - 'aria-label': name, - }, - geo.segments.map((segment, i) => h('path', { - key: `s${i}`, - className: 'bt-del-chart-line', - 'data-today': segment.today ? 'true' : undefined, - ...(segment.dashed ? { 'stroke-dasharray': '2 2' } : {}), - d: segment.d, - })), - geo.dots.filter(dot => dot.y !== null).map(dot => h('circle', { - key: `d${dot.index}`, - className: 'bt-del-chart-dot', - 'data-state': dot.state, - 'data-hollow': dot.hollow ? 'true' : undefined, - cx: dot.x, - cy: dot.y, - r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS, - }, h('title', null, dot.title)))) -} - -// The metrics block #1212 draws: `label | line | today | 7 days`, one row per metric, the five -// countable lines and the CI share stacked. Rows keep the numbers `metricRows()` already formatted; a total is -// prefixed with Σ and muted so only one number per row is the day's, and it wears a star when the -// window has days the host could not read. The two median rows get no line, no placeholder and no -// total — a median cannot be added up. Below 900px the line moves under its row (DELIVERY_CHART_CSS). -export function DeliveryMetrics({ rows = [], daily }) { - const days = chartDays(daily) - const short = CHART_KEYS - .map(key => knownDays(days, key)) - .filter(count => count < (days?.length ?? 0)) - const fewest = short.length ? Math.min(...short) : 0 - const head = h('div', { className: 'bt-del-dm-head', 'data-flybot': 'delivery-metrics-head' }, - h('span', { className: 'bt-del-dm-label' }, ''), - h('span', { className: 'bt-del-dm-chart-head' }, ''), - h('span', { className: 'bt-del-dm-value' }, t('Today')), - h('span', { className: 'bt-del-dm-value bt-del-dm-week' }, t('7 days'))) - const body = rows.map((row, index) => { - // The row names the series it belongs to, so reordering the rows cannot hand one metric - // another metric's line; a row outside CHART_KEYS (a median) simply gets none. - const key = CHART_KEYS.includes(row?.key) ? row.key : null - const chart = days && key - ? h(DeliveryChart, { key: `c-${key}`, daily: days, metricKey: key, label: row?.label }) - : null - // A ratio is not a sum: the CI share keeps its week number plain, and the star marks an - // incomplete count only. - const counted = key !== null && COUNTING_KEYS.includes(key) - const star = days && counted && knownDays(days, key) < days.length ? '*' : '' - const cells = [ - h('span', { key: 'label', className: 'bt-del-dm-label' }, row?.label ?? ''), - // A median row gets no chart cell at all: no line, no placeholder box, no total. - ...(key ? [h('span', { key: 'chart', className: 'bt-del-dm-chart' }, chart)] : []), - h('span', { key: 'today', className: 'bt-del-dm-value bt-del-dm-today' }, row?.today ?? '—'), - h('span', { key: 'week', className: 'bt-del-dm-value bt-del-dm-week' }, counted ? `Σ ${row?.week ?? '—'}${star}` : (row?.week ?? '—')), - ] - return h('div', { key: row?.label ?? String(index), className: 'bt-del-dm-row', 'data-metric': key ?? undefined }, cells) - }) - return h('div', { className: 'bt-del-dm', 'data-flybot': 'delivery-metrics' }, - head, - body, - days ? null : h('p', { className: 'bt-del-chart-empty', 'data-flybot': 'delivery-chart-empty' }, t('No 7-day data yet.')), - days && short.length - ? h('p', { className: 'bt-del-chart-foot', 'data-flybot': 'delivery-chart-foot' }, t('* {count} of {days} days had data', { count: fewest, days: days.length })) - : null) -} - -export const DELIVERY_CHART_CSS = ` -.bt-del-dm{display:flex;flex-direction:column;gap:2px;min-width:0} -.bt-del-dm-head,.bt-del-dm-row{display:flex;align-items:center;gap:8px} -.bt-del-dm-head{font-size:12px;line-height:16px;font-weight:600;color:var(--bt-ink-3)} -.bt-del-dm-label{flex:1 1 auto;min-width:0;font-size:12px;line-height:16px;color:var(--bt-ink-2)} -.bt-del-dm-head .bt-del-dm-label{color:inherit} -.bt-del-dm-chart,.bt-del-dm-chart-head{flex:0 0 168px;display:flex;align-items:center;min-height:28px} -.bt-del-dm-value{flex:0 0 auto;min-width:56px;text-align:right;font-size:12px;line-height:16px;font-variant-numeric:tabular-nums;color:var(--bt-ink)} -.bt-del-dm-head .bt-del-dm-value{color:inherit;font-weight:inherit} -.bt-del-dm-today{color:var(--bt-ink);font-weight:600} -.bt-del-dm-week{color:var(--bt-ink-3);font-weight:400} -.bt-del-chart{display:block;overflow:visible} -.bt-del-chart-line{fill:none;stroke:var(--bt-accent);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} -.bt-del-chart-dot{fill:var(--bt-accent);stroke:none} -.bt-del-chart-dot[data-state=partial]{fill:none;stroke:var(--bt-accent);stroke-width:1.5} -.bt-del-chart-empty,.bt-del-chart-foot{margin:2px 0 0;font-size:12px;line-height:16px;color:var(--bt-ink-3)} -@media (max-width:899.98px){ - .bt-del-dm-head{display:none} - .bt-del-dm-row{flex-wrap:wrap;gap:4px 8px;min-height:44px;align-content:center} - .bt-del-dm-chart{order:5;flex:0 0 100%;min-height:0} - .bt-del-dm-value{min-width:0} -} -` diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js deleted file mode 100644 index 5bc6c761a..000000000 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ /dev/null @@ -1,721 +0,0 @@ -// Delivery board (#1173): the repository's GitHub issues and pull requests by delivery stage, -// in the same dialog as the per-Bot Tasks board. This is a view only — nothing here writes to -// GitHub, and the snapshot it renders is built host-side (src/host/delivery-board.js). -// -// #1211 makes the failure visible: four states instead of "no snapshot" for everything — -// `loading` counts the seconds, `failed` names the endpoint, the status and what to do about it, -// `stale` keeps the old board and hangs the reason beside it, `partial` says which part was not -// read. The poll is fast while a round is running (2 s) and slow once there is something to read. -// -// #1326: One board that shows both task cards and delivery cards together, columns from workflow -// definitions, filters for stages with column:false (review, done-today). -import { createElement as h, useEffect, useState } from 'react' -import { t } from './i18n.js' -import { columnOf } from '../shared/task-board.js' -import { DeliveryMetrics, DELIVERY_CHART_CSS, todayPartial } from './delivery-chart.js' -import { githubCard } from '../shared/board-card.js' -import { BoardCard, DELIVERY_SKIN } from './board-card.js' -import { BoardCard as TaskBoardCard, canMoveTask } from './kanban.js' - -const POLL_MS = 60_000 -// While the host is still building there is nothing to see, so ask often; once the board is up, -// asking often only burns GitHub reads (#1211). -const LOADING_POLL_MS = 2_000 - -// A failed or stale read carries the host's `retryAt` (#1211): the next read is scheduled for it -// instead of a fixed minute, so the host's 10 s → 30 s → 2 min backoff actually happens. The floor -// keeps a clock skew from spinning, and the ceiling keeps `setTimeout`'s 32-bit limit from turning a -// long wait into an immediate 0 ms re-fire. -const RETRY_FLOOR_MS = 1_000 -const TIMER_MAX_MS = 2 ** 31 - 1 -export function pollDelay(state, payload = null, now = Date.now()) { - if (state === 'loading' || state === 'idle') return LOADING_POLL_MS - const at = Number(payload?.retryAt) - if ((state === 'failed' || state === 'stale') && Number.isFinite(at) && at > 0) { - return Math.min(Math.max(at - now, RETRY_FLOOR_MS), TIMER_MAX_MS) - } - return POLL_MS -} - -// #1326: Columns from workflow definitions. The three visible stages of repo-dev. -// Stages with column:false (review, done-today) are filters, not columns. -export const DELIVERY_COLUMNS = [ - { id: 'not-started', label: 'Not started' }, - { id: 'in-progress', label: 'In progress' }, - { id: 'done', label: 'Done' }, -] - -// The columns to draw for one payload, in one place (#1265 step 4). The snapshot names which -// columns exist (its own `columns` map) and DELIVERY_COLUMNS gives their order and labels; an id -// this build does not label still renders, under its own id. When a workflow definition becomes the -// source (#1272) it replaces this function's data, not the renderer. -export function stagesOf(view) { - const columns = view?.columns - if (columns === null || typeof columns !== 'object') return DELIVERY_COLUMNS - const present = Object.keys(columns) - if (present.length === 0) return DELIVERY_COLUMNS - const known = DELIVERY_COLUMNS.filter(column => present.includes(column.id)) - const unlabelled = present.filter(id => !DELIVERY_COLUMNS.some(column => column.id === id)).map(id => ({ id, label: id })) - return [...known, ...unlabelled] -} - -const BADGES = { - inc: 'INC · rework', - 'pass-enqueue': 'PASS · waiting for enqueue', - 'pass-merge': 'PASS · waiting for merge', - 'ci-red': 'CI red', - 'ci-running': 'CI running', - 'no-verdict': 'No verdict', - // The checks could not be read: that is not "no verdict", and saying so blamed the pull request - // for a permission the instance is missing (#1211). - 'checks-unknown': 'Checks unknown', -} - -export const badgeLabel = (badge) => (badge ? t(BADGES[badge] ?? badge) : '') - -// #1326: Task cards in workflow columns. Task status maps to column: open/claimed → not-started, -// working/blocked/result-ready → in-progress, accepted/cancelled/failed → done. -function taskColumnOf(task) { - const col = columnOf(task) - if (col === 'not-started') return 'not-started' - if (col === 'in-progress') return 'in-progress' - return 'done' -} - -// A column takes a card dropped on it, and the board's own `onMove` decides whether that move is -// allowed — the drop is a request, never a write from the client (#1326). -const dropProps = (column, { dragging, onMove, onDrag }) => ({ - onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, - onDrop: (event) => { - event.preventDefault() - const id = event.dataTransfer?.getData('text/plain') ?? '' - if (id) onMove?.(id, column) - onDrag?.(null) - }, -}) - -// Simplified task column renderer for degraded states (no GitHub snapshot). -function TaskColumns({ tasks, roster = null, dragging = null, onMove = null, onDrag = null }) { - const columns = { 'not-started': [], 'in-progress': [], 'done': [] } - for (const task of tasks ?? []) { - const col = taskColumnOf(task) - if (columns[col]) columns[col].push(task) - } - const columnTitles = { - 'not-started': t('Not started'), - 'in-progress': t('In progress'), - 'done': t('Done'), - } - return h('div', { className: 'bt-board-columns' }, - ['not-started', 'in-progress', 'done'].map(column => h('section', { - key: column, - className: 'bt-board-column', - 'data-column': column, - 'aria-label': columnTitles[column], - ...dropProps(column, { dragging, onMove, onDrag }), - }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, columnTitles[column]), - h('span', { className: 'bt-board-count' }, String(columns[column].length))), - columns[column].length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) - : columns[column].map(task => h(TaskBoardCard, { - key: task.id, - task, - roster, - movable: canMoveTask(roster, task), - onMove, - onDrag - }))))) -} - -export function duration(ms) { - if (!Number.isFinite(ms) || ms < 0) return '—' - const hours = ms / 3_600_000 - if (hours < 1) return `${Math.max(1, Math.round(ms / 60_000))}m` - if (hours < 48) return `${Math.round(hours)}h` - return `${Math.round(hours / 24)}d` -} - -const secondsOf = (ms) => String(Math.max(0, Math.round((Number(ms) || 0) / 1000))) - -// The three states have three sentences, and none of them is "no snapshot yet" (#1211). -// Seconds until the host retries on its own (#1211). The number is the host's `retryAt`, so the line -// states a fact instead of promising a cadence the page would not keep. -export function retryCountdown(retryAt, now = Date.now()) { - const at = Number(retryAt) - if (!Number.isFinite(at) || at <= 0) return null - return Math.max(0, Math.round((at - now) / 1000)) -} - -export const loadingText = (elapsedMs) => t('First load in progress, {seconds} s elapsed', { seconds: secondsOf(elapsedMs) }) - -export const staleText = (asOf, error) => t('Showing the snapshot from {time} · refresh failed: {message}', { - time: clockOf(asOf), message: String(error?.message ?? error ?? ''), -}) - -// What a person can do about the failure, by kind and by the part of GitHub that refused (#1211). -// A wait is printed only when GitHub itself gave one. -export function hintFor(error, { repo = null } = {}) { - if (!error) return '' - const scope = error.scope ?? null - const kind = error.kind ?? null - const target = repo ?? t('this repository') - if (kind === 'no-repo') return t('Set delivery.repo in this instance\'s settings to the repository this instance works in.') - if (kind === 'no-token') return t('Add a GITHUB_TOKEN in this instance\'s settings — one that can read {repo}.', { repo: target }) - if (kind === 'rate-limit') { - return error.retryAfterMs === null || error.retryAfterMs === undefined - // No number from GitHub means no number here: the backoff is a rule, not a measurement. The - // retrying is the open page's job — the host installs no timer (#1173) (#1227). - ? t('GitHub is rate-limiting this instance; while this page is open it retries on its own (10 s → 30 s → 2 min).') - : t('GitHub is rate-limiting this instance; while this page is open it retries on its own in {seconds} s.', { seconds: secondsOf(error.retryAfterMs) }) - } - if (kind === 'timeout') return t('GitHub did not answer in time; the next attempt happens while this page is open.') - if (scope === 'actions' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Actions: Read.') - if (scope === 'checks' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Checks: Read.') - if ((scope === 'comments' || scope === 'issues') && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Issues: Read.') - if (scope === 'pulls' && (kind === 'not-found' || kind === 'forbidden')) return t('Give the token Pull requests: Read.') - if (scope === 'repo' || kind === 'not-found' || kind === 'forbidden' || kind === 'auth') { - return t('Check the repository name, and that the token can see {repo}.', { repo: target }) - } - if (kind === 'server' || kind === 'network') return t('GitHub is not answering this instance right now; retry in a moment.') - return t('The next attempt happens while this page is open; the reason above names what failed.') -} - -// A part that could not be read is named where it is missing, not hidden (#1211). With more than -// one repository the failing read is one repository's, so the line says which (#1265). -export function missingText(entry) { - const status = entry?.status === null || entry?.status === undefined ? '' : ` · ${entry.status}` - const part = entry?.part === 'ci' ? t('CI data') - : entry?.part === 'budget' ? t('The rest of the board') - : entry?.part ?? t('Part of the board') - const where = entry?.repo ? `${entry.repo} · ` : '' - return t('{part} was not fetched this time: {message}', { part: `${where}${part}`, message: `${String(entry?.message ?? '')}${status}` }) -} - -const clockOf = (iso) => { - const at = Date.parse(iso ?? '') - if (!Number.isFinite(at)) return '—' - return new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) -} - -const timeOf = (iso) => { - const at = Date.parse(iso ?? '') - if (!Number.isFinite(at)) return '—' - return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) -} - -// The header rows the dialog renders; kept pure so the numbers can be checked without React. -export function metricRows(snapshot) { - const today = snapshot?.today ?? {} - const week = snapshot?.week ?? {} - const ci = today.ci ?? null - // The CI share is a ratio of the runs that counted, so it is written as a percentage and is - // never summed. A day the host could only count by `total` still has a denominator — the one the - // 7-day line beside this row draws — so the two halves read the same day the same way (#1244); - // only a window with no runs at all reads '—', never 0%. - const share = (entry) => { - if (!entry || !Number.isFinite(entry.failed)) return '—' - const denominator = Number.isFinite(entry.counted) && entry.counted > 0 - ? entry.counted - : (Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null) - return denominator === null ? '—' : `${Math.round((entry.failed / denominator) * 100)}%` - } - const count = (value) => (value === null || value === undefined ? '—' : String(value)) - const rows = [ - { label: t('New issues'), key: 'newIssues' }, - { label: t('Closed issues'), key: 'closedIssues' }, - { label: t('New pull requests'), key: 'newPullRequests' }, - { label: t('Merged pull requests'), key: 'mergedPullRequests' }, - { key: 'ci', label: t('CI red share'), today: share(today.ci), week: share(week.ci) }, - { label: t('Median issue close'), key: 'medianIssueCloseMs', format: duration }, - { label: t('Median PR merge'), key: 'medianPullMergeMs', format: duration }, - ].map(row => { - // The CI row already carries its own formatted share; every other row reads its number from - // today/week by key. - if (row.today !== undefined) return { key: row.key, label: row.label, today: row.today, week: row.week } - const format = row.format ?? count - // `key` travels with the row so the chart finds its own series by name, not by position. - return { key: row.key, label: row.label, today: format(today[row.key]), week: format(week[row.key]) } - }) - return { - dayStart: snapshot?.dayStart ?? null, - rows, - // `null` is "not read this time", which the header prints as unknown; a real 0 stays a 0. - ci: ci === null ? null : { - failed: ci.failed ?? 0, - total: ci.total ?? 0, - counted: ci.counted ?? 0, - caveat: Number.isFinite(ci.total) && Number.isFinite(ci.counted) && ci.counted < ci.total, - superseded: ci.superseded ?? 0, - unknownDays: ci.unknownDays ?? [], - }, - open: snapshot?.open ?? {}, - } -} - -// The poll loop, kept out of React so a fake clock can prove the cadence (#1211). A retry paints -// `loading` before the request leaves, so the button is not silent, and the schedule after a failure -// is the host's own `retryAt` rather than a minute of nothing. -export function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = id => clearTimeout(id) }) { - let stopped = false - let timer = null - let state = 'idle' - let last = null - const schedule = () => { - if (stopped) return - if (timer !== null) clearTimer(timer) - timer = setTimer(() => { timer = null; void run() }, pollDelay(state, last, now())) - } - const run = async (force = false) => { - try { - const next = await load(force) - if (stopped) return - if (next && typeof next === 'object') { - last = next - state = typeof next.state === 'string' ? next.state : 'fresh' - onView(next) - } else { - state = 'failed' - last = null - onView({ state: 'failed', error: { message: 'The host did not answer', kind: 'network', endpoint: null, status: null, at: null } }) - } - schedule() - } catch (error) { - if (stopped) return - state = 'failed' - last = null - onView({ state: 'failed', error: { message: String(error?.message ?? error), kind: 'network', endpoint: null, status: null, at: null } }) - schedule() - } - } - return { - start: () => { void run() }, - // Retry: the screen changes now, then the force read runs; the old view is kept so its columns - // do not flash away while the round starts. - retry: (from = null) => { - if (stopped) return - state = 'loading' - last = null - onView({ ...(from && typeof from === 'object' ? from : {}), state: 'loading', since: new Date(now()).toISOString(), elapsedMs: 0, refreshing: false }) - if (timer !== null) { clearTimer(timer); timer = null } - void run(true) - }, - stop: () => { stopped = true; if (timer !== null) clearTimer(timer); timer = null }, - } -} - -// Where the repository name came from (#1250). A bare source word is deliberate: the page says what -// the host read, not a sentence that hides the difference between a setting and a default. -export function repoSourceText(source) { - if (source === 'config') return t('from config') - if (source === 'remote') return t('from remote') - if (source === 'package') return t('from the package (default)') - return null -} - -export function useDeliveryBoard(actions) { - const [view, setView] = useState({ state: 'idle' }) - const [retry, setRetry] = useState(() => () => {}) - useEffect(() => { - let current = { state: 'idle' } - const poller = createPoller({ - load: force => (typeof actions?.deliveryBoard === 'function' - ? actions.deliveryBoard(force ? { force: true } : {}) - : Promise.resolve(null)), - onView: next => { current = next; setView(next) }, - }) - setRetry(() => () => poller.retry(current)) - poller.start() - return () => poller.stop() - }, [actions]) - return [view, retry] -} - -// The diagnostic report as text, so the 诊断 button can show and copy the same five lines the -// `--probe` CLI prints (#1211). -export function probeText(report) { - if (!report) return '' - const repos = Array.isArray(report.repos) ? report.repos : [] - const head = report.repo ?? `${repos.length} repositories` - const lines = [`${head}${report.repoSource ? ` · ${report.repoSource}` : ''} · ${timeOf(report.at)}`] - for (const row of report.rows ?? []) { - const status = row.status === null || row.status === undefined ? '—' : String(row.status) - const where = report.repo === null && row.repo ? `${row.repo} ` : '' - lines.push(`${where}${row.ok ? 'ok' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind ?? ''}`}`) - } - if (report.error) lines.push(String(report.error)) - return lines.join('\n') -} - -function Diagnose({ onProbe }) { - const [report, setReport] = useState(null) - const [busy, setBusy] = useState(false) - const run = () => { - if (busy) return - setBusy(true) - Promise.resolve(onProbe?.()) - .then(next => { setReport(next ?? { rows: [], error: 'no answer' }) }) - .catch(error => setReport({ rows: [], error: String(error?.message ?? error) })) - .finally(() => setBusy(false)) - } - return h('span', { className: 'bt-del-diag' }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: run, 'aria-label': t('Diagnose') }, busy ? t('Checking…') : t('Diagnose')), - report - ? h('div', { className: 'bt-del-diag-box' }, - h('pre', { className: 'bt-del-diag-text', 'data-probe': 'report' }, probeText(report)), - h('button', { - type: 'button', className: 'bt-soft bt-soft-sm', - onClick: () => { try { globalThis.navigator?.clipboard?.writeText?.(probeText(report)) } catch { /* copy is a convenience */ } }, - }, t('Copy')), - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => setReport(null) }, t('Close'))) - : null) -} - -export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = null }) { - const payload = view ?? snapshot - if (!payload) return null - const metricsPayload = payload.metrics ?? payload - const { rows, ci, open } = metricRows(metricsPayload) - const error = payload.error ?? null - const state = payload.state ?? 'fresh' - const repoName = payload.repo ?? repo ?? null - const repoSource = payload.repoSource ?? null - const sourceText = repoSourceText(repoSource) - // More than one repository has no single name to print, so the line carries the count instead - // (#1265 step 4); each repository block names itself below. - const repoCount = Array.isArray(payload.repos) ? payload.repos.length : 0 - const shared = repoCount > 1 - const openCount = value => (value === null || value === undefined ? '—' : String(value)) - // One time, one source (#1244): while today is still running the hint follows the time, so the - // paragraph prints the zone-clocked time once instead of repeating a local-clock rendering of the - // same instant — the two can disagree, not merely repeat. - const partial = state !== 'loading' && todayPartial(metricsPayload.daily) - return h('header', { className: 'bt-del-head' }, - // Whose numbers these are, and where the name came from (#1250): a package default that looks - // like a fact is what showed one instance another repository's board. - h('p', { className: 'bt-del-repo', 'data-repo': shared ? undefined : repoName ?? undefined, 'data-repo-source': shared ? undefined : repoSource ?? undefined, 'data-repos': shared ? String(repoCount) : undefined }, - shared ? t('{count} repositories', { count: repoCount }) : repoName === null ? t('no repository — set delivery.repo') : repoName, - shared || sourceText === null ? '' : ` · ${sourceText}`), - h('div', { className: 'bt-del-metrics' }, - h(DeliveryMetrics, { rows, daily: metricsPayload.daily }), - // An unreadable CI number is "unknown", never "0 of 0" (#1211). - h('p', { className: 'bt-del-ci', 'data-ci': ci === null ? 'unknown' : 'read' }, - ci === null ? t('CI not available this time') : t('CI failed {failed} of {total}', { failed: ci.failed, total: ci.total }), - ci !== null && ci.caveat ? ` · ${t('counted {counted} of {total}', { counted: ci.counted, total: ci.total })}` : '', - ci !== null && ci.superseded ? ` · ${t('{count} superseded', { count: ci.superseded })}` : '', - ci !== null && ci.unknownDays?.length ? ` · ${t('{count} day(s) not read', { count: ci.unknownDays.length })}` : ''), - h('p', { className: 'bt-del-open' }, t('Open: {issues} issues ({needs} need a human), {prs} pull requests', { - issues: openCount(open.issues), needs: openCount(open.needsHuman), prs: openCount(open.pullRequests), - }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined - ? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')), - h('p', { className: 'bt-del-asof' }, - state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), - partial ? ` · ${t('today is partial')}` : '', - payload.refreshing ? ` · ${t('refreshing…')}` : '', - error ? ` · ${hintFor(error, { repo })}` : '', - onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null, - onProbe ? h(Diagnose, { onProbe }) : null)) -} - -// #1326: the stages a definition draws no column for — a review, today's finished cards — are -// filters, not lanes. A filter narrows which cards a column shows; it never reorders the columns, and -// one board keeps one set of them. -const BOARD_FILTERS = ['all', 'review', 'done-today'] -function BoardFilters({ filter, onFilter }) { - return h('div', { className: 'bt-del-filters', role: 'group', 'aria-label': t('Filter') }, - BOARD_FILTERS.map(id => h('button', { - key: id, - type: 'button', - className: 'bt-soft bt-soft-sm', - 'data-filter': id, - 'aria-pressed': filter === id ? 'true' : 'false', - onClick: () => onFilter(id), - }, id === 'all' ? t('All') : id === 'review' ? t('Review') : t('Done today')))) -} - -const stageOf = (card, column) => card?.stage ?? column -const matchesFilter = (card, column, filter) => filter === 'all' || stageOf(card, column) === filter - -// One repository's columns (#1265 step 4, #1326). Task cards and delivery cards together. -function DeliveryColumns({ view, repo = null, placed = null, tasks = [], filter = 'all', roster = null, dragging = null, onMove = null, onDrag = 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 deliveryCards = (id) => (columns[id] ?? []) - .filter((card) => drawn === null || !drawn.has(`${repo ?? card.repo ?? ''}#${card.kind}${card.number}`)) - .filter((card) => matchesFilter(card, id, filter)) - - // #1326: Task cards map into the same columns. Map task column to delivery column. - const tasksByColumn = { 'not-started': [], 'in-progress': [], 'done': [] } - // A task card names no review or done-today stage, so a stage filter narrows it away rather than - // moving it to a lane it never named (#1326). - for (const task of filter === 'all' ? tasks ?? [] : []) { - const col = taskColumnOf(task) - if (tasksByColumn[col]) tasksByColumn[col].push(task) - } - - const allCards = (id) => { - const delivery = deliveryCards(id) - const taskList = tasksByColumn[id] ?? [] - return [...taskList, ...delivery] - } - - // The two kinds share these columns, so which one a card is has to come from where it was read, - // never from guessing at its fields. - const taskIds = new Set((tasks ?? []).map(task => task?.id)) - const isTask = (item) => taskIds.has(item?.id) - - return h('div', { className: 'bt-del-columns' }, - stagesOf(view).map(column => h('section', { - key: column.id, className: 'bt-del-column', 'data-column': column.id, - ...dropProps(column.id, { dragging, onMove, onDrag }), - }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String(allCards(column.id).length))), - allCards(column.id).length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : allCards(column.id).map(item => - isTask(item) - ? h(TaskBoardCard, { key: item.id, task: item, roster, movable: canMoveTask(roster, item), onMove, onDrag }) - : h(DeliveryCard, { key: `${repo ?? item.repo ?? ''}-${item.kind}-${item.number}`, card: item, repo: repo ?? item.repo ?? null }))))) -} - -// 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, filter = 'all' }) { - 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 => { - const cards = column.cards.filter(card => matchesFilter(card, column.id, filter)) - return 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(cards.length))), - cards.length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : 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))) -} - -// #1326: Multi-repo unified board — merge successful repos into one shared column set with tasks -// appearing once, and show failed repos as error blocks. -function multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag }) { - const successful = (entries ?? []).filter(entry => entry?.columns !== undefined) - const failed = (entries ?? []).filter(entry => entry?.columns === undefined) - - // Merge all successful repo columns into one view, keeping only the workflow columns. - // Stamp entry.repo onto each card so data-key stays unique across repos (#1326). - const merged = { columns: { 'not-started': [], 'in-progress': [], 'done': [] } } - for (const entry of successful) { - for (const [column, cards] of Object.entries(entry.columns)) { - // Only merge columns that exist in the workflow column set - if (merged.columns[column]) { - merged.columns[column].push(...cards.map(card => ({ ...card, repo: card.repo ?? entry.repo }))) - } - } - } - - return h('div', { className: 'bt-del-multi' }, - // Successful repos: one shared column set with all cards and tasks once - successful.length > 0 - ? h(DeliveryColumns, { view: merged, repo: null, placed: null, tasks, filter, roster, dragging, onMove, onDrag }) - : null, - // Failed repos: show error blocks - failed.map(entry => h(DeliveryRepoBlock, { - key: entry.repo ?? 'unknown', - entry, - tasks: [], // Don't pass tasks to failed repo blocks - roster, - dragging, - onMove, - onDrag, - filter - }))) -} - -// A repository that cannot be read keeps its block and says so; the readable ones are drawn beside -// it, which is why one failure no longer empties the board (#1265, #1211). -function DeliveryRepoBlock({ entry, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, filter = 'all' }) { - const state = entry?.state ?? 'fresh' - const source = repoSourceText(entry?.repoSource) - return h('section', { className: 'bt-del-repo-block', 'data-repo': entry?.repo ?? undefined, 'data-state': state }, - h('h3', { className: 'bt-del-repo-head' }, entry?.repo ?? t('no repository — set delivery.repo'), - source === null ? '' : ` · ${source}`), - entry?.columns === undefined - ? 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, placed: entry.placed, tasks, filter, roster, dragging, onMove, onDrag })) -} - -export function DeliveryBoard({ view, snapshot, message = '', repo = null, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null, onRetry = null, onProbe = null }) { - const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) - const state = payload.state ?? 'fresh' - // The countdown promises a number that moves, so the screen redraws once a second while a retry - // is actually scheduled — and only then: a fresh or loading board must not tick (#1227). The - // timer lives in the browser, not in the host, which installs none (#1173). - const retryAt = Number(payload.retryAt) - const ticking = (state === 'failed' || state === 'stale') && Number.isFinite(retryAt) && retryAt > 0 - const [now, setNow] = useState(() => Date.now()) - useEffect(() => { - if (!ticking) return undefined - setNow(Date.now()) - const timer = setInterval(() => setNow(Date.now()), 1_000) - return () => clearInterval(timer) - }, [ticking, retryAt]) - const countdown = retryCountdown(payload.retryAt, now) - // The filter is view state: it narrows which cards the columns show and never reorders them (#1326). - const [filter, setFilter] = useState('all') - // More than one repository is one block per repository; exactly one is today's single board. - const entries = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null - // "No snapshot yet." belongs to a host that has never tried; every attempt has a better word. - // #1326: Task cards still render when delivery fails. - const hasTasks = Array.isArray(tasks) && tasks.length > 0 - if (state === 'idle') { - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - message - ? h('p', { className: 'bt-board-error', role: 'alert' }, message) - : h('p', { className: 'bt-board-empty' }, t('No snapshot yet.'))) - } - if (state === 'loading') { - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - h('p', { className: 'bt-del-loading', role: 'status' }, loadingText(payload.elapsedMs)), - h('p', { className: 'bt-del-hint' }, t('The first round reads the repository once; the columns appear as soon as it lands.'))) - } - // A board exists when the payload carries its columns, or when at least one repository does; with - // several repositories one failure is `partial`, not an empty page (#1265). - const hasBoard = Boolean(payload.columns) || (entries !== null && entries.some(entry => entry.columns !== undefined)) - if (state === 'failed' || !hasBoard) { - const error = payload.error ?? null - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - hasTasks ? h(TaskColumns, { tasks, roster, dragging, onMove, onDrag }) : null, - h('div', { className: 'bt-board-error', role: 'alert' }, - h('p', null, String(error?.message ?? message ?? t('The delivery board is not ready yet.'))), - error ? h('p', { className: 'bt-del-hint' }, hintFor(error, { repo })) : null, - h('p', { className: 'bt-del-hint', 'data-retry-at': payload.retryAt ?? undefined }, - countdown === null - ? t('While this page is open it retries on its own; press Retry to try again now.') - : t('Retrying in {seconds} s · press Retry to try again now', { seconds: countdown })), - onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null)) - } - const missing = payload.missing ?? [] - return h('div', { className: 'bt-del-body', 'data-view': 'board', 'data-state': state }, - h(BoardFilters, { filter, onFilter: setFilter }), - message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, - state === 'stale' && payload.error - ? h('p', { className: 'bt-del-stale', role: 'status' }, staleText(payload.asOf, payload.error), - ` · ${hintFor(payload.error, { repo })}`, - countdown === null ? '' : ` · ${t('Retrying in {seconds} s · press Retry to try again now', { seconds: countdown })}`) - : null, - state !== 'stale' && missing.length > 0 - ? 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, filter }) - : null, - entries === null - ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null, placed: payload.placed, tasks, filter, roster, dragging, onMove, onDrag }) - : multiRepoView(entries, { tasks, filter, roster, dragging, onMove, onDrag })) -} - -export function DeliveryView({ actions, tasks = [], roster = null, dragging = null, onMove = null, onDrag = null }) { - const [view, retry] = useDeliveryBoard(actions) - return h(DeliveryBoard, { - view, - repo: view?.repo ?? null, - tasks, - roster, - dragging, - onMove, - onDrag, - onRetry: actions?.deliveryBoard ? retry : null, - onProbe: actions?.deliveryProbe ? () => actions.deliveryProbe() : null, - }) -} - -// A repository row as a card on the one board (#1265 step 1): the same component the task cards -// use, keyed `owner/name#number` so an issue and the pull request that closes it are one identity. -export function DeliveryCard({ card, repo = null }) { - const model = githubCard(card, repo) ?? { source: 'github', key: null, title: card?.title ?? '', badge: card?.badge ?? null } - return h(BoardCard, { - card: model, - skin: DELIVERY_SKIN, - attrs: { 'data-kind': card?.kind, 'data-number': card?.number, 'data-badge': card?.badge ?? undefined, 'data-stage': card?.stage ?? undefined }, - idText: `#${card?.number ?? '?'}`, - badge: badgeLabel(card?.badge), - }) -} - -export const 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-filters{display:flex;flex-wrap:wrap;gap:6px;align-items:center} -.bt-del-filters .bt-soft[aria-pressed=true]{border-color:var(--bt-accent);color:var(--bt-accent);background:color-mix(in srgb,var(--bt-accent) 10%,transparent)} -.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 diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index cee4c91db..d3dd00e34 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -7,7 +7,6 @@ import { taskCard } from '../shared/board-card.js' import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' -import { DeliveryView, DELIVERY_CSS } from './delivery-view.js' import { t } from './i18n.js' const POLL_MS = 15_000 @@ -224,8 +223,8 @@ function RailButton({ row, selected, meta, onSelect }) { meta ? h('span', { className: 'bt-board-rail-meta' }, meta) : null) } -// A task record as a card on the one board (#1265 step 1): the same component the delivery cards -// use, with the task's own attributes and actions hanging off it. +// A task record as a card. The shared frame draws the identity line; this board hangs its own +// attributes and actions off it. export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { const column = columnOf(task) const priority = priorityOf(task) @@ -358,36 +357,8 @@ function scopeName(roster, scope) { return '' } -// #1326: one board that shows both task cards and delivery cards together, no tab split. -// Columns come from workflow definitions; task cards map into the same columns as delivery cards. -function UnifiedBoard({ roster, tasks, rail, scope, counts, dragging, error, draft, actions, onDraft, onSelect, onMove, onDrag, onCreate }) { - const selectedBot = scope?.kind === 'bot' ? scope.botId : '' - const selectedRoom = scope?.kind === 'group' ? scope.roomId : '' - const lanes = scope?.kind === 'group' - return h('div', { className: 'bt-board-body' }, - h('aside', { className: 'bt-board-rail', 'aria-label': t('Task board') }, - h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), - rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { - const count = counts?.[row.id] ?? { done: 0, total: 0 } - return h(RailButton, { - key: row.id, row, selected: row.id === selectedBot, onSelect, - meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', - }) - }), - rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, - rail.groups.map(row => h(RailButton, { key: row.id, row, selected: row.id === selectedRoom, onSelect }))), - h('div', { className: 'bt-board-main' }, - scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, - h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), - h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, - error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, - lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, - h(DeliveryView, { actions, tasks, roster, dragging, onMove, onDrag }))) -} - export function KanbanDialog({ roster, actions, leaving = false }) { - // One board (#1326): task cards and delivery cards together, no tab split. The rail is per-Bot - // for task scope; delivery is repo-wide and reads from actions.deliveryBoard. + // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) const [dragging, setDragging] = useState(null) @@ -435,16 +406,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-status' }, name), h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), - h(UnifiedBoard, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, actions, + h(BoardView, { + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } export const KANBAN_CSS = ` .bt-board-row{min-height:40px;padding:6px 8px;gap:10px} -.bt-board-views{display:flex;gap:6px;align-items:center} -.bt-board-views .bt-soft[aria-selected=true]{background:var(--bt-active,var(--bt-hover));color:var(--bt-ink)} .bt-board-mark{flex:none;width:36px;height:28px;display:inline-flex;align-items:center;justify-content:center;color:var(--bt-ink-2)} .bt-board-mark svg{width:18px;height:18px} .bt-board-label{flex:1 1 auto;min-width:0;font-size:14px;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} @@ -505,4 +474,4 @@ export const KANBAN_CSS = ` .bt-board-columns{flex-direction:column} .bt-board-column{min-width:0} } -` + DELIVERY_CSS +` diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 74148bdba..ede61bead 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -402,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' || ep === 'workflows') return ALLOW + || 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 deleted file mode 100644 index 817a572bb..000000000 --- a/patches/ds-bot/files/src/host/delivery-board.js +++ /dev/null @@ -1,1031 +0,0 @@ -// Delivery board (#1173): one read-only snapshot of the repository's GitHub issues, pull -// requests and CI runs, laid out by delivery stage. This module is a view over GitHub, not a -// writer: every request it makes is a GET, the token stays in this process, and the payload the -// client receives carries no credential. The snapshot is cached; a stale cache is still served -// while a refresh runs, and the payload says when it was taken. -// -// A round neither blocks the caller nor fails whole (#1211): the first read answers -// `state: 'loading'` at once and the round runs behind `inflight`; a part that cannot be read is -// named in `missing` and the rest of the board still comes back; a round that outlives its 90 -// second budget lands as `partial` with what it has. Every failure carries -// `{ message, endpoint, status, kind, scope, at }` so the view can say what failed, where, and what -// 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' - -// #1326: Workflow-based columns. The three visible stages of repo-dev; review and done-today are filters. -export const DELIVERY_COLUMNS = ['not-started', 'in-progress', 'done'] - -// State labels first on a card, then the areas; the order is the order the board shows. -const STATE_LABELS = ['needs-human', 'needs-info', 'agent-ready', 'agent-working', 'agent-pr', 'awaiting-approval', 'hold'] -const DAY_MS = 86_400_000 -// Asia/Shanghai has no daylight saving; the day boundary the owner reads is its 00:00. -const SHANGHAI_OFFSET_MS = 8 * 3_600_000 -const CACHE_TTL_MS = 5 * 60_000 -const PAGE = 100 -// GitHub caps a list at 1000 items per query; the CI payload says counted/total instead of -// pretending the tail does not exist. -const MAX_PAGES = 10 -const REQUEST_TIMEOUT_MS = 20_000 -const MAX_DONE = 40 -// A round that runs past this lands as `partial` instead of reaching the browser as a timeout -// (#1211): the instance sits behind a reverse proxy that gives up long before an unbounded round -// would finish. -export const BUILD_BUDGET_MS = 90_000 -// After a failed round the next one waits 10 s, then 30 s, then two minutes, and never longer than -// that. The fixed 60 second poll either hammered a broken instance or hid a recovery (#1211). -export const BACKOFF_MS = [10_000, 30_000, 120_000] - -export const startOfDay = (now = Date.now()) => Math.floor((now + SHANGHAI_OFFSET_MS) / DAY_MS) * DAY_MS - SHANGHAI_OFFSET_MS - -// `YYYY-MM-DD` for a day boundary, read in the zone the day is cut in — the date the owner sees, -// not the UTC one, which is a day behind for eight hours of every Shanghai day (#1222). -export const shanghaiDate = (start) => new Date(start + SHANGHAI_OFFSET_MS).toISOString().slice(0, 10) - -// The seven-day series the chart draws (#1222): one entry per Asia/Shanghai day, oldest first, the -// last one today and marked `partial`. It is assembled from the two windows the round already read -// — the week's seven days and today's own — so it costs no request. A window that could not be -// read, or a day nobody counted, makes the whole series `null`: the view says it has no data -// instead of drawing seven quiet zero days. -export function dailySeries(week, todayMetrics, today) { - const buckets = new Map() - for (const day of [...(week?.daily ?? []), ...(todayMetrics?.daily ?? [])]) buckets.set(day.start, day) - const series = [] - for (let offset = 6; offset >= 0; offset -= 1) { - const start = today - offset * DAY_MS - const bucket = buckets.get(start) - if (bucket === undefined) return null - series.push({ - date: shanghaiDate(start), - ...(offset === 0 ? { partial: true } : {}), - newIssues: bucket.newIssues, - closedIssues: bucket.closedIssues, - newPullRequests: bucket.newPullRequests, - mergedPullRequests: bucket.mergedPullRequests, - ci: bucket.ci, - }) - } - return series -} - -// The per-day buckets are the series' raw material; the payload carries the assembled series only. -const withoutBuckets = (metrics) => { - if (!metrics) return metrics ?? null - const { daily: _buckets, ...rest } = metrics - return rest -} -const withoutCiDays = (ci) => { - if (!ci) return ci ?? null - const { days: _days, ...rest } = ci - return rest -} - -export function median(values) { - const list = (values ?? []).filter(value => Number.isFinite(value)).sort((a, b) => a - b) - if (list.length === 0) return null - const middle = Math.floor(list.length / 2) - return list.length % 2 === 0 ? (list[middle - 1] + list[middle]) / 2 : list[middle] -} - -const labelNames = (row) => (row.labels ?? []).map(label => (typeof label === 'string' ? label : label?.name)).filter(Boolean) -const orderLabels = (names) => [...names].sort((a, b) => { - const left = STATE_LABELS.indexOf(a), right = STATE_LABELS.indexOf(b) - if (left !== -1 || right !== -1) return (left === -1 ? STATE_LABELS.length : left) - (right === -1 ? STATE_LABELS.length : right) - return a.localeCompare(b) -}) -// The issues endpoint returns pull requests too; the pulls endpoint never does, so the two -// lists are told apart by that field rather than by sniffing a PR's shape. -const isPull = (row) => row.pull_request !== undefined || row.merge_commit_sha !== undefined -// GitHub's closing keywords are the only mention that links a pull request to an issue (#1189). -// `also see #12`, `same as #12` or a changelog line quoting a number is prose, and a card that -// showed those as a link would claim a pull request that closes nothing. -const CLOSING_KEYWORD = /\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)[ \t]*:?[ \t]*#(\d+)/gi -const closingLinks = (text) => [...String(text).matchAll(CLOSING_KEYWORD)].map(match => Number(match[1])) -const verdictOf = (comments) => { - for (let index = (comments ?? []).length - 1; index >= 0; index -= 1) { - const body = String(comments[index]?.body ?? '') - const match = /^\s*Verdict:\s*(PASS|INC)\b(.*)$/im.exec(body) - if (!match) continue - // The Verdict line names the tip the conclusion is about, so its sha is the marker; an - // Attestation line only stands in when the verdict line carries no sha. Reading them the other - // way round would let an older attestation hide a verdict that names the current tip (#1173). - const at = /^\s*Verdict:\s*(?:PASS|INC)\s+at\s+([0-9a-f]{7,40})/im.exec(body)?.[1] ?? null - const attestation = at ?? /^\s*Attestation:\s*([0-9a-f]{7,40})/im.exec(body)?.[1] ?? null - return { verdict: match[1].toUpperCase(), attestation } - } - return null -} - -// 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, - workflow: prefixed('workflow:'), - node: prefixed('node:'), - owner, - created: row.created_at ?? null, - updated: row.updated_at ?? null, - linked, - badge, - sha, - draft: row.draft === true, - } -} - -// #1326: Map delivery stage to workflow columns. PRs are in-progress (build/verify/gate); -// review is a filter (column:false). Issues map by labels. -export function columnOfDelivery(row, { kind }) { - const labels = labelNames(row) - if (kind === 'pr') return 'in-progress' - if (labels.includes('agent-working') || labels.includes('agent-pr')) return 'in-progress' - if (labels.includes('agent-ready')) return 'not-started' - return 'not-started' -} - -// Which part of the board a path belongs to. The view turns this into advice a person can act on -// ("give the token Actions: Read"), so it travels with every failure (#1211). -export function scopeOf(pathname) { - const path = String(pathname ?? '') - if (/\/actions\/runs/.test(path)) return 'actions' - if (/\/check-runs/.test(path)) return 'checks' - if (/\/comments/.test(path)) return 'comments' - if (/\/pulls/.test(path)) return 'pulls' - if (/\/issues/.test(path)) return 'issues' - if (/^\/repos\/[^/]+\/[^/]+$/.test(path)) return 'repo' - return null -} - -const headerOf = (response, name) => { - try { - const value = response?.headers?.get?.(name) - return typeof value === 'string' && value !== '' ? value : null - } catch { return null } -} - -// How long GitHub itself said to wait, or null when it said nothing usable. A number from here is -// the only time the view may print a wait (#1211): an invented "about 60 seconds" is disproved by -// the next rate limit. -export function retryAfterMsOf(response, now = Date.now) { - const retryAfter = headerOf(response, 'retry-after') - if (retryAfter !== null) { - const seconds = Number(retryAfter) - if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000) - const at = Date.parse(retryAfter) - if (Number.isFinite(at)) return Math.max(0, at - now()) - } - const reset = Number(headerOf(response, 'x-ratelimit-reset')) - if (Number.isFinite(reset) && reset > 0) return Math.max(0, reset * 1000 - now()) - return null -} - -// One failure, described well enough to tell five causes apart (#1211): a timeout is not a 404, a -// rate limit is not a missing permission, and neither is "no token at all". -export function describeResponse(response, pathname, { now = Date.now } = {}) { - const status = Number(response?.status ?? 0) - const remaining = headerOf(response, 'x-ratelimit-remaining') - const wait = retryAfterMsOf(response, now) - const rateLimited = status === 429 || (status === 403 && (wait !== null || remaining === '0')) - const kind = rateLimited ? 'rate-limit' - : status === 401 ? 'auth' - : status === 403 ? 'forbidden' - : status === 404 ? 'not-found' - : status >= 500 ? 'server' - : 'http' - const scope = scopeOf(pathname) - const message = rateLimited - ? `GitHub rate-limited this request for ${pathname}${wait === null ? '' : ` (retry after ${Math.ceil(wait / 1000)} s)`}` - : `GitHub answered ${status} for ${pathname}` - return { message, endpoint: pathname, status, kind, scope, retryAfterMs: rateLimited ? wait : null } -} - -export const failureOf = (message, detail = {}) => Object.assign(new Error(message), detail) - -async function readJson(response) { - if (!response.ok) throw new Error(`GitHub answered ${response.status}`) - return response.json() -} - -// The diagnostic entry (#1211): the same reads the board makes, once, with their statuses, so an -// instance that "does not come up" can be explained instead of guessed at. The UI button and the -// `--probe` CLI both call this function; there is no second implementation. -export async function probeGitHub({ repo, token, request = fetch, now = Date.now, sha = null, issue = null } = {}) { - const base = `https://api.github.com/repos/${repo}` - const bearer = typeof token === 'function' ? token() : token - const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'flybot-delivery-board' } - if (bearer) headers.Authorization = `Bearer ${bearer}` - const rows = [] - const ask = async (key, url) => { - const pathname = new URL(url).pathname - try { - const response = await request(url, { method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - if (!response.ok) { - const detail = describeResponse(response, pathname, { now }) - rows.push({ key, ok: false, ...detail }) - return null - } - rows.push({ key, ok: true, endpoint: pathname, status: response.status, kind: null, scope: scopeOf(pathname), retryAfterMs: null, message: null }) - return await response.json().catch(() => null) - } catch (error) { - const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' - rows.push({ - key, ok: false, endpoint: pathname, status: null, - kind: timedOut ? 'timeout' : 'network', scope: scopeOf(pathname), retryAfterMs: null, - message: timedOut - ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1000} seconds for ${pathname}` - : `GitHub request failed for ${pathname}: ${error?.message ?? error}`, - }) - return null - } - } - // The pull request list is asked first because the checks read needs a sha to ask about. - const pullsBody = await ask('pulls', `${base}/pulls?per_page=1`) - const openPull = Array.isArray(pullsBody) ? pullsBody[0] : null - await ask('repo', base) - await ask('actions', `${base}/actions/runs?per_page=1`) - const target = sha ?? openPull?.head?.sha ?? null - if (target) await ask('checks', `${base}/commits/${target}/check-runs?per_page=1`) - else rows.push({ key: 'checks', ok: false, endpoint: `/repos/${repo}/commits/{sha}/check-runs`, status: null, kind: 'skipped', scope: 'checks', retryAfterMs: null, message: 'no sha to read checks at' }) - await ask('comments', `${base}/issues/${issue ?? openPull?.number ?? 1}/comments?per_page=1`) - // The order the view prints them in; the order they were asked in is an implementation detail. - const order = ['repo', 'actions', 'checks', 'comments', 'pulls'] - rows.sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key)) - return { ok: rows.every(row => row.ok), repo, at: new Date(now()).toISOString(), rows } -} - -export function createDeliveryBoard({ repo, repoSource = null, token, request = fetch, now = Date.now, ttl = CACHE_TTL_MS, warn = () => {}, budgetMs = BUILD_BUDGET_MS } = {}) { - // Secrets are read asynchronously after install, so the token is resolved per request: a value - // captured at install time is empty until the next restart (#1173). - const tokenNow = () => (typeof token === 'function' ? token() : token) - const base = `https://api.github.com/repos/${repo}` - const cache = new Map() - let snapshot = null - let lastError = null - let inflight = null - let since = null - let attempt = 0 - let nextAttemptAt = 0 - // The round in progress: the deadline every request checks, and the parts that could not be read - // this time. Null outside a round. - let round = null - - const note = (part, error) => { - if (round === null) return - const detail = error !== null && typeof error === 'object' ? error : { message: String(error) } - round.missing.push({ - part, - scope: detail.scope ?? null, - endpoint: detail.endpoint ?? null, - status: detail.status ?? null, - kind: detail.kind ?? 'unknown', - retryAfterMs: detail.retryAfterMs ?? null, - message: detail.message ?? String(error), - }) - } - - // Every URL this board asks for is a read. A write would be a bug, so the shape is refused - // here rather than trusted from the call sites (#1173 read-only invariant). - async function get(url) { - const pathname = new URL(url).pathname - if (round !== null && now() > round.deadline) { - throw failureOf(`the round used its ${Math.round(budgetMs / 1000)} second budget`, { budget: true, endpoint: pathname, kind: 'budget', scope: scopeOf(pathname) }) - } - const cached = cache.get(url) - const headers = { Accept: 'application/vnd.github+json', 'User-Agent': 'flybot-delivery-board' } - const bearer = tokenNow() - if (bearer) headers.Authorization = `Bearer ${bearer}` - if (cached?.etag) headers['If-None-Match'] = cached.etag - let response - try { - response = await request(url, { method: 'GET', headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) - } catch (error) { - const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError' - throw failureOf( - timedOut - ? `GitHub did not answer within ${REQUEST_TIMEOUT_MS / 1000} seconds for ${pathname}` - : `GitHub request failed for ${pathname}: ${error?.message ?? error}`, - { endpoint: pathname, scope: scopeOf(pathname), status: null, kind: timedOut ? 'timeout' : 'network' }, - ) - } - if (response.status === 304 && cached !== undefined) { - cache.set(url, { ...cached, at: now() }) - return cached.body - } - if (!response.ok) { - const detail = describeResponse(response, pathname, { now }) - throw failureOf(detail.message, detail) - } - const body = await readJson(response) - cache.set(url, { etag: response.headers?.get?.('etag') ?? null, body, at: now() }) - return body - } - - // A walk that ends on its last allowed page with that page full, and with no row the `stop` - // predicate wanted, left a tail nobody read: the caller must say "unknown", not "fewer" - // (#1211, #1222). A full page alone is not that answer — the stop may have matched on it, which - // means the window was read whole and only the list ended (#1240). - async function walk(path, { max = MAX_PAGES, stop = null } = {}) { - const rows = [] - let truncated = false - for (let page = 1; page <= max; page += 1) { - const separator = path.includes('?') ? '&' : '?' - const chunk = await get(`${base}${path}${separator}per_page=${PAGE}&page=${page}`) - if (!Array.isArray(chunk)) break - rows.push(...chunk) - if (chunk.length < PAGE || (stop !== null && chunk.some(stop))) break - if (page === max) truncated = true - } - return { rows, truncated } - } - - async function pages(path, options) { - return (await walk(path, options)).rows - } - - // Every page, not only the first (#1189). Past 100 comments — ordinary on this repository - // (#1170, #1183) — the newest verdict sat on a page nothing read, and the badge fell back to - // "no verdict at this tip" while one existed. The cap is the same MAX_PAGES as every other list - // and each page is a cached GET (ETag), so a long thread costs up to MAX_PAGES reads of one URL - // and a normal one still costs one. - async function commentsOf(number) { - return pages(`/issues/${number}/comments`) - } - - async function runsOfDay(from, to) { - // GitHub reads `created` as a date-time range in ISO 8601; epoch milliseconds are accepted - // silently and match nothing, which reads as "no CI today" rather than an error (#1173). - const created = `${new Date(from).toISOString()}..${new Date(to).toISOString()}` - let total = null - let counted = 0 - let failed = 0 - let superseded = 0 - let truncated = false - for (let page = 1; page <= MAX_PAGES; page += 1) { - const body = await get(`${base}/actions/runs?created=${created}&per_page=${PAGE}&page=${page}`) - if (total === null) total = Number(body?.total_count ?? 0) - const runs = Array.isArray(body?.workflow_runs) ? body.workflow_runs : [] - counted += runs.length - for (const run of runs) { - if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') failed += 1 - else if (run.conclusion === 'cancelled') superseded += 1 - } - if (runs.length < PAGE) break - // The page cap is not the end of the day: a day whose runs outnumber MAX_PAGES * PAGE has a - // tail nobody counted, and its number is unknown rather than "no failures" (#1211). - if (page === MAX_PAGES) truncated = true - } - return { failed, superseded, counted, total: total ?? counted, truncated } - } - - const windowOf = (from) => ({ from, to: from + DAY_MS }) - - // The CI half of a window, kept apart from the issue and pull counts: `/actions/runs` missing a - // permission is one unknown number in the header, not an empty board (#1211). - async function ciFor(from, ciDays) { - const ci = { failed: 0, superseded: 0, counted: 0, total: 0, unknownDays: [], days: [] } - for (let day = 0; day < ciDays; day += 1) { - // Forward from the window's start, the way every other count in this module reads its - // window: walking back from `from` described the week before the one the other numbers - // cover, so a seven-day CI share was a week out of step with its own row (#1222). - const window = windowOf(from + day * DAY_MS) - const runs = await runsOfDay(window.from, window.to) - if (runs.truncated) { - // The tail was not read, so this day's number is unknown: it is named and left out rather - // than added as a partial truth (#1211). - note('ci', failureOf(`the workflow runs for ${new Date(window.from).toISOString()} were truncated at ${MAX_PAGES * PAGE}`, { kind: 'truncated', scope: 'actions', endpoint: '/actions/runs', status: null })) - ci.unknownDays.push(new Date(window.from).toISOString()) - ci.days.push({ start: window.from, unknown: true }) - continue - } - ci.failed += runs.failed - ci.superseded += runs.superseded - ci.counted += runs.counted - ci.total += runs.total - // The per-day block the chart reads (#1222). A cancelled run never judged the code, so it is - // left out of the denominator; the header keeps its own aggregate untouched. - ci.days.push({ start: window.from, failed: runs.failed, counted: Math.max(0, runs.counted - runs.superseded), total: runs.total }) - } - return ci - } - - // A list that stopped at the page cap has a tail nobody read, so the counts built from it are - // unknown rather than smaller numbers (#1222). The flag is the walk's own answer, not the row - // count: a page four that is full because `stop` matched an old row there was read whole (#1240). - const capped = (listing) => listing.truncated === true - - async function metricsFor(from, { days = 1, ciDays = 1, buckets = false } = {}) { - const to = from + days * DAY_MS - const [issueList, pullList] = await Promise.all([ - walk(`/issues?state=all&since=${new Date(from - DAY_MS).toISOString()}`, { max: 4, stop: row => row.created_at !== undefined && Date.parse(row.created_at) < from - DAY_MS }), - walk('/pulls?state=all&sort=created&direction=desc', { max: 4, stop: row => Date.parse(row.created_at ?? 0) < from }), - ]) - const issues = issueList.rows - const pulls = pullList.rows - const plainIssues = issues.filter(row => !isPull(row)) - const inWindow = (value) => { const at = Date.parse(value ?? ''); return Number.isFinite(at) && at >= from && at < to } - const newIssues = plainIssues.filter(row => inWindow(row.created_at)).length - const closedIssues = plainIssues.filter(row => inWindow(row.closed_at)) - const newPrs = pulls.filter(row => inWindow(row.created_at)).length - const merged = pulls.filter(row => inWindow(row.merged_at)) - const cappedIssues = capped(issueList) - const cappedPulls = capped(pullList) - // A CI window that cannot be read leaves `ci: null` — the header says unknown, never zero. - let ci = null - if (ciDays > 0) { - try { ci = await ciFor(from, ciDays) } catch (error) { - if (error?.budget) throw error - note('ci', error) - } - } - // One bucket per day of the window, for the seven-day series (#1222). A bucket is built from - // the rows this round already read, so it costs no request; a day whose list or run query was - // truncated, or whose CI could not be read at all, says `null` instead of a smaller number. - const dayBuckets = buckets - ? Array.from({ length: days }, (_, index) => { - const start = from + index * DAY_MS - const end = start + DAY_MS - const inDay = (value) => { const at = Date.parse(value ?? ''); return Number.isFinite(at) && at >= start && at < end } - const ciDay = ci?.days?.find(day => day.start === start) ?? null - return { - start, - newIssues: cappedIssues ? null : plainIssues.filter(row => inDay(row.created_at)).length, - closedIssues: cappedIssues ? null : plainIssues.filter(row => inDay(row.closed_at)).length, - newPullRequests: cappedPulls ? null : pulls.filter(row => inDay(row.created_at)).length, - mergedPullRequests: cappedPulls ? null : pulls.filter(row => inDay(row.merged_at)).length, - ci: ciDay === null || ciDay.unknown === true - ? null - : { failed: ciDay.failed, counted: ciDay.counted, total: ciDay.total }, - } - }) - : undefined - return { - newIssues, - closedIssues: closedIssues.length, - closedReasons: { - completed: closedIssues.filter(row => row.state_reason === 'completed').length, - notPlanned: closedIssues.filter(row => row.state_reason === 'not_planned').length, - duplicate: closedIssues.filter(row => row.state_reason === 'duplicate').length, - }, - newPullRequests: newPrs, - mergedPullRequests: merged.length, - medianIssueCloseMs: median(closedIssues.map(row => Date.parse(row.closed_at) - Date.parse(row.created_at))), - medianPullMergeMs: median(merged.map(row => Date.parse(row.merged_at) - Date.parse(row.created_at))), - ci: withoutCiDays(ci), - ...(dayBuckets ? { daily: dayBuckets } : {}), - } - } - - function badgeFor(pr, verdict, checks) { - const head = pr.head?.sha ?? '' - const atTip = verdict !== null && verdict.attestation !== null && (head.startsWith(verdict.attestation) || verdict.attestation.startsWith(head)) - // A verdict only counts at the tip it names; an older one is history, so CI speaks. - if (atTip) { - if (verdict.verdict === 'INC') return { badge: 'inc', basis: 'INC at this tip' } - } else { - // `checks === null` is "could not be read", not "none ran": calling that "no verdict" would - // blame the pull request for a permission the instance is missing (#1211). - if (checks === null) return { badge: 'checks-unknown', basis: 'check-runs could not be read' } - const states = (checks ?? []).map(run => run.status === 'completed' ? run.conclusion : run.status) - if (states.some(state => state === 'in_progress' || state === 'queued' || state === 'requested' || state === 'waiting')) { - return { badge: 'ci-running', basis: 'checks running' } - } - if (states.some(state => state === 'failure' || state === 'timed_out')) return { badge: 'ci-red', basis: 'checks failed' } - return { badge: 'no-verdict', basis: verdict === null ? 'no verdict at this tip' : `verdict at ${verdict.attestation?.slice(0, 7) ?? 'another tip'} is older than this tip` } - } - // GitHub's REST API does not expose the merge queue. A clean, green PR is waiting to be - // enqueued; one that is blocked or still waiting on checks is waiting for its merge. - if (pr.mergeable_state === 'clean') return { badge: 'pass-enqueue', basis: 'PASS, mergeable, not queued yet' } - return { badge: 'pass-merge', basis: `PASS, ${pr.mergeable_state ?? 'unknown'}` } - } - - async function ownerOf(row, kind, comments = null) { - const assignee = (row.assignees ?? [])[0]?.login - if (typeof assignee === 'string' && assignee !== '') return { owner: assignee, ownerKind: 'human' } - if (kind === 'pr') { - const body = String(row.body ?? '') - const who = /^\s*(?:Written-by|Owner):\s*([^\n]+)$/im.exec(body)?.[1]?.trim() - if (who) return { owner: who, ownerKind: /bot/i.test(who) ? 'bot' : 'human' } - } - const list = comments ?? await commentsOf(row.number) - for (let index = list.length - 1; index >= 0; index -= 1) { - const claim = /^\s*Claimed by\s+([^\n]+)$/im.exec(String(list[index]?.body ?? ''))?.[1]?.trim() - if (claim) return { owner: claim, ownerKind: /bot/i.test(claim) ? 'bot' : 'human' } - } - return { owner: null, ownerKind: null } - } - - // One part of the round: a failure is noted by name and the fallback is used, except the budget, - // which ends the whole round as a partial snapshot (#1211). - async function part(name, work, fallback) { - try { return await work() } catch (error) { - if (error?.budget) throw error - note(name, error) - return fallback - } - } - - async function snapshotFromGitHub({ startedAt }) { - const today = startOfDay(startedAt) - const weekStart = today - 7 * DAY_MS - let openIssues = [] - let openPulls = [] - let closedToday = [] - let mergedToday = [] - let todayMetrics = null - let weekMetrics = null - // #1326: Three workflow columns; done-today cards go in 'done' with data-stage='done-today' for filtering. - const columns = { 'not-started': [], 'in-progress': [], 'done': [] } - try { - // The two open lists are the board itself: when they cannot be read there is no board to - // show, so that failure is the round's, not a part's (#1211). - ;[openIssues, openPulls] = await Promise.all([ - pages('/issues?state=open&sort=updated&direction=desc'), - pages('/pulls?state=open&sort=updated&direction=desc'), - ]) - // Two windows, settled separately: a week that cannot be read must not take today with it. - const metricsResults = await Promise.allSettled([ - metricsFor(today, { buckets: true }), - metricsFor(weekStart, { days: 7, ciDays: 7, buckets: true }), - ]) - const settle = (result, name) => { - if (result.status === 'fulfilled') return result.value - if (result.reason?.budget) throw result.reason - note(name, result.reason) - return null - } - todayMetrics = settle(metricsResults[0], 'metrics.today') - weekMetrics = settle(metricsResults[1], 'metrics.week') - closedToday = await part('closed-today', () => pages(`/issues?state=closed&since=${new Date(today - DAY_MS).toISOString()}`, { max: 3 }), []) - mergedToday = await part('merged-today', () => pages('/pulls?state=closed&sort=updated&direction=desc', { max: 3, stop: row => Date.parse(row.updated_at ?? 0) < today - 2 * DAY_MS }), []) - } catch (error) { - // The budget is not a failure: everything read so far is assembled and the snapshot lands as - // `partial`, so the view has something to show and the reason it is incomplete (#1211). - if (!error?.budget) throw error - note('budget', error) - } - const issues = openIssues.filter(row => !isPull(row)) - const pullRows = openPulls - const linkedTo = new Map() - for (const pr of pullRows) { - for (const number of closingLinks(`${pr.title ?? ''}\n${pr.body ?? ''}`)) { - if (!linkedTo.has(number)) linkedTo.set(number, pr.number) - } - } - // A card whose owner cannot be read loses its owner, not the board (#1211). - for (const issue of issues) { - const column = columnOfDelivery(issue, { kind: 'issue' }) - let owner = null - let ownerKind = null - if (column !== 'raw') { - try { ({ owner, ownerKind } = await ownerOf(issue, 'issue')) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${issue.number}`, error) - } - } - columns[column].push(cardOf(issue, { kind: 'issue', linked: linkedTo.get(issue.number) ?? null, owner, ownerKind })) - } - for (const pr of pullRows) { - let verdictComments = null - try { verdictComments = await commentsOf(pr.number) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${pr.number}`, error) - } - // `null` is "could not be read"; an empty list is "none ran". The badge says which (#1211). - let checks = null - if (pr.head?.sha) { - try { - const body = await get(`${base}/commits/${pr.head.sha}/check-runs?per_page=100`) - checks = body?.check_runs ?? [] - } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`checks:${pr.number}`, error) - } - } else { - note(`checks:${pr.number}`, failureOf('this pull request has no head sha', { kind: 'skipped', scope: 'checks', endpoint: '/commits/{sha}/check-runs', status: null })) - } - const verdict = verdictOf(verdictComments ?? []) - const { badge, basis } = badgeFor(pr, verdict, checks) - let owner = null - let ownerKind = null - try { ({ owner, ownerKind } = await ownerOf(pr, 'pr', verdictComments)) } catch (error) { - if (error?.budget) { note('budget', error); break } - note(`comments:${pr.number}`, error) - } - // An open pull request is work in progress, and `review` is a stage a card carries: the - // definition draws no column for it (#1326), so the card is placed by `columnOfDelivery`. - columns[columnOfDelivery(pr, { kind: 'pr' })].push({ - ...cardOf(pr, { kind: 'pr', owner, ownerKind, badge, sha: String(pr.head?.sha ?? '').slice(0, 7) }), - badgeBasis: basis, - stage: 'review', - }) - } - const closedInDay = (row) => Date.parse(row.closed_at ?? '') >= today - const doneIssues = closedToday.filter(row => !isPull(row) && closedInDay(row)) - const donePulls = mergedToday.filter(row => Date.parse(row.merged_at ?? '') >= today) - // A pull request closed today without a merge is finished work too, so it is done today and not - // nowhere (#1173). The issues endpoint describes its pull requests by the `pull_request` object - // and gives no top-level `merged_at` and no `head`: reading either would count a merged pull - // twice and name a commit this endpoint never returns. The numbers already counted as merged - // settle it even when that row carries no merge time. - const mergedNumbers = new Set(donePulls.map(row => row.number)) - const abandonedPulls = closedToday.filter(row => isPull(row) && !row.pull_request?.merged_at && !mergedNumbers.has(row.number) && closedInDay(row)) - // #1326: done-today cards go in 'done' column with stage='done-today' for filtering. - for (const row of donePulls) columns.done.push({ ...cardOf(row, { kind: 'pr', sha: String(row.merge_commit_sha ?? '').slice(0, 7) || null }), stage: 'done-today' }) - for (const row of abandonedPulls) columns.done.push({ ...cardOf(row, { kind: 'pr' }), stage: 'done-today' }) - for (const row of doneIssues) columns.done.push({ ...cardOf(row, { kind: 'issue' }), stage: 'done-today' }) - // done column holds all done cards; done-today is a filter, not a separate column. - columns.done = columns.done.slice(0, MAX_DONE) - const needsHuman = issues.filter(row => labelNames(row).includes('needs-human') || labelNames(row).includes('needs-info')).length - const openPrs = pullRows.length - return { - ok: true, - repo, - asOf: new Date(startedAt).toISOString(), - computedMs: now() - startedAt, - columns, - counts: Object.fromEntries(DELIVERY_COLUMNS.map(column => [column, columns[column].length])), - metrics: { - dayStart: new Date(today).toISOString(), - timeZone: 'Asia/Shanghai', - today: withoutBuckets(todayMetrics), - week: withoutBuckets(weekMetrics), - daily: dailySeries(weekMetrics, todayMetrics, today), - open: { - issues: issues.length, - pullRequests: openPrs, - needsHuman, - medianIssueAgeMs: median(issues.map(row => startedAt - Date.parse(row.created_at))), - }, - }, - } - } - - function view(extra = {}) { - const elapsedMs = since === null ? 0 : Math.max(0, now() - Date.parse(since)) - if (snapshot === null) { - if (inflight !== null) return { ok: true, state: 'loading', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: null, ...extra } - if (lastError !== null) return { ok: true, state: 'failed', repo, repoSource, since, attempt, elapsedMs, error: lastError, retryAt: nextAttemptAt || null, ...extra } - // Never tried: the one case where the view may say there is no snapshot yet (#1211). - return { ok: false, state: 'idle', repo, repoSource, since: null, attempt: 0, elapsedMs: 0, error: null, retryAt: null, ...extra } - } - const state = lastError !== null || inflight !== null ? 'stale' : (snapshot.missing?.length ? 'partial' : 'fresh') - return { - ok: true, - ...snapshot, - repoSource, - state, - stale: lastError !== null || inflight !== null, - refreshing: inflight !== null, - error: lastError, - since, - attempt, - elapsedMs, - retryAt: lastError !== null ? nextAttemptAt || null : null, - ...extra, - } - } - - // The round runs behind `inflight`; nobody awaits it. The first read starts it and answers - // `loading` at once, so the browser is never parked on a whole round (#1211). - function start() { - if (inflight !== null) return - const startedAt = now() - since = new Date(startedAt).toISOString() - attempt += 1 - const current = { deadline: startedAt + budgetMs, missing: [] } - round = current - inflight = (async () => { - try { - const built = await snapshotFromGitHub({ startedAt }) - const missing = [] - const seen = new Set() - for (const entry of current.missing) { - if (seen.has(entry.part)) continue - seen.add(entry.part) - missing.push(entry) - } - snapshot = { ...built, partial: missing.length > 0, missing } - lastError = null - nextAttemptAt = 0 - attempt = 0 - } catch (error) { - const detail = error !== null && typeof error === 'object' ? error : {} - const message = String(error?.message ?? error) - warn('ds-bot: delivery board refresh failed: %s', message) - // Kept for the next read: throwing the reason away is what made a failed instance look - // like one that was never connected (#1211). - lastError = { - message, - endpoint: detail.endpoint ?? null, - status: detail.status ?? null, - kind: detail.kind ?? 'unknown', - scope: detail.scope ?? null, - retryAfterMs: detail.retryAfterMs ?? null, - at: new Date(now()).toISOString(), - } - if (snapshot !== null) snapshot = { ...snapshot, error: message } - const delay = BACKOFF_MS[Math.min(Math.max(attempt, 1) - 1, BACKOFF_MS.length - 1)] - nextAttemptAt = now() + delay - } finally { - inflight = null - round = null - } - })() - } - - return { - columns: DELIVERY_COLUMNS, - // `wait` awaits the round in progress and is for diagnostics and tests; the browser never - // passes it, because parking a request on a whole round is the bug this fixed (#1211). - async read({ force = false, wait = false } = {}) { - if (force) nextAttemptAt = 0 - const age = snapshot === null ? Infinity : now() - Date.parse(snapshot.asOf) - if (inflight === null && now() >= nextAttemptAt && (force || age >= ttl)) start() - if (wait && inflight !== null) { - await inflight.catch(() => { - // The round stores its own failure in `error`; a read must not throw it (#1211). - }) - } - return view() - }, - // Tests and the host's own diagnostics read the cache without touching GitHub. - cached: () => snapshot, - probe: () => probeGitHub({ repo, token: tokenNow, request, now }), - } -} - -// One board out of several repositories (#1265 step 4). A repository that cannot be read does not -// take the board with it: it keeps its own entry and its own `data-state`, and the body says -// `partial`, the same word a half-read round uses (#1211). A single repository answers exactly the -// view it answered alone, so nothing about a one-repository instance changes on screen. -const sumOf = (values) => (values.every(value => Number.isFinite(value)) ? values.reduce((total, value) => total + value, 0) : null) - -const mergeCi = (list) => { - if (list.length === 0 || list.some(ci => ci === null || ci === undefined)) return null - return { - failed: sumOf(list.map(ci => Number(ci.failed ?? 0))), - total: sumOf(list.map(ci => Number(ci.total ?? 0))), - counted: sumOf(list.map(ci => Number(ci.counted ?? 0))), - superseded: sumOf(list.map(ci => Number(ci.superseded ?? 0))), - unknownDays: list.flatMap(ci => ci.unknownDays ?? []), - } -} - -// Seven days added day by day; a field any repository could not count stays `null` rather than -// becoming a smaller number (#1211, #1222). -const mergeDaily = (series) => { - if (series.length === 0 || series.some(days => !Array.isArray(days) || days.length !== series[0]?.length)) return null - const length = series[0].length - return Array.from({ length }, (_, index) => { - const field = (key) => sumOf(series.map(days => days[index]?.[key])) - return { - date: series[0][index]?.date ?? null, - ...(series.some(days => days[index]?.partial === true) ? { partial: true } : {}), - newIssues: field('newIssues'), - closedIssues: field('closedIssues'), - newPullRequests: field('newPullRequests'), - mergedPullRequests: field('mergedPullRequests'), - ci: mergeCi(series.map(days => days[index]?.ci ?? null)), - } - }) -} - -const mergeWindow = (windows) => { - if (windows.length === 0 || windows.some(window => window === null || window === undefined)) return null - return { - newIssues: sumOf(windows.map(window => window.newIssues)), - closedIssues: sumOf(windows.map(window => window.closedIssues)), - closedReasons: { - completed: sumOf(windows.map(window => window.closedReasons?.completed ?? 0)), - notPlanned: sumOf(windows.map(window => window.closedReasons?.notPlanned ?? 0)), - duplicate: sumOf(windows.map(window => window.closedReasons?.duplicate ?? 0)), - }, - newPullRequests: sumOf(windows.map(window => window.newPullRequests)), - mergedPullRequests: sumOf(windows.map(window => window.mergedPullRequests)), - // A median across repositories is not the sum of their medians, and an invented one is worse - // than none: the header reads '—' for both while the board shows more than one repository. - medianIssueCloseMs: null, - medianPullMergeMs: null, - ci: mergeCi(windows.map(window => window.ci ?? null)), - } -} - -// A repository that did not read contributes no numbers: the board's totals are then unknown, not -// the readable repositories' sum (#1297). The entry list is handed over whole — filtering the -// missing `metrics` out first is what printed one repository's numbers under "2 repositories". -const mergeMetrics = (list) => { - if (list.length === 0 || list.some(entry => entry === null || entry === undefined)) return null - const opens = list.map(entry => entry.open ?? null) - return { - dayStart: list[0].dayStart ?? null, - timeZone: list[0].timeZone ?? 'Asia/Shanghai', - today: mergeWindow(list.map(entry => entry.today ?? null)), - week: mergeWindow(list.map(entry => entry.week ?? null)), - daily: mergeDaily(list.map(entry => entry.daily)), - open: opens.some(open => open === null) ? null : { - issues: sumOf(opens.map(open => open.issues)), - pullRequests: sumOf(opens.map(open => open.pullRequests)), - needsHuman: sumOf(opens.map(open => open.needsHuman)), - medianIssueAgeMs: null, - }, - } -} - -export function combineRepos(views) { - if (!Array.isArray(views) || views.length === 0) return null - const entries = views.map(view => ({ - repo: view.repo ?? null, - repoSource: view.repoSource ?? null, - state: view.state ?? 'fresh', - ok: view.ok !== false, - since: view.since ?? null, - attempt: view.attempt ?? 0, - elapsedMs: view.elapsedMs ?? 0, - asOf: view.asOf ?? null, - computedMs: view.computedMs ?? 0, - columns: view.columns, - counts: view.counts ?? null, - metrics: view.metrics ?? null, - error: view.error ?? null, - retryAt: view.retryAt ?? null, - stale: view.stale === true, - refreshing: view.refreshing === true, - missing: view.missing ?? [], - partial: view.partial === true, - })) - // A one-repository instance answers with today's payload: the top-level fields are that - // repository's own, and `repos` is the same view for a caller that reads the list. - if (entries.length === 1) return { ...entries[0], repos: [{ ...entries[0] }] } - const withBoard = entries.filter(entry => entry.columns !== undefined) - const state = withBoard.length === 0 - ? (entries.every(entry => entry.state === 'idle') ? 'idle' : entries.some(entry => entry.state === 'loading') ? 'loading' : 'failed') - : withBoard.length < entries.length ? 'partial' - : entries.some(entry => entry.state === 'stale') ? 'stale' - : entries.some(entry => entry.state === 'partial') ? 'partial' : 'fresh' - const stamps = entries.map(entry => entry.asOf).filter(Boolean).sort() - const startsAt = entries.map(entry => entry.since).filter(Boolean).sort() - const retries = entries.map(entry => Number(entry.retryAt)).filter(at => Number.isFinite(at) && at > 0) - const missing = entries.flatMap(entry => (entry.missing ?? []).map(item => ({ ...item, repo: entry.repo }))) - // Every column the readable repositories have, in one list; the per-repository blocks draw their - // own slices of it, so this top-level copy is the union the payload always carried. - const columns = withBoard.length === entries.length - ? Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, withBoard.flatMap(entry => entry.columns?.[id] ?? [])])) - : undefined - return { - ok: entries.every(entry => entry.ok), - state, - // No single repository owns a shared board, so the header names the count instead (#1265). - repo: null, - repoSource: null, - repos: entries, - asOf: stamps[0] ?? null, - since: startsAt[0] ?? null, - attempt: Math.max(...entries.map(entry => entry.attempt ?? 0)), - elapsedMs: Math.max(...entries.map(entry => entry.elapsedMs ?? 0)), - computedMs: Math.max(...entries.map(entry => entry.computedMs ?? 0)), - error: entries.find(entry => entry.error !== null)?.error ?? null, - retryAt: retries.length > 0 ? Math.min(...retries) : null, - stale: entries.some(entry => entry.stale), - refreshing: entries.some(entry => entry.refreshing), - partial: entries.some(entry => entry.partial) || withBoard.length < entries.length, - missing, - columns, - counts: columns === undefined ? null : Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, columns[id].length])), - // The whole list, missing entries included: a repository that has never read answers `null` - // metrics, and dropping it here would make the rest look like the board's total (#1297). - metrics: mergeMetrics(entries.map(entry => entry.metrics ?? null)), - } -} - -export function install(rt) { - // One board per repository, not one global board with a repo baked in (#1250): a changed - // `delivery.repo` resolves to a new name, and the new name's first read is its own `loading` - // instead of the previous repository's snapshot. - const boards = new Map() - const notedDefaults = new Set() - const warn = (...args) => rt.warn?.(...args) - const tokenOf = () => { - try { return rt.secretValue?.('GITHUB_TOKEN') } catch { return undefined } - } - const request = (url, init) => (rt.fetch ?? fetch)(url, init) - // The workspace is the team folder, not one Bot's folder: the board is host-level, and a Bot's - // folder would make the answer depend on which Bot happened to be named (#1250). - const workspace = () => { - try { return rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { return null } - } - const setting = () => { - try { return instanceRepoSettings(rt.config) } catch { return [] } - } - // Level 3 is the package's own repository, not the instance's choice. The shipped package carries - // it; a host whose package.json has none (or a test) hands in '' and the board refuses rather than - // asking for `/repos/null` (#1250). - const packageSlug = () => { - try { return rt.packageRepository === undefined ? repoSlug() : rt.packageRepository } catch { return null } - } - // Resolved on every call, so the setting is read live and the caches below are keyed by its - // answer. Each repository keeps its own source, so two of them cannot share one line (#1265). - const resolveNow = () => { - const found = resolveRepos({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() }) - // A default is acceptable only while it is visible as a default: the page labels it, and this - // line records it too, so nothing falls back silently (#1250). - for (const entry of found) { - if (entry.source !== 'package' || notedDefaults.has(entry.repo)) continue - notedDefaults.add(entry.repo) - warn('ds-bot: delivery board: no delivery.repos and no git remote in the workspace; reading the package repository %s (default)', entry.repo) - } - return found - } - const boardFor = ({ repo }) => { - let board = boards.get(repo) - if (board === undefined) { - board = createDeliveryBoard({ repo, token: tokenOf, request, warn }) - boards.set(repo, board) - } - return board - } - // Nothing resolves: the answer is a failure, and it is answered before any board exists so no - // request can be made for `/repos/null` (#1250). - const noRepo = () => ({ - ok: false, state: 'failed', repo: null, repoSource: null, repos: [], since: null, attempt: 0, elapsedMs: 0, - error: { message: NO_REPO_MESSAGE, endpoint: null, status: null, kind: 'no-repo', scope: 'repo', retryAfterMs: null, at: new Date().toISOString() }, - retryAt: null, - }) - rt.deliveryBoard = async (payload = {}) => { - const found = resolveNow() - if (found.length === 0) return noRepo() - // The channel answers before a host restart would; load once so the first read is signed. - // The answer is `loading` either way — the round runs behind `inflight` (#1211). - await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery board: secrets did not load: %s', error?.message ?? error)) - const views = [] - for (const entry of found) { - const view = await boardFor(entry).read({ force: payload?.force === true, wait: payload?.wait === true }) - views.push({ ...view, repo: entry.repo, repoSource: entry.source }) - } - 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. - rt.deliveryProbe = async () => { - const found = resolveNow() - if (found.length === 0) return { ok: false, repo: null, repoSource: null, repos: [], at: new Date().toISOString(), rows: [], error: NO_REPO_MESSAGE } - await rt.loadSecrets?.().catch(error => warn('ds-bot: delivery probe: secrets did not load: %s', error?.message ?? error)) - const reports = [] - for (const entry of found) { - try { reports.push({ repo: entry.repo, repoSource: entry.source, ...(await boardFor(entry).probe()) }) } catch (error) { - reports.push({ repo: entry.repo, repoSource: entry.source, ok: false, at: new Date().toISOString(), rows: [], error: String(error?.message ?? error) }) - } - } - const single = reports.length === 1 - const at = new Date().toISOString() - return { - ok: reports.every(report => report.ok), - repo: single ? reports[0].repo : null, - repoSource: single ? reports[0].repoSource : null, - repos: reports.map(report => ({ repo: report.repo, repoSource: report.repoSource, ok: report.ok })), - at, - rows: single ? reports[0].rows : reports.flatMap(report => (report.rows ?? []).map(row => ({ ...row, repo: report.repo }))), - ...(reports.some(report => report.error !== undefined) ? { error: reports.find(report => report.error !== undefined)?.error } : {}), - } - } -} diff --git a/patches/ds-bot/files/src/host/delivery-repo.js b/patches/ds-bot/files/src/host/delivery-repo.js deleted file mode 100644 index be7356c73..000000000 --- a/patches/ds-bot/files/src/host/delivery-repo.js +++ /dev/null @@ -1,163 +0,0 @@ -// Which repositories the delivery board reads, and where each name came from (#1250, #1265). -// -// The board used to read the shipped package's `repository.url` and present it as a fact, so an -// instance set up on another fork showed this project's numbers and said nothing about whose they -// were. Since #1265 step 4 an instance may work in more than one repository, and each one is -// resolved on its own so no two of them share a source line. The same three steps, first answer -// wins, for every name: -// -// 1. the instance setting, `delivery.repos` (a list) or `delivery.repo` (one name, the older -// spelling, read as a list of one) on the plugin entry — the host hands the entry through -// verbatim, so all the spellings people write are read (`instanceRepoSettings`); -// 2. the workspace's own git remote — `/.git/config`, `[remote "origin"] url`; -// 3. the package's `repository.url`, which is a default and is labelled as one. -// -// Nothing is spawned: the host may run where `git` is not on PATH. A `.git` that is a file is -// followed through its `gitdir:` pointer: a submodule keeps `config` there, and a linked worktree -// keeps only a `commondir` pointer to the shared directory that does (#1258). A name is normalised -// to `owner/name`; a GitHub URL or the scp form `git@github.com:owner/name.git` is accepted where a -// name is, because that is what people paste. -import { readFileSync, statSync } from 'node:fs' -import { join, resolve } from 'node:path' - -// The wording acceptance criterion 4 asks for, in one place so the view, the host and the CLI agree. -export const NO_REPO_MESSAGE = 'no repository — set delivery.repo' - -// A segment is a plain repository name part: word characters, dots and dashes, but never a leading -// dot. `.` and `..` are path steps, not names, and a board that accepted them would build -// `/repos/../../issues` and normalise it to the API root (#1258). -const OWNER_NAME = /^(?!\.)[\w.-]+\/(?!\.)[\w.-]+$/ -const SCP_GITHUB = /^(?:[\w.-]+@)?github\.com:([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i - -const cleanName = text => { - const trimmed = String(text ?? '').trim().replace(/\/+$/, '').replace(/\.git$/i, '') - return OWNER_NAME.test(trimmed) ? trimmed : null -} - -// `owner/name` out of whatever form the value arrived in; null when it is not a GitHub repository -// name. A URL on another host is refused rather than mined for its last two segments. -export function parseRepoName(value) { - const text = String(value ?? '').trim() - if (text === '') return null - const scp = SCP_GITHUB.exec(text) - if (scp) return cleanName(scp[1]) - if (/^[a-z][\w+.-]*:\/\//i.test(text)) { - let url - try { url = new URL(text) } catch { return null } - if (url.hostname.toLowerCase() !== 'github.com') return null - const parts = url.pathname.replace(/^\/+|\/+$/g, '').split('/') - return parts.length === 2 ? cleanName(parts.join('/')) : null - } - return cleanName(text) -} - -// The `[remote "origin"] url` out of git's own config text; null when that remote or its url is -// absent. Other remotes are ignored on purpose: one host shows one repository, and a union of -// disagreeing remotes would be a guess dressed as a fact. -export function originUrlIn(text) { - let section = null - for (const line of String(text ?? '').split(/\r?\n/)) { - const trimmed = line.trim() - if (trimmed === '' || trimmed.startsWith('#') || trimmed.startsWith(';')) continue - const header = /^\[(.+)\]$/.exec(trimmed) - if (header) { section = header[1].trim(); continue } - if (section === null || !/^remote\s+"origin"$/i.test(section)) continue - const value = /^url\s*=\s*(.+)$/i.exec(trimmed) - if (value) return value[1].trim() - } - return null -} - -// Where git keeps the config for this folder: `.git/config`, or whatever a `.git` file's `gitdir:` -// pointer leads to. A submodule keeps `config` in that directory; a linked worktree keeps only a -// `commondir` file naming the shared directory that has it (#1258). Null when the folder is not a -// git checkout at all. -export function gitConfigPath(folder) { - if (typeof folder !== 'string' || folder === '') return null - const dot = join(folder, '.git') - try { - if (statSync(dot).isDirectory()) return join(dot, 'config') - const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync(dot, 'utf8')) - if (!pointer) return null - const gitdir = resolve(folder, pointer[1].trim()) - const own = join(gitdir, 'config') - if (isFile(own)) return own - const common = commonDirOf(gitdir) - return common === null ? null : join(common, 'config') - } catch { return null } -} - -const isFile = path => { - try { return statSync(path).isFile() } catch { return false } -} - -// A linked worktree's gitdir holds no config: `commondir` names the common directory, relative to -// the gitdir, where the shared `config` lives. -const commonDirOf = gitdir => { - try { - const pointer = readFileSync(join(gitdir, 'commondir'), 'utf8').trim() - return pointer === '' ? null : resolve(gitdir, pointer) - } catch { return null } -} - -// The instance setting as the profile actually wrote it. The host passes the plugin entry through -// verbatim and expands no dotted key, so one setting reaches us as a flat `deliveryRepo`, a literal -// `'delivery.repo'`, or a nested `delivery.repo` — the hint, the page and the docs all say the last -// spelling, and reading only the first would ignore what a user just wrote (#1258). First defined -// answer wins; validation stays in `parseRepoName`. -export function instanceRepoSetting(config) { - if (config === null || typeof config !== 'object') return null - for (const value of [config.delivery?.repo, config['delivery.repo'], config.deliveryRepo]) { - if (value !== undefined && value !== null && value !== '') return value - } - return null -} - -// The same setting as a list (#1265 step 4). `delivery.repos` is the new shape and is read first; -// the three older spellings each describe one repository, so a profile written before this change -// keeps working as a list of one. An empty list is "nothing written", not "show nothing": the -// next level still answers, which is what a person who cleared the field expects. -export function instanceRepoSettings(config) { - if (config === null || typeof config !== 'object') return [] - for (const value of [config.delivery?.repos, config['delivery.repos'], config.deliveryRepos]) { - if (value === undefined || value === null || value === '') continue - const list = Array.isArray(value) ? value : [value] - const kept = list.map(entry => String(entry ?? '').trim()).filter(entry => entry !== '') - if (kept.length > 0) return kept - } - const single = instanceRepoSetting(config) - return single === null ? [] : [single] -} - -// The `owner/name` the folder's `origin` names, read from disk without running git. -export function gitOriginRepo(folder) { - const path = gitConfigPath(folder) - if (path === null) return null - try { return parseRepoName(originUrlIn(readFileSync(path, 'utf8'))) } catch { return null } -} - -// Every repository the board reads, each with its own source (#1265 step 4). The levels answer -// once: the first level that yields a name yields the whole list, because a list from `config` -// replaces the inferred one rather than adding to it — a workspace remote and a setting are two -// answers to one question, not two repositories. Duplicates are one repository, so a name written -// twice (as a URL and as `owner/name`) cannot draw two blocks of the same board. -export function resolveRepos({ setting = null, workspace = null, packageSlug = null } = {}) { - const raw = Array.isArray(setting) ? setting : (setting === null || setting === undefined || setting === '' ? [] : [setting]) - const configured = [] - for (const value of raw) { - const name = parseRepoName(value) - if (name !== null && !configured.some(entry => entry.repo === name)) configured.push({ repo: name, source: 'config' }) - } - if (configured.length > 0) return configured - const remote = gitOriginRepo(workspace) - if (remote !== null) return [{ repo: remote, source: 'remote' }] - const packaged = parseRepoName(packageSlug) - if (packaged !== null) return [{ repo: packaged, source: 'package' }] - return [] -} - -// The first answer, for a caller that asks about one repository (`core/scripts/delivery-probe.mjs` -// used to; it now asks for the list). An empty answer is the same shape it always had. -export function resolveRepo(options = {}) { - return resolveRepos(options)[0] ?? { repo: null, source: null } -} diff --git a/patches/ds-bot/files/src/host/workflow-store.js b/patches/ds-bot/files/src/host/workflow-store.js index 793cd0f05..a50095b17 100644 --- a/patches/ds-bot/files/src/host/workflow-store.js +++ b/patches/ds-bot/files/src/host/workflow-store.js @@ -232,7 +232,7 @@ export function install(rt) { } 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). + // Presets plus the workspace files. The settings page lists and opens them; nothing here draws a board. rt.workflowDefinitions = () => { try { return store()?.definitions() ?? PRESETS.map(normaliseWorkflow) } catch { return PRESETS.map(normaliseWorkflow) } } diff --git a/patches/ds-bot/files/src/shared/board-card.js b/patches/ds-bot/files/src/shared/board-card.js index d97bc0200..ee9a4c88a 100644 --- a/patches/ds-bot/files/src/shared/board-card.js +++ b/patches/ds-bot/files/src/shared/board-card.js @@ -1,54 +1,13 @@ -// One card behind two boards (#1265, step 1). The delivery board reads GitHub, the task board -// reads FlyBot's task records; on screen they are the same object, so a card says which side it -// came from and carries a key that is unique across both. -// -// A repository card is keyed by `owner/name#number`, not by kind: one issue is one card whether the -// row arrived as an issue or as the pull request that closes it, which is what lets step 3 hang a -// FlyBot record under the issue it belongs to. A task keeps its own id under `task:`. Nothing here -// fetches, writes or reads a clock. - -export const CARD_SOURCES = ['github', 'task'] - -export function githubCardKey(repo, number) { - const name = typeof repo === 'string' ? repo.trim() : '' - const value = Number(number) - if (name === '' || !Number.isInteger(value) || value <= 0) return null - return `${name}#${value}` -} +// Task-card identity for the per-Bot board. The key is `task:`, which the client's +// BoardCard puts on `data-key`. Nothing here fetches, writes or reads a clock. export function taskCardKey(id) { const text = String(id ?? '').trim() return text === '' ? null : `task:${text}` } -const repoOf = (repo) => (typeof repo === 'string' && repo.trim() !== '' ? repo.trim() : null) - -// A delivery-board row (`cardOf` in src/host/delivery-board.js) as a card. Without a repository -// there is no cross-repository identity, so the key falls back to the row's kind and number -// instead of implying one. -export function githubCard(row, repo) { - const number = Number(row?.number) - if (!Number.isInteger(number) || number <= 0) return null - const name = repoOf(repo) - return { - source: 'github', - key: githubCardKey(name, number) ?? `github:${row.kind ?? 'item'}:${number}`, - repo: name, - number, - kind: row.kind ?? null, - title: String(row.title ?? ''), - badge: row.badge ?? null, - labels: Array.isArray(row.labels) ? row.labels.slice() : [], - owner: row.owner ?? null, - ownerKind: row.ownerKind ?? null, - linked: row.linked ?? null, - sha: row.sha ?? null, - url: row.url ?? null, - } -} - -// A task record (`src/host/tasks.js`) as a card. The record's own fields stay on the record: the -// card carries what it draws, and the view reads the rest from the task it was built from. +// A task record (`src/host/tasks.js`) as a card. The record's own fields stay on the record: +// the card carries what it draws, and the view reads the rest from the task it was built from. export function taskCard(task) { const key = taskCardKey(task?.id) if (key === null) return null @@ -56,22 +15,6 @@ export function taskCard(task) { source: 'task', key, id: String(task.id), - repo: repoOf(task.repo), - number: null, - kind: 'task', title: String(task.title ?? ''), - badge: null, - labels: [], - owner: null, - ownerKind: null, - linked: null, - sha: null, - url: null, } } - -// Two cards are the same card when they answer the same key, which is the identity step 3 hangs a -// task record on. Kept here so both sides dedupe the same way. -export function sameCard(a, b) { - return Boolean(a?.key) && a.key === b?.key -} diff --git a/patches/ds-bot/files/src/shared/workflow-board.js b/patches/ds-bot/files/src/shared/workflow-board.js deleted file mode 100644 index 1ae1ec10a..000000000 --- a/patches/ds-bot/files/src/shared/workflow-board.js +++ /dev/null @@ -1,87 +0,0 @@ -// 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/test/board-card.test.js b/patches/ds-bot/files/test/board-card.test.js index c997ffd29..d8c2ddd45 100644 --- a/patches/ds-bot/files/test/board-card.test.js +++ b/patches/ds-bot/files/test/board-card.test.js @@ -1,12 +1,11 @@ -// One card behind two boards (#1265, step 1): the identity a card carries, and the one component -// that draws a GitHub card and a task card. The view tests next door prove each board emits it; -// this file proves the identity rules and the component itself, without React. +// Task-card identity, and the one component that draws a task card. The view test next door +// proves the board emits it; this file proves the identity rules and the component, without React. 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' -import { CARD_SOURCES, githubCard, githubCardKey, sameCard, taskCard, taskCardKey } from '../src/shared/board-card.js' +import { taskCard, taskCardKey } from '../src/shared/board-card.js' const bundle = await build({ entryPoints: [fileURLToPath(new URL('../src/client/board-card.js', import.meta.url))], @@ -24,69 +23,35 @@ runInNewContext(bundle.outputFiles[0].text, { return platform[name] }, }) -const { BoardCard, DELIVERY_SKIN, TASK_SKIN } = module.exports +const { BoardCard, TASK_SKIN } = module.exports const all = (node, out = []) => { if (Array.isArray(node)) { for (const item of node) all(item, out); return out } - if (node && typeof node === 'object') { out.push(node); all(node.children, out) } + if (node === null || typeof node !== 'object') return out + out.push(node) + all(node.children, out) return out } const textOf = node => (typeof node === 'string' ? node : node && typeof node === 'object' ? node.children.map(textOf).join('') : '') -const row = (number, extra) => ({ number, kind: 'pr', title: `Row ${number}`, labels: ['area:ui'], owner: 'Dev4', ownerKind: 'bot', badge: 'pass-enqueue', sha: 'abc1234', url: 'https://example.invalid/1', ...extra }) - -test('a repository card is keyed by repository and number, so an issue and its pull request are one card', () => { - assert.equal(githubCardKey('o/r', 12), 'o/r#12') - assert.equal(githubCardKey(' o/r ', '12'), 'o/r#12') - assert.equal(githubCardKey('', 12), null) - assert.equal(githubCardKey('o/r', 0), null) - assert.equal(githubCardKey('o/r', 'x'), null) - const issue = githubCard(row(12, { kind: 'issue' }), 'o/r') - const pull = githubCard(row(12, { kind: 'pr' }), 'o/r') - assert.equal(issue.key, pull.key) - assert.equal(sameCard(issue, pull), true) - assert.equal(issue.source, 'github') - assert.deepEqual(CARD_SOURCES, ['github', 'task']) -}) - -test('two repositories with the same number are two cards, and a row without one says so', () => { - assert.notEqual(githubCardKey('o/a', 7), githubCardKey('o/b', 7)) - assert.equal(githubCard(row(7), 'o/a').repo, 'o/a') - // No repository: no cross-repository identity is implied, so the key names the kind instead. - assert.equal(githubCard(row(7), null).key, 'github:pr:7') - assert.equal(githubCard({ number: 0 }, 'o/r'), null) -}) - -test('a task card keeps its own id, and the two sources never collide', () => { +test('a task card keeps its own id', () => { assert.equal(taskCardKey('task-abc'), 'task:task-abc') assert.equal(taskCardKey(' '), null) assert.equal(taskCard(null), null) const card = taskCard({ id: 'task-abc', title: 'Ship it', note: 'halfway', ownerBotName: 'Dev4' }) assert.equal(card.source, 'task') assert.equal(card.key, 'task:task-abc') + assert.equal(card.id, 'task-abc') assert.equal(card.title, 'Ship it') - assert.equal(card.owner, null) - assert.equal(sameCard(card, githubCard(row(7), 'task:task-abc')), false) - assert.equal(sameCard(card, { key: 'task:task-abc' }), true) }) -test('one component draws both sources, each with the skin and identity of its board', () => { - const repository = githubCard(row(12, { linked: 13 }), 'o/r') +test('the task card draws with the task skin and its identity', () => { const task = taskCard({ id: 'task-abc', title: 'Ship it' }) - const repositoryCard = BoardCard({ card: repository, skin: DELIVERY_SKIN, idText: '#12', badge: 'PASS · waiting for enqueue' }) - const taskCardNode = BoardCard({ card: task, skin: TASK_SKIN, idText: '#abc' }) - - assert.equal(repositoryCard.props.className, 'bt-del-card') - assert.equal(repositoryCard.props['data-source'], 'github') - assert.equal(repositoryCard.props['data-key'], 'o/r#12') - assert.match(textOf(repositoryCard), /PASS · waiting for enqueue/) - assert.match(textOf(repositoryCard), /linked to/) - - assert.equal(taskCardNode.props.className, 'bt-board-card') - assert.equal(taskCardNode.props['data-source'], 'task') - assert.equal(taskCardNode.props['data-key'], 'task:task-abc') - assert.match(textOf(taskCardNode), /Ship it/) - - // The badge slot is only drawn when a board hands one in, so a task card keeps its own top row. - assert.equal(all(taskCardNode).some(node => node.props?.className === TASK_SKIN.badge), false) + const node = BoardCard({ card: task, skin: TASK_SKIN, idText: '#abc', note: 'halfway' }) + assert.equal(node.props.className, 'bt-board-card') + assert.equal(node.props['data-source'], 'task') + assert.equal(node.props['data-key'], 'task:task-abc') + assert.match(textOf(node), /Ship it/) + assert.match(textOf(node), /halfway/) + assert.equal(all(node).some(item => item.props?.className === TASK_SKIN.badge), false) }) diff --git a/patches/ds-bot/files/test/delivery-board.test.js b/patches/ds-bot/files/test/delivery-board.test.js deleted file mode 100644 index 929c4fa2a..000000000 --- a/patches/ds-bot/files/test/delivery-board.test.js +++ /dev/null @@ -1,1112 +0,0 @@ -// The delivery board (#1173) is a read-only view of GitHub: five columns, one cached snapshot, -// metrics with an Asia/Shanghai day boundary, and no write request anywhere. -// -// #1211 added the failure half: a round answers `loading` at once and runs in the background, a -// part that cannot be read is named in `missing` instead of emptying the board, an unreadable -// number is never printed as zero, and every failure carries the endpoint, the status and the kind -// so the view can tell "no token" from "no permission" from "too slow". -import assert from 'node:assert/strict' -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 { 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' - -const NOW = Date.parse('2026-10-25T04:00:00Z') // 12:00 in Asia/Shanghai -const AT = (iso) => Date.parse(iso) - -// A response whose headers can be asked by name, the way `fetch` answers (#1211). -const respond = (body, status = 200, headerMap = {}) => ({ - ok: status < 400, - status, - headers: { get: name => headerMap[String(name).toLowerCase()] ?? (status < 400 ? 'etag-1' : null) }, - json: async () => body, -}) - -function fakeGitHub({ comments = {}, checks = {}, closedExtra = [], mergedExtra = [], openPullsExtra = [], failures = {}, hang = false, runsTruncated = false, listsCapped = false, listsStale = false, listsStopLate = false, slug = 'o/r' } = {}) { - const calls = [] - const request = async (url, init = {}) => { - const parsed = new URL(url) - const path = parsed.pathname.replace(`/repos/${slug}`, '') || '/' - const page = Number(parsed.searchParams.get('page') ?? 1) - calls.push({ path, url, method: init.method, headers: init.headers ?? {}, query: parsed.search }) - if (init.method !== 'GET') throw new Error(`the delivery board must not send ${init.method}`) - if (hang) return new Promise(() => {}) - for (const [key, failure] of Object.entries(failures)) { - if (path.startsWith(key) || key === '*') { - if (failure === 'timeout') throw Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' }) - return respond(failure.body ?? { message: 'failure' }, failure.status, failure.headers ?? {}) - } - } - if (path === '/') return respond({ full_name: 'o/r', default_branch: 'main' }) - if (path === '/commits' || path.startsWith('/commits?')) return respond([{ sha: 'aaaaaaa1' }]) - if (path === '/issues' && parsed.searchParams.get('state') === 'open') { - return respond(page === 1 ? [ - { number: 10, title: 'Untriaged', html_url: 'https://x/10', labels: [{ name: 'bug' }], created_at: '2026-10-20T00:00:00Z', updated_at: '2026-10-24T00:00:00Z' }, - { number: 11, title: 'Ready', html_url: 'https://x/11', labels: [{ name: 'agent-ready' }, { name: 'enhancement' }], created_at: '2026-10-21T00:00:00Z', updated_at: '2026-10-23T00:00:00Z' }, - { number: 12, title: 'Working', html_url: 'https://x/12', labels: [{ name: 'agent-working' }, { name: 'agent-ready' }], created_at: '2026-10-19T00:00:00Z', updated_at: '2026-10-22T00:00:00Z' }, - { number: 13, title: 'Needs a human', html_url: 'https://x/13', labels: [{ name: 'needs-info' }], created_at: '2026-09-01T00:00:00Z', updated_at: '2026-10-01T00:00:00Z' }, - ] : []) - } - // The two list calls that feed the counts (#1222), when the page cap is what ends them: - // `listsCapped` answers four full pages of rows dated inside the week the board asks for, so a - // window's walk only stops at the cap; `listsStale` answers full pages of rows older than every - // window, so the walk ends on its first page and nothing was in fact left unread. - // `listsStopLate` is the same full walk with the `stop` row on page four: four full pages, and - // the only old row is the last one, so the walk ends because the window was read whole, not - // because it ran out of pages. - if ((path === '/issues' || path === '/pulls') && parsed.searchParams.get('state') === 'all' && listsStopLate) { - return respond(Array.from({ length: 100 }, (_, index) => ({ - number: 700 + page * 100 + index, - title: 'a row on a full page', - created_at: page === 4 && index === 99 ? '2026-10-01T00:00:00Z' : '2026-10-20T00:00:00Z', - closed_at: null, - ...(path === '/pulls' ? { merged_at: page === 4 && index === 99 ? '2026-10-01T00:00:00Z' : '2026-10-20T01:00:00Z' } : {}), - }))) - } - if ((path === '/issues' || path === '/pulls') && parsed.searchParams.get('state') === 'all' && (listsCapped || listsStale)) { - const created = listsStale ? '2026-10-01T00:00:00Z' : '2026-10-20T00:00:00Z' - return respond(Array.from({ length: 100 }, (_, index) => ({ - number: 900 + page * 100 + index, - title: 'a row on a full page', - created_at: created, - closed_at: null, - ...(path === '/pulls' ? { merged_at: listsStale ? '2026-10-01T01:00:00Z' : '2026-10-20T01:00:00Z' } : {}), - }))) - } - if (path === '/pulls' && (parsed.searchParams.get('state') ?? 'open') === 'open') { - return respond(page === 1 ? [ - { number: 40, title: 'Pending verdict', html_url: 'https://x/40', body: 'Owner: Dev', head: { sha: 'aaaaaaa1' }, mergeable_state: 'unstable', created_at: '2026-10-24T00:00:00Z', updated_at: '2026-10-24T10:00:00Z', labels: [] }, - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', body: 'Fixes #12', head: { sha: 'bbbbbbb2' }, mergeable_state: 'clean', created_at: '2026-10-23T00:00:00Z', updated_at: '2026-10-24T09:00:00Z', labels: [{ name: 'agent-pr' }] }, - ...openPullsExtra, - ] : []) - } - if (path === '/pulls' && parsed.searchParams.get('state') === 'all') { - return respond(page === 1 ? [ - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z' }, - { number: 42, title: 'Older merge', html_url: 'https://x/42', created_at: '2026-10-22T00:00:00Z', merged_at: '2026-10-24T02:00:00Z', updated_at: '2026-10-24T02:00:00Z' }, - ] : []) - } - if (path === '/pulls' && parsed.searchParams.get('state') === 'closed') { - return respond(page === 1 ? [ - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z', merge_commit_sha: 'ccccccc3' }, - ...mergedExtra, - ] : []) - } - if (path === '/issues' && parsed.searchParams.get('state') === 'all') { - return respond(page === 1 ? [ - { number: 10, title: 'Untriaged', created_at: '2026-10-20T00:00:00Z', closed_at: null, state_reason: null }, - { number: 11, title: 'Ready', created_at: '2026-10-21T00:00:00Z', closed_at: null, state_reason: null }, - { number: 12, title: 'Working', created_at: '2026-10-19T00:00:00Z', closed_at: null, state_reason: null }, - { number: 41, title: 'a PR, not an issue', pull_request: { url: 'x' }, created_at: '2026-10-23T00:00:00Z' }, - { number: 14, title: 'Closed today', created_at: '2026-10-20T00:00:00Z', closed_at: '2026-10-25T01:00:00Z', state_reason: 'completed' }, - { number: 15, title: 'Closed yesterday', created_at: '2026-10-21T00:00:00Z', closed_at: '2026-10-24T01:00:00Z', state_reason: 'not_planned' }, - ] : []) - } - if (path === '/issues' && parsed.searchParams.get('state') === 'closed') { - return respond(page === 1 ? [ - { number: 14, title: 'Closed today', html_url: 'https://x/14', created_at: '2026-10-20T00:00:00Z', closed_at: '2026-10-25T01:00:00Z', state_reason: 'completed', updated_at: '2026-10-25T01:00:00Z', labels: [] }, - // A merged pull as /issues?state=closed really returns it: the merge time lives on the - // pull_request object, there is no top-level merged_at and no head (#1173). - { number: 41, title: 'Fix the thing', html_url: 'https://x/41', pull_request: { url: 'x', html_url: 'https://x/41', diff_url: 'https://x/41.diff', patch_url: 'https://x/41.patch', merged_at: '2026-10-25T02:00:00Z' }, created_at: '2026-10-23T00:00:00Z', closed_at: '2026-10-25T02:00:00Z', updated_at: '2026-10-25T02:00:00Z', labels: [] }, - ...closedExtra, - ] : []) - } - if (path.startsWith('/actions/runs')) { - // A day whose runs outnumber the page cap: every page is full, so the tail is never read. - if (runsTruncated) { - return respond({ total_count: 1000, workflow_runs: Array.from({ length: 100 }, () => ({ conclusion: 'success' })) }) - } - return respond({ - total_count: 3, - workflow_runs: [ - { conclusion: 'failure' }, { conclusion: 'cancelled' }, { conclusion: 'success' }, - ], - }) - } - if (/^\/issues\/\d+\/comments$/.test(path)) { - const number = Number(path.split('/')[2]) - const value = comments[number] ?? [] - // A thread longer than one page is passed as pages; a flat array is page one (#1189). - const pagesOf = Array.isArray(value[0]) ? value : [value] - return respond(pagesOf[page - 1] ?? []) - } - if (/^\/commits\/.+\/check-runs$/.test(path)) { - const sha = path.split('/')[2] - return respond({ check_runs: checks[sha] ?? [] }) - } - return respond({ message: 'not found' }, 404) - } - return { request, calls } -} - -const build = (options = {}, boardOptions = {}) => { - const github = fakeGitHub(options) - const board = createDeliveryBoard({ repo: 'o/r', token: 'SECRET-TOKEN', request: github.request, now: () => NOW, ...boardOptions }) - return { board, github } -} -// The browser never waits; tests do, so one round is settled before its result is read. -const settle = (board, options = {}) => board.read({ wait: true, ...options }) - -test('the delivery board is read-only and the payload carries no credential (#1173)', async () => { - const { board, github } = build() - const snapshot = await settle(board) - assert.ok(github.calls.length > 0) - assert.deepEqual([...new Set(github.calls.map(call => call.method))], ['GET']) - for (const call of github.calls) assert.match(call.headers.Authorization ?? '', /^Bearer SECRET-TOKEN$/) - assert.doesNotMatch(JSON.stringify(snapshot), /SECRET-TOKEN|Authorization/) -}) - -test('the first read answers loading and does not wait for the round (#1211)', async () => { - const github = fakeGitHub({ hang: true }) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => NOW }) - // The request never settles; the read must still answer, and it must say what it is doing. - const first = await board.read() - assert.equal(first.state, 'loading') - assert.equal(first.ok, true) - assert.equal(first.attempt, 1) - assert.equal(typeof first.since, 'string') - assert.equal(first.elapsedMs, 0) - assert.equal(first.columns, undefined, 'a loading answer carries no half-built board') -}) - -test('a 404 is described, not reported as "no GitHub connection" (#1211)', async () => { - const { board } = build({ failures: { '/issues': { status: 404 } } }) - const result = await settle(board) - assert.equal(result.state, 'failed') - assert.equal(result.ok, true) - assert.equal(result.error.status, 404) - assert.equal(result.error.kind, 'not-found') - assert.equal(result.error.scope, 'issues') - assert.match(result.error.message, /GitHub answered 404 for \/repos\/o\/r\/issues/) - assert.equal(typeof result.error.at, 'string') -}) - -test('a timeout keeps the endpoint and the kind, and is not a 404 (#1211)', async () => { - const { board } = build({ failures: { '/pulls': 'timeout' } }) - const result = await settle(board) - assert.equal(result.state, 'failed') - assert.equal(result.error.kind, 'timeout') - assert.equal(result.error.status, null) - assert.equal(result.error.scope, 'pulls') - assert.match(result.error.message, /did not answer within 20 seconds for \/repos\/o\/r\/pulls/) -}) - -test('a 403 rate limit carries only the wait GitHub gave (#1211)', async () => { - const limited = build({ failures: { '/actions/runs': { status: 403, headers: { 'retry-after': '30' } } } }) - const result = await settle(limited.board) - assert.equal(result.state, 'partial') - assert.equal(result.error, null, 'a named part failing is a partial board, not a failed one') - const entry = result.missing.find(item => item.part === 'ci') - assert.equal(entry.kind, 'rate-limit') - assert.equal(entry.retryAfterMs, 30_000) - assert.match(entry.message, /retry after 30 s/) - - // No header, no number: the view must not invent a wait it cannot keep. - const silent = build({ failures: { '/actions/runs': { status: 403 } } }) - const quiet = await settle(silent.board) - assert.equal(quiet.missing.find(item => item.part === 'ci').kind, 'forbidden') - assert.doesNotMatch(quiet.missing.find(item => item.part === 'ci').message, /retry after/) - - const raw = describeResponse(respond({}, 403, { 'x-ratelimit-remaining': '0' }), '/repos/o/r/issues', { now: () => NOW }) - assert.equal(raw.kind, 'rate-limit') - assert.equal(raw.retryAfterMs, null) - assert.doesNotMatch(raw.message, /retry after/) -}) - -test('an unreadable /actions/runs leaves the board, and the CI number unknown not zero (#1211)', async () => { - const { board } = build({ failures: { '/actions/runs': { status: 404 } } }) - const result = await settle(board) - assert.equal(result.state, 'partial') - assert.equal(result.partial, true) - assert.equal(result.ok, true) - // The board is still there: one failed request empties nothing. - // The column keeps its cards and the two open pull requests; only CI is missing. - assert.deepEqual(result.columns['in-progress'].map(card => card.number), [12, 40, 41]) - assert.deepEqual(result.counts['in-progress'], 3) - // Unknown is null, never 0 — and the reason is named. - assert.equal(result.metrics.today.ci, null) - assert.equal(result.metrics.week.ci, null) - const entry = result.missing.find(item => item.part === 'ci') - assert.equal(entry.status, 404) - assert.equal(entry.scope, 'actions') -}) - -test('unreadable check-runs read as unknown, not as "no verdict" (#1211)', async () => { - const { board } = build({ failures: { '/commits/bbbbbbb2/check-runs': { status: 404 } } }) - const result = await settle(board) - const card = result.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'checks-unknown') - assert.equal(result.state, 'partial') - const entry = result.missing.find(item => item.part === 'checks:41') - assert.equal(entry.scope, 'checks') -}) - -test('unreadable comments cost that card its owner, not the board (#1211)', async () => { - const { board } = build({ failures: { '/issues/12/comments': { status: 404 } } }) - const result = await settle(board) - const card = result.columns['in-progress'].find(item => item.number === 12) - assert.equal(card.owner, null) - assert.equal(result.state, 'partial') - assert.ok(result.missing.some(item => item.part === 'comments:12')) -}) - -test('a failed refresh keeps the old snapshot and its reason (#1211)', async () => { - let clock = NOW - let failing = false - const github = fakeGitHub() - const broken = fakeGitHub({ failures: { '*': { status: 404 } } }) - const request = async (url, init) => (failing ? broken.request : github.request)(url, init) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => clock, ttl: 60_000 }) - const first = await settle(board) - assert.equal(first.state, 'fresh') - failing = true - clock += 120_000 - const stale = await settle(board, { force: true }) - assert.equal(stale.state, 'stale') - assert.equal(stale.error.status, 404) - assert.equal(stale.asOf, first.asOf, 'the old snapshot is still served') - assert.equal(stale.columns['in-progress'].length, 3) -}) - -test('after a failure the next round waits 10 s, then 30 s, then two minutes (#1211)', async () => { - let clock = NOW - const github = fakeGitHub({ failures: { '*': { status: 500 } } }) - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => clock, ttl: 0 }) - const first = await settle(board) - assert.equal(first.state, 'failed') - assert.equal(first.retryAt, NOW + BACKOFF_MS[0]) - - const afterFirst = github.calls.length - clock += BACKOFF_MS[0] - 1 - const tooEarly = await settle(board) - assert.equal(tooEarly.state, 'failed') - assert.equal(github.calls.length, afterFirst, 'inside the backoff no request is made') - - clock += 1 - await settle(board) - const afterSecond = github.calls.length - assert.ok(afterSecond > afterFirst, 'the backoff expired, so the round ran') - clock += BACKOFF_MS[0] - await settle(board) - assert.equal(github.calls.length, afterSecond, 'the second failure backs off for 30 s, not 10') - - // A person pressing retry skips the wait on purpose. - clock += 1 - await settle(board, { force: true }) - assert.ok(github.calls.length > afterSecond) -}) - -test('a round past its budget lands as partial, not as a failure (#1211)', async () => { - let clock = NOW - const github = fakeGitHub() - const request = async (url, init) => { clock += 60_000; return github.request(url, init) } - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => clock, budgetMs: 100 }) - const result = await board.read({ wait: true }) - assert.equal(result.state, 'partial') - assert.equal(result.ok, true) - assert.equal(result.partial, true) - assert.ok(Array.isArray(result.columns['not-started'])) - assert.equal(BUILD_BUDGET_MS, 90_000) -}) - -test('a workflow day truncated at the page cap is unknown, not zero failures (#1211)', async () => { - const github = fakeGitHub() - const fullPage = { total_count: 1000, workflow_runs: Array.from({ length: 100 }, () => ({ conclusion: 'success' })) } - const request = async (url, init) => { - const parsed = new URL(url) - if (parsed.pathname.endsWith('/actions/runs')) return respond(fullPage) - return github.request(url, init) - } - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request, now: () => NOW }) - const result = await board.read({ wait: true }) - assert.equal(result.metrics.today.ci.unknownDays.length, 1) - assert.equal(result.metrics.today.ci.failed, 0) - assert.equal(result.metrics.today.ci.counted, 0, 'a truncated day is not counted as if it were read') - assert.ok(result.missing.some(item => item.kind === 'truncated')) -}) - -test('the probe asks the five endpoints and says which one is refused (#1211)', async () => { - const github = fakeGitHub({ failures: { '/actions/runs': { status: 404 } } }) - const report = await probeGitHub({ repo: 'o/r', token: 'SECRET-TOKEN', request: github.request, now: () => NOW }) - assert.deepEqual(report.rows.map(row => row.key), ['repo', 'actions', 'checks', 'comments', 'pulls']) - assert.equal(report.ok, false) - const actions = report.rows.find(row => row.key === 'actions') - assert.equal(actions.status, 404) - assert.equal(actions.scope, 'actions') - assert.equal(actions.ok, false) - for (const key of ['repo', 'pulls']) assert.equal(report.rows.find(row => row.key === key).ok, true) - // The checks read is asked at a real sha taken from the pull list, never `//`. - assert.equal(report.rows.find(row => row.key === 'checks').endpoint, '/repos/o/r/commits/aaaaaaa1/check-runs') -}) - -test('the --probe CLI is a shell over probeGitHub, not a second implementation (#1211)', () => { - const source = readFileSync(new URL('../scripts/delivery-probe.mjs', import.meta.url), 'utf8') - assert.match(source, /import \{ probeGitHub \} from '\.\.\/src\/host\/delivery-board\.js'/) - assert.doesNotMatch(source, /api\.github\.com/, 'the CLI makes no request of its own') -}) - -test('three workflow columns from one snapshot, an issue and its PR linked (#1173, #1326)', async () => { - const { board } = build() - const snapshot = await settle(board) - assert.deepEqual(Object.keys(snapshot.columns), DELIVERY_COLUMNS) - // #1326: Three workflow columns. Issues map to not-started, PRs to in-progress, done-today in done. - assert.deepEqual(snapshot.columns['not-started'].map(card => card.number).sort((a, b) => a - b), [10, 11, 13]) - assert.deepEqual(snapshot.columns['in-progress'].map(card => card.number).sort((a, b) => a - b), [12, 40, 41]) - assert.deepEqual(snapshot.columns.done.filter(c => c.stage === 'done-today').map(card => card.number).sort((a, b) => a - b), [14, 41]) - const progress = snapshot.columns['in-progress'].find(c => c.number === 12) - assert.equal(progress.linked, 41) - assert.equal(progress.owner, null) - const notStarted = snapshot.columns['not-started'].find(c => c.number === 11) - assert.deepEqual(notStarted.labels, ['agent-ready', 'enhancement']) - assert.deepEqual(snapshot.counts, { 'not-started': 3, 'in-progress': 3, done: 2 }) -}) - -test('only a closing keyword links a pull request to an issue (#1189)', async () => { - const { board } = build({ - openPullsExtra: [ - { number: 43, title: 'Related work', html_url: 'https://x/43', body: 'also see #10, same as #12', head: { sha: 'ddddddd4' }, mergeable_state: 'clean', created_at: '2026-10-22T00:00:00Z', updated_at: '2026-10-24T08:00:00Z', labels: [] }, - { number: 44, title: 'Close it', html_url: 'https://x/44', body: 'Closes: #13', head: { sha: 'eeeeeee5' }, mergeable_state: 'clean', created_at: '2026-10-22T00:00:00Z', updated_at: '2026-10-24T08:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const card = (column, number) => snapshot.columns[column].find(item => item.number === number) - // "also see #10, same as #12" is prose: #10 links to nothing, and #12 keeps the pull request - // that closes it (41), not the one that only mentions it. - assert.equal(card('not-started', 10).linked, null) - assert.equal(card('in-progress', 12).linked, 41) - // A closing keyword with a colon counts; nothing else does. - assert.equal(card('not-started', 13).linked, 44) -}) - -test('a pull request closed today without a merge is done today, not nowhere (#1173)', async () => { - const { board } = build({ - closedExtra: [ - // The shape /issues?state=closed gives a closed pull: no top-level merged_at, no head. - { number: 52, title: 'Closed, never merged', html_url: 'https://x/52', pull_request: { url: 'x', html_url: 'https://x/52', diff_url: 'https://x/52.diff', patch_url: 'https://x/52.patch', merged_at: null }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-25T03:00:00Z', updated_at: '2026-10-25T03:00:00Z', labels: [] }, - { number: 53, title: 'Closed yesterday, never merged', html_url: 'https://x/53', pull_request: { url: 'x', html_url: 'https://x/53', diff_url: 'https://x/53.diff', patch_url: 'https://x/53.patch', merged_at: null }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-24T03:00:00Z', updated_at: '2026-10-24T03:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const done = snapshot.columns.done - // Acceptance 2.5: today's merges plus today's closures, and a closed pull request counts even - // though it never merged. Yesterday's closure stays off the board. - assert.equal(done.filter(card => card.number === 41).length, 1) - assert.equal(done.filter(card => card.number === 52).length, 1) - assert.equal(done.filter(card => card.number === 14).length, 1) - const closed = done.find(card => card.number === 52) - assert.equal(closed.kind, 'pr') - // /issues?state=closed returns no head for a pull, so this card carries no merge commit. - assert.equal(closed.sha, null) - assert.equal(done.some(card => card.number === 53), false) - assert.equal(snapshot.counts.done, done.length) -}) - -test('a pull merged today is done once although the closed page lists it too (#1173)', async () => { - const { board } = build({ - mergedExtra: [ - { number: 44, title: 'Merged today', html_url: 'https://x/44', created_at: '2026-10-23T00:00:00Z', merged_at: '2026-10-25T02:30:00Z', updated_at: '2026-10-25T02:30:00Z', merge_commit_sha: 'fffffff6' }, - ], - // The closed page returns the same pull, and a row there can reach us without a merge time on - // its pull_request object, so reading that object alone would count it twice. A pull closed - // without merging sits on that page too and must still reach the board. - closedExtra: [ - { number: 44, title: 'Merged today', html_url: 'https://x/44', pull_request: { url: 'x' }, created_at: '2026-10-23T00:00:00Z', closed_at: '2026-10-25T02:30:00Z', updated_at: '2026-10-25T02:30:00Z', labels: [] }, - { number: 52, title: 'Closed, never merged', html_url: 'https://x/52', pull_request: { url: 'x' }, created_at: '2026-10-22T00:00:00Z', closed_at: '2026-10-25T03:00:00Z', updated_at: '2026-10-25T03:00:00Z', labels: [] }, - ], - }) - const snapshot = await settle(board) - const done = snapshot.columns.done - for (const number of [41, 44, 14, 52]) assert.equal(done.filter(card => card.number === number).length, 1, `#${number} is done today once`) - assert.equal(done.some(card => card.number === 15), false) - assert.equal(snapshot.counts.done, done.length) -}) - -test('a verdict with no Attestation still counts at the sha it names (#1173)', async () => { - const { board } = build({ - comments: { 41: [{ body: 'Verdict: PASS at bbbbbbb2' }] }, - checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'pass-enqueue') -}) - -test('a verdict line wins over an Attestation that names an older sha (#1173)', async () => { - // Two markers, two tips: the Attestation points at #40's sha, the Verdict line at this PR's. - // Reading Attestation first would hide a verdict that names the current tip (#1173 INC). - const { board } = build({ - comments: { 41: [{ body: 'Verdict: PASS at bbbbbbb2\nAttestation: aaaaaaa1 role=Reviewer' }] }, - checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'pass-enqueue') -}) - -test('a verdict on a later page of comments still decides the badge (#1189)', async () => { - // 120 comments with the newest verdict at index 101: on a board that reads one page it never - // appears, and the card says "no verdict at this tip" while the tip has one. - const firstPage = Array.from({ length: 100 }, (_, index) => ({ body: `note ${index}` })) - const secondPage = [ - { body: 'note 100' }, - { body: 'Verdict: INC at bbbbbbb2' }, - ...Array.from({ length: 18 }, (_, index) => ({ body: `note ${102 + index}` })), - ] - const { board, github } = build({ - comments: { 41: [firstPage, secondPage] }, - checks: { bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const card = snapshot.columns['in-progress'].find(item => item.number === 41) - assert.equal(card.badge, 'inc') - assert.ok(github.calls.some(call => call.path === '/issues/41/comments' && /[?&]page=2(&|$)/.test(call.query))) -}) - -test('the token is read per request, and install loads the secrets before the round (#1173)', async () => { - // A token captured at install time is empty until the host loads secrets; every request must - // resolve it again, and the first read must ask the host to load them (#1173 INC). - const direct = fakeGitHub() - let secret - const board = createDeliveryBoard({ repo: 'o/r', token: () => secret, request: direct.request, now: () => NOW }) - await settle(board) - assert.ok(direct.calls.every(call => call.headers.Authorization === undefined)) - secret = 'SECRET-TOKEN' - await settle(board, { force: true }) - assert.ok(direct.calls.slice(-10).some(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - - const github = fakeGitHub() - let loads = 0 - let loaded - const rt = { - config: { deliveryRepo: 'o/r' }, - // The secrets file is read asynchronously, so at install time there is nothing to read yet. - secretValue: name => (name === 'GITHUB_TOKEN' ? loaded : undefined), - loadSecrets: async () => { loads += 1; loaded = 'SECRET-TOKEN' }, - fetch: github.request, - warn: () => {}, - } - const installed = Object.assign({}, rt) - install(installed) - // The first answer is loading (#1211): the round runs behind it, and the secrets were loaded - // before any request went out. - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, 'o/r') - assert.equal(loading.repoSource, 'config') - assert.equal(loads, 1) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.ok, true) - assert.ok(github.calls.length > 0) - assert.ok(github.calls.every(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - // The 诊断 entry runs the same probe, through the host's own token. - const probe = await installed.deliveryProbe() - assert.ok(Array.isArray(probe.rows)) - assert.ok(probe.rows.every(row => row.ok)) - assert.equal(probe.repo, 'o/r') - assert.equal(probe.repoSource, 'config') -}) - -test('a PR badge reads the verdict at its own tip, and CI when there is none (#1173)', async () => { - const { board } = build({ - comments: { - 40: [{ body: 'Verdict: PASS at deadbee\nAttestation: deadbee role=Reviewer2' }], - 41: [{ body: 'Verdict: PASS at bbbbbbb\nAttestation: bbbbbbb2 role=Reviewer' }], - }, - checks: { aaaaaaa1: [{ status: 'in_progress', conclusion: null }], bbbbbbb2: [{ status: 'completed', conclusion: 'success' }] }, - }) - const snapshot = await settle(board) - const badges = Object.fromEntries(snapshot.columns['in-progress'].map(card => [card.number, card.badge])) - // Verdict names an older sha: it does not count at this tip, so CI speaks. - assert.equal(badges[40], 'ci-running') - assert.equal(badges[41], 'pass-enqueue') -}) - -test('metrics use the Shanghai day boundary, medians, and the CI counted/total caveat (#1173)', async () => { - assert.equal(startOfDay(NOW), AT('2026-10-24T16:00:00Z')) - assert.equal(median([5, 1, 3]), 3) - assert.equal(median([4, 1, 3, 2]), 2.5) - const { board } = build() - const snapshot = await settle(board) - const { today, week, open } = snapshot.metrics - assert.equal(today.newIssues, 0) - assert.equal(today.closedIssues, 1) - assert.equal(today.closedReasons.completed, 1) - assert.equal(today.closedReasons.notPlanned, 0) - assert.equal(today.mergedPullRequests, 1) - assert.equal(today.medianPullMergeMs, AT('2026-10-25T02:00:00Z') - AT('2026-10-23T00:00:00Z')) - assert.deepEqual(today.ci, { failed: 1, superseded: 1, counted: 3, total: 3, unknownDays: [] }) - assert.equal(week.newIssues, 5) - assert.equal(week.closedIssues, 1) - assert.equal(week.closedReasons.notPlanned, 1) - assert.equal(open.issues, 4) - assert.equal(open.pullRequests, 2) - assert.equal(open.needsHuman, 1) - assert.equal(open.medianIssueAgeMs, median([ - NOW - AT('2026-10-20T00:00:00Z'), NOW - AT('2026-10-21T00:00:00Z'), - NOW - AT('2026-10-19T00:00:00Z'), NOW - AT('2026-09-01T00:00:00Z'), - ])) -}) - -test('metrics.daily is seven Shanghai days ending today, and only today is partial (#1222)', async () => { - const { board, github } = build() - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - // Seven days, oldest first, the last one today: the window the chart draws is the same one the - // header's Today column sits at the end of. - assert.equal(daily.length, 7) - assert.deepEqual([...daily.map(day => day.date)], - ['2026-10-19', '2026-10-20', '2026-10-21', '2026-10-22', '2026-10-23', '2026-10-24', '2026-10-25']) - assert.equal(daily[6].partial, true) - assert.equal(daily.slice(0, 6).some(day => 'partial' in day), false, 'only today is a part of a day') - const byDate = Object.fromEntries(daily.map(day => [day.date, day])) - // A day nobody counted is 0, and 0 is a measurement: 10-22 opened and closed nothing. - assert.deepEqual({ ...byDate['2026-10-22'] }, { - date: '2026-10-22', - newIssues: 0, - closedIssues: 0, - newPullRequests: 1, - mergedPullRequests: 0, - ci: { failed: 1, counted: 2, total: 3 }, - }) - assert.equal(byDate['2026-10-19'].newIssues, 1) - assert.equal(byDate['2026-10-20'].newIssues, 2) - assert.equal(byDate['2026-10-21'].newIssues, 2) - assert.equal(byDate['2026-10-24'].closedIssues, 1) - assert.equal(byDate['2026-10-24'].mergedPullRequests, 1) - // Today is the same day the Today column counts, not a day behind it. - assert.equal(byDate['2026-10-25'].closedIssues, snapshot.metrics.today.closedIssues) - assert.equal(byDate['2026-10-25'].mergedPullRequests, snapshot.metrics.today.mergedPullRequests) - // A cancelled run never judged the code, so it is out of the denominator; the header keeps its - // own aggregate, superseded included. - assert.deepEqual(byDate['2026-10-25'].ci, { failed: 1, counted: 2, total: 3 }) - assert.deepEqual(snapshot.metrics.today.ci, { failed: 1, superseded: 1, counted: 3, total: 3, unknownDays: [] }) - // The series costs no request: it is built from the two windows the round already reads. - assert.ok(github.calls.every(call => call.headers.Authorization === 'Bearer SECRET-TOKEN')) - // The assembled series is the only thing the payload carries — no per-day buckets anywhere else. - assert.equal('daily' in snapshot.metrics.today, false) - assert.equal('daily' in snapshot.metrics.week, false) -}) - -test('the day boundary is Shanghai, not UTC: an evening in UTC is already tomorrow (#1222)', async () => { - // 2026-10-24T17:00Z is 2026-10-25 01:00 in Shanghai, so the newest day is the 25th. - const { board } = build({}, { now: () => AT('2026-10-24T17:00:00Z') }) - const snapshot = await settle(board) - assert.equal(snapshot.metrics.dayStart, '2026-10-24T16:00:00.000Z') - const daily = snapshot.metrics.daily - assert.equal(daily[6].date, '2026-10-25') - assert.equal(daily[6].partial, true) - assert.equal(daily[0].date, '2026-10-19') - assert.equal(daily.length, 7) -}) - -test('the seven-day CI share is read over the week its counts cover (#1222)', async () => { - // The week window is [2026-10-17T16:00Z, 2026-10-24T16:00Z). Walking backwards from its start - // read the seven days *before* it, so the CI row described a week the other numbers never saw. - const { board, github } = build() - await settle(board) - const ranges = github.calls - .filter(call => call.path.startsWith('/actions/runs')) - .map(call => new URLSearchParams(call.query).get('created')) - const starts = ranges.map(range => Date.parse(range.split('..')[0])) - assert.equal(starts.length, 8, 'seven days for the week, one for today') - assert.ok(starts.every(at => at >= AT('2026-10-17T16:00:00Z')), 'no window reaches back before the week it describes') - assert.equal(starts.filter(at => at === AT('2026-10-17T16:00:00Z')).length, 1, 'the first day of the week is read') - assert.equal(starts.filter(at => at === AT('2026-10-23T16:00:00Z')).length, 1, 'the week runs up to the day before today') - assert.equal(starts.filter(at => at === AT('2026-10-24T16:00:00Z')).length, 1, 'today is its own window') -}) - -test('a day whose runs were truncated is unknown, and unknown is not zero (#1222)', async () => { - const { board } = build({ runsTruncated: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - // The tail of every day was unread, so no day may claim a CI share — including a quiet-looking 0%. - assert.equal(daily.every(day => day.ci === null), true) - // The counts are still numbers: a CI read that failed does not erase them. - assert.equal(daily[0].newIssues, 1) - assert.ok(snapshot.missing.some(item => item.part === 'ci')) -}) - -test('a count built from a list that stopped at the page cap is unknown, not smaller (#1222)', async () => { - // Both lists answer four full pages, so the walk ends on the cap with a tail nobody read. The - // week's window is capped; today's own window stops on its first page because these rows are - // already older than it, so today keeps its measurements and the cap reaches only the six days - // whose list it truncated. - const { board, github } = build({ listsCapped: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - for (const day of daily.slice(0, 6)) { - assert.equal(day.newIssues, null, `${day.date} sits in a list that stopped at the page cap`) - assert.equal(day.closedIssues, null, `${day.date} has no issue count to show`) - assert.equal(day.newPullRequests, null, `${day.date} has no pull count to show`) - assert.equal(day.mergedPullRequests, null) - } - // Today read its own window whole: an unknown day never costs a day that was measured. - assert.equal(typeof daily[6].newIssues, 'number') - assert.equal(typeof daily[6].closedIssues, 'number') - assert.equal(typeof daily[6].newPullRequests, 'number') - assert.equal(typeof daily[6].mergedPullRequests, 'number') - // The header counts are built from the same lists but are not per-day: the cap nulls the series - // entry, never the number a reader compares across the week. - assert.equal(typeof snapshot.metrics.today.newIssues, 'number') - assert.equal(typeof snapshot.metrics.week.newIssues, 'number') - assert.equal(daily[6].newIssues, snapshot.metrics.today.newIssues) - const issueCalls = github.calls.filter(call => call.path === '/issues' && new URLSearchParams(call.query).get('state') === 'all') - assert.equal(issueCalls.filter(call => new URLSearchParams(call.query).get('page') === '4').length >= 1, true, 'the week really walked into the page cap') -}) - -test('a window that stopped early on an old row is complete, not unknown (#1222)', async () => { - // A full first page of rows older than the window ends the walk with nothing left unread. The - // page-count heuristic reads that as "the list had more to give"; it must not, or a quiet week - // would drift into permanent unknowns. - const { board, github } = build({ listsStale: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - // Nothing was opened or merged inside the seven days, and that is a measurement: 0, never null. - for (const day of daily) { - assert.equal(day.newIssues, 0, `${day.date} was read whole`) - assert.equal(day.closedIssues, 0) - assert.equal(day.newPullRequests, 0) - assert.equal(day.mergedPullRequests, 0) - } - // One page per window, not four: the walk stopped on the old row rather than at the cap. - const issueCalls = github.calls.filter(call => call.path === '/issues' && new URLSearchParams(call.query).get('state') === 'all') - assert.equal(issueCalls.length, 2, 'week plus today, one page each') - assert.equal(issueCalls.some(call => new URLSearchParams(call.query).get('page') === '2'), false) - assert.equal(snapshot.missing.some(item => item.part === 'issues' || item.part === 'pulls'), false) -}) - -test('a full page four that stopped on an old row is a complete window, not a capped one (#1240)', async () => { - // The page count cannot tell these apart: four full pages either hit the cap or ended because the - // stop row was on page four. Only the walk knows, so the flag has to come from it. - const { board } = build({ listsStopLate: true }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.length, 7) - const byDate = Object.fromEntries(daily.map(day => [day.date, day])) - // 300 rows on pages one to three plus 99 on page four; the hundredth is the old row that stopped - // the walk. Every one of them was measured, so none of these days is unknown. - assert.equal(byDate['2026-10-20'].newIssues, 399) - assert.equal(byDate['2026-10-20'].newPullRequests, 399) - assert.equal(daily.every(day => day.newIssues !== null && day.newPullRequests !== null), true) - assert.equal(typeof snapshot.metrics.week.newIssues, 'number') -}) - -test('CI that cannot be read leaves every day at null, never at a zero share (#1222)', async () => { - const { board } = build({ failures: { '/actions/runs': { status: 403, headers: { 'retry-after': '30' } } } }) - const snapshot = await settle(board) - const daily = snapshot.metrics.daily - assert.equal(daily.every(day => day.ci === null), true) - assert.equal(daily[6].closedIssues, 1) -}) - -test('no week, no series: a window nobody read is null, not seven zero days (#1222)', () => { - // The series is assembled from the two windows the round read; without the week there is nothing - // to draw, and the view says so instead of drawing a flat line at zero. - assert.equal(dailySeries(null, null, AT('2026-10-24T16:00:00Z')), null) - const today = AT('2026-10-24T16:00:00Z') - const only = { daily: [{ start: today, newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, ci: null }] } - assert.equal(dailySeries(null, only, today), null, 'six days without a bucket are six unknown days') - assert.equal(dailySeries({ daily: [] }, only, today), null) -}) - -test('a snapshot is cached, a stale one is served while it refreshes, and 304 reuses it (#1173)', async () => { - let clock = NOW - const github = fakeGitHub() - const board = createDeliveryBoard({ repo: 'o/r', token: 't', request: github.request, now: () => clock, ttl: 60_000 }) - const first = await settle(board, { force: true }) - assert.equal(first.state, 'fresh') - const requestsAfterFirst = github.calls.length - clock += 30_000 - const cached = await board.read() - assert.equal(github.calls.length, requestsAfterFirst, 'a fresh cache makes no request') - assert.equal(cached.asOf, first.asOf) - // Past the TTL the old snapshot is served at once, and the refresh runs in the background. - clock += 60_000 - const stale = await board.read() - assert.equal(stale.state, 'stale') - assert.equal(stale.stale, true) - assert.equal(stale.refreshing, true) - assert.equal(stale.asOf, first.asOf) - await board.read({ wait: true }) - const conditional = github.calls.filter(call => call.headers['If-None-Match'] !== undefined) - assert.ok(conditional.length > 0, 'a refresh asks with If-None-Match') -}) - -test('the host installs no timer: nothing refreshes behind the browser (#1173)', () => { - // A host-side interval would burn a turn's worth of GitHub reads with nobody looking; the - // snapshot moves only when the browser asks. The client's own poll is tested in delivery-view. - const source = readFileSync(new URL('../src/host/delivery-board.js', import.meta.url), 'utf8') - assert.equal(/\bset(?:Interval|Timeout)\s*\(/.test(source), false) -}) - -test('CI counts ask GitHub for the day in ISO time, not epoch numbers (#1173)', async () => { - // Epoch milliseconds answer 200 with no runs, so a wrong format reads as "CI never ran". - const { board, github } = build() - await settle(board) - const runs = github.calls.filter(call => call.path.startsWith('/actions/runs')) - assert.ok(runs.length > 0) - for (const call of runs) { - const created = new URLSearchParams(call.query).get('created') - assert.match(created, /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\.\.\d{4}-\d{2}-\d{2}T[\d:.]+Z$/, `created=${created}`) - } -}) - -test('the Diagnose entry is on the API route, and refused without a session (#1211)', async () => { - // #1211: the probe has to be reachable the way the button reaches it — through the channel's - // route, past authz — because calling `rt.deliveryProbe()` directly skips both and left - // `delivery-probe` out of the allow list while every test stayed green (Reviewer, 03:30Z). - let route - const ctx = { - effect: fn => { const dispose = fn(); return () => { if (typeof dispose === 'function') dispose() } }, - connection: { fetch: { register: registered => { route = registered; return () => { route = undefined } } } }, - logger: { warn: () => {} }, - sessionController: { prompt: async () => {} }, - } - const state = { - authSessions: { tok1: { memberId: 'owner-1' } }, - members: { 'owner-1': { id: 'owner-1', displayName: 'Owner' } }, - bots: {}, - rooms: {}, - } - const probeRows = [{ name: '/repos/o/r', ok: true, status: 200, hint: '' }] - const rt = { - ctx, - state, - refresh: async () => {}, - readOnly: () => undefined, - authenticate: async token => (token === 'tok1' ? { memberId: 'owner-1' } : null), - load: async () => {}, - deliveryBoard: async () => ({ ok: true, state: 'fresh', columns: [] }), - deliveryProbe: async () => ({ rows: probeRows }), - ensureCapabilities: async () => {}, - warn: () => {}, - } - installAuthz(rt) - installChannel(rt) - assert.ok(route !== undefined, 'the channel registered its /api/bot route') - - const post = async (payload, token) => { - const headers = { 'content-type': 'application/json' } - if (token) headers['x-flybot-member-session'] = token - const response = await route.fetch(new Request('http://localhost/api/bot', { method: 'POST', headers, body: JSON.stringify(payload) })) - return response.json() - } - - // Signed in: both reads answer, so the 诊断 button's endpoint is not "Unknown endpoint". - for (const endpoint of ['delivery-board', 'delivery-probe']) { - const answer = await post({ endpoint }, 'tok1') - assert.equal(answer.ok, true, endpoint + ' should answer for a signed-in member: ' + JSON.stringify(answer)) - } - // The channel wraps a successful answer as `{ ok: true, value }`. - const probed = await post({ endpoint: 'delivery-probe' }, 'tok1') - assert.deepEqual(probed.value.rows, probeRows) - - // No session: both are refused, not silently served. - for (const endpoint of ['delivery-board', 'delivery-probe']) { - const answer = await post({ endpoint }) - assert.equal(answer.ok, false, endpoint + ' must not answer without a session') - assert.equal(answer.error.code, 'bot/unauthorized', JSON.stringify(answer)) - } -}) - -// #1250: the host resolves which repository it reads before any board exists, says where the name -// came from, and keeps one board per repository. The resolver's own levels are in -// delivery-repo.test.js; these tests hold the host wiring around it. -const hostWith = (github, extra = {}) => { - const rt = { - config: {}, - secretValue: () => 'SECRET-TOKEN', - loadSecrets: async () => {}, - fetch: github.request, - warn: () => {}, - ...extra, - } - const installed = Object.assign({}, rt) - install(installed) - return installed -} - -test('the instance setting names the repository for the board and the probe, and says "config" (#1250)', async () => { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config: { deliveryRepo: 'acme/widget' } }) - const loading = await installed.deliveryBoard() - assert.equal(loading.repo, 'acme/widget') - assert.equal(loading.repoSource, 'config') - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'config') - // Every read, not only the repo call, went to the repository the instance named. - assert.ok(github.calls.length > 0) - for (const call of github.calls) assert.match(call.url, /^https:\/\/api\.github\.com\/repos\/acme\/widget(\/|$|\?)/, call.url) - const probe = await installed.deliveryProbe() - assert.equal(probe.repo, 'acme/widget') - assert.equal(probe.repoSource, 'config') - assert.ok(probe.rows.every(row => row.ok)) -}) - -test('a dotted or nested delivery.repo names the repository too, as a user writes it (#1258)', async () => { - // The hint says `delivery.repo`, so that is what a profile carries — as a literal dotted key or - // nested; a literal key wins nowhere, it is simply read. The flat `deliveryRepo` is the third. - for (const config of [{ 'delivery.repo': 'acme/widget' }, { delivery: { repo: 'acme/widget' } }]) { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget', JSON.stringify(config)) - assert.equal(settled.repoSource, 'config', JSON.stringify(config)) - assert.equal(settled.state, 'fresh') - for (const call of github.calls) assert.match(call.url, /^https:\/\/api\.github\.com\/repos\/acme\/widget(\/|$|\?)/, call.url) - } -}) - -test('the workspace git remote names the repository when there is no setting (#1250)', async () => { - const dir = mkdtempSync(join(tmpdir(), 'delivery-install-')) - try { - mkdirSync(join(dir, '.git')) - writeFileSync(join(dir, '.git', 'config'), '[remote "origin"]\n\turl = git@github.com:acme/widget.git\n') - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { - // The team folder is the instance's workspace, which is where the board's one repository lives. - ctx: { workspaceRegistry: { get: id => (id === 'team' ? { path: dir } : undefined) } }, - state: { workspaceId: 'team' }, - }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'remote') - assert.equal(settled.state, 'fresh') - } finally { rmSync(dir, { recursive: true, force: true }) } -}) - -test('a changed setting re-resolves, and the new repository starts at loading (#1250)', async () => { - // The cache is keyed by repository, so the previous repository's snapshot can never answer for - // the new name: the new board's first read is its own `loading`. - const one = fakeGitHub({ slug: 'o/r' }) - const two = fakeGitHub({ slug: 'acme/widget' }) - const request = (url, init) => (url.includes('/repos/acme/widget') ? two.request(url, init) : one.request(url, init)) - const config = { deliveryRepo: 'o/r' } - const installed = hostWith({ request, calls: [] }, { config }) - const before = await installed.deliveryBoard({ wait: true }) - assert.equal(before.state, 'fresh') - assert.equal(before.repo, 'o/r') - - config.deliveryRepo = 'acme/widget' - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, 'acme/widget') - assert.equal(loading.columns, undefined, 'the old repository columns must not be served for the new name') - const after = await installed.deliveryBoard({ wait: true }) - assert.equal(after.state, 'fresh') - assert.equal(after.repo, 'acme/widget') - assert.ok(two.calls.length > 0) - assert.ok(two.calls.every(call => call.path === '/' || call.path.startsWith('/repos/acme/widget') || !call.path.startsWith('/repos/'))) -}) - -test('the package repository answers only last, labelled a default, and says so in the log (#1250)', async () => { - const github = fakeGitHub({ slug: 'ThinkFlowLab/FlyBot' }) - const warnings = [] - const installed = hostWith(github, { - packageRepository: 'git+https://github.com/ThinkFlowLab/FlyBot.git', - // The host's warning log is printf-shaped; substitute so the assertion reads the message a person sees. - warn: (format, ...rest) => { let index = 0; warnings.push(String(format).replace(/%s/g, () => String(rest[index++] ?? ''))) }, - }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'ThinkFlowLab/FlyBot') - assert.equal(settled.repoSource, 'package') - // A default is acceptable only while it is visible as one, in the log as well as on the page. - assert.equal(warnings.filter(line => /package repository ThinkFlowLab\/FlyBot \(default\)/.test(line)).length, 1) - const again = await installed.deliveryBoard() - assert.equal(again.repoSource, 'package') - assert.equal(warnings.filter(line => /\(default\)/.test(line)).length, 1, 'the default is recorded once, not on every read') -}) - -test('with nothing to read, the board and the probe refuse before any request (#1250)', async () => { - let requests = 0 - const installed = hostWith({ request: async () => { requests += 1; throw new Error('must not be asked') } }, { - packageRepository: '', - }) - const answer = await installed.deliveryBoard() - assert.equal(answer.state, 'failed') - assert.equal(answer.repo, null) - assert.equal(answer.repoSource, null) - assert.equal(answer.error.kind, 'no-repo') - assert.equal(answer.error.message, 'no repository — set delivery.repo') - const probe = await installed.deliveryProbe() - assert.equal(probe.ok, false) - assert.equal(probe.repo, null) - assert.equal(probe.error, 'no repository — set delivery.repo') - // Not `/repos/null`, not a probe of nothing: the resolution refuses first. - assert.equal(requests, 0) -}) - -test('a repository the token cannot read fails naming the repository and the endpoint (#1250)', async () => { - const github = fakeGitHub({ slug: 'acme/widget', failures: { '/issues': { status: 404 } } }) - const installed = hostWith(github, { config: { deliveryRepo: 'acme/widget' } }) - const answer = await installed.deliveryBoard({ wait: true }) - assert.equal(answer.state, 'failed') - assert.equal(answer.repo, 'acme/widget') - assert.equal(answer.error.status, 404) - assert.equal(answer.error.endpoint, '/repos/acme/widget/issues') - // The same failure shape as #1211/#1232, now naming whose 404 it is. - assert.match(answer.error.message, /acme\/widget/) - assert.match(answer.error.message, /\/repos\/acme\/widget\/issues/) -}) - -// #1265 step 4: one board with more than one repository. Each name resolves with its own source, -// each repository keeps its own snapshot and cache, and one that cannot be read no longer takes the -// board with it. -test('two repositories are two blocks of one board, each with its own source (#1265)', async () => { - const one = fakeGitHub({ slug: 'acme/one' }) - const two = fakeGitHub({ slug: 'acme/two' }) - const request = (url, init) => (url.includes('/repos/acme/two') ? two.request(url, init) : one.request(url, init)) - const installed = hostWith({ request, calls: [] }, { config: { delivery: { repos: ['acme/one', 'acme/two'] } } }) - const loading = await installed.deliveryBoard() - assert.equal(loading.state, 'loading') - assert.equal(loading.repo, null, 'no single repository owns a shared board') - assert.deepEqual(loading.repos.map(entry => [entry.repo, entry.repoSource, entry.state]), - [['acme/one', 'config', 'loading'], ['acme/two', 'config', 'loading']]) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'fresh') - assert.equal(settled.repo, null) - assert.equal(settled.repoSource, null) - assert.deepEqual(settled.repos.map(entry => [entry.repo, entry.repoSource, entry.state]), - [['acme/one', 'config', 'fresh'], ['acme/two', 'config', 'fresh']]) - // Each repository's reads went to its own API path: one board, two sources, never one line. - assert.ok(one.calls.length > 0 && two.calls.length > 0) - assert.ok(one.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/one'))) - assert.ok(two.calls.every(call => call.url.startsWith('https://api.github.com/repos/acme/two'))) - // The union carries both repositories' cards: the same issue number in two repositories is two - // cards, and the second one does not disappear behind the first. - assert.deepEqual(settled.repos[0].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) - assert.deepEqual(settled.repos[1].columns['not-started'].map(card => card.number).sort(), [10, 11, 13]) - assert.equal(settled.columns['not-started'].length, 6, 'the same number from each repository is its own card') - // The header's numbers are the repositories' sums; a median across repositories is not a median, - // so it is unknown instead of invented. - assert.equal(settled.metrics.today.newIssues, - settled.repos[0].metrics.today.newIssues + settled.repos[1].metrics.today.newIssues) - assert.equal(settled.metrics.open.issues, - settled.repos[0].metrics.open.issues + settled.repos[1].metrics.open.issues) - assert.equal(settled.metrics.today.medianIssueCloseMs, null) - assert.equal(settled.metrics.open.medianIssueAgeMs, null) - // The probe asks each repository and names it on every row, so a status can be acted on. - const probe = await installed.deliveryProbe() - assert.equal(probe.repo, null) - assert.deepEqual(probe.repos.map(entry => entry.repo), ['acme/one', 'acme/two']) - assert.ok(probe.rows.length > 0) - assert.deepEqual([...new Set(probe.rows.map(row => row.repo))].sort(), ['acme/one', 'acme/two']) -}) - -test('one repository that cannot be read leaves the other on the board as partial (#1265)', async () => { - const one = fakeGitHub({ slug: 'acme/one' }) - const two = fakeGitHub({ slug: 'acme/two', failures: { '/issues': { status: 404 } } }) - const request = (url, init) => (url.includes('/repos/acme/two') ? two.request(url, init) : one.request(url, init)) - const installed = hostWith({ request, calls: [] }, { config: { delivery: { repos: ['acme/one', 'acme/two'] } } }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.state, 'partial', 'a failed repository is partial, not a failed board') - const good = settled.repos.find(entry => entry.repo === 'acme/one') - const bad = settled.repos.find(entry => entry.repo === 'acme/two') - assert.equal(good.state, 'fresh') - assert.ok(good.columns) - assert.equal(bad.state, 'failed') - assert.equal(bad.columns, undefined) - assert.equal(bad.error.kind, 'not-found') - // The failure names its own repository, so the page can say whose read it was. - assert.match(bad.error.endpoint, /\/repos\/acme\/two\//) - assert.equal(settled.error.kind, 'not-found') - assert.equal(settled.missing.length, 0, 'a failed round answers its error, not a partial list') - assert.equal(settled.columns, undefined, 'a partial union is not passed off as a whole one') -}) - -test('a one-repository instance answers the payload it always answered (#1265)', async () => { - const github = fakeGitHub({ slug: 'acme/widget' }) - const installed = hostWith(github, { config: { delivery: { repos: ['acme/widget'] } } }) - const settled = await installed.deliveryBoard({ wait: true }) - assert.equal(settled.repo, 'acme/widget') - assert.equal(settled.repoSource, 'config') - assert.equal(settled.state, 'fresh') - // One entry in the list, so the view can tell one repository from many without guessing. - assert.equal(settled.repos.length, 1) - assert.deepEqual(Object.keys(settled.columns), DELIVERY_COLUMNS) -}) - -test('the aggregation rules: oldest snapshot, partial body, and no invented median (#1265)', () => { - const stamp = '2026-10-25T04:00:00Z' - const view = (repo, extra = {}) => ({ - ok: true, state: 'fresh', repo, repoSource: 'config', asOf: stamp, since: stamp, attempt: 1, elapsedMs: 10, - computedMs: 10, columns: { 'not-started': [], 'in-progress': [], done: [] }, - counts: { 'not-started': 0, 'in-progress': 0, done: 0 }, - metrics: { - dayStart: stamp, timeZone: 'Asia/Shanghai', - today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: 1, medianPullMergeMs: 2, ci: { failed: 0, total: 1, counted: 1, superseded: 0 } }, - week: null, daily: null, - open: { issues: 1, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: 3 }, - }, - ...extra, - }) - const single = combineRepos([view('acme/one')]) - assert.equal(single.state, 'fresh') - assert.equal(single.repo, 'acme/one', 'one repository answers with today\'s payload') - assert.equal(single.repos.length, 1) - - const mixed = combineRepos([ - view('acme/one', { state: 'stale', stale: true, asOf: '2026-10-25T03:00:00Z', retryAt: 5_000 }), - view('acme/two', { asOf: '2026-10-25T02:00:00Z' }), - ]) - assert.equal(mixed.state, 'stale') - assert.equal(mixed.asOf, '2026-10-25T02:00:00Z', 'the oldest snapshot, never a fresher claim') - assert.equal(mixed.metrics.today.newIssues, 2) - // Nothing that could not be read becomes a zero, and no median is invented from two medians. - assert.equal(mixed.metrics.today.medianIssueCloseMs, null) - assert.equal(mixed.metrics.week, null) - assert.equal(mixed.metrics.daily, null) - assert.equal(mixed.metrics.open.medianIssueAgeMs, null) - - const partial = combineRepos([ - view('acme/one'), - { ok: false, state: 'failed', repo: 'acme/two', repoSource: 'config', since: null, attempt: 0, elapsedMs: 0, error: { message: 'GitHub answered 404 for /repos/acme/two/issues', kind: 'not-found' }, retryAt: 10_000 }, - ]) - assert.equal(partial.state, 'partial') - assert.equal(partial.partial, true) - assert.ok(partial.repos[0].columns) - assert.equal(partial.repos[1].columns, undefined) - // Nothing readable is left out. - assert.equal(partial.columns, undefined) - assert.equal(partial.retryAt, 10_000) - // A partial board prints no total it can only half count (#1297 INC): one repository read and - // one did not, so the sums are unknown. Printing the readable half under "2 repositories" is the - // very "a number that looks like a fact" failure the null rule in this file exists to prevent. - assert.equal(partial.metrics, null) - assert.equal(partial.repos[0].metrics.today.newIssues, 1, 'the readable repository keeps its own numbers') - // While every repository reads, the sums are the board's and stay numbers, medians excepted. - assert.equal(mixed.metrics.open.issues, 2) - assert.equal(mixed.metrics.open.medianIssueAgeMs, null) - - // A part that could not be read names its repository too, so one line cannot be read as another's. - const withMissing = combineRepos([ - view('acme/one', { state: 'partial', partial: true, missing: [{ part: 'ci', message: 'GitHub answered 404' }] }), - view('acme/two'), - ]) - 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-chart.test.js b/patches/ds-bot/files/test/delivery-chart.test.js deleted file mode 100644 index a16eb3dd2..000000000 --- a/patches/ds-bot/files/test/delivery-chart.test.js +++ /dev/null @@ -1,249 +0,0 @@ -// The delivery trend chart (#1212): one x per day, a line that breaks at an unknown day instead of -// drawing through zero, a real zero as a filled dot on the baseline, today hollow only while it is -// still running, and no SVG at all when the snapshot carries no series. The chart is pure, so the -// geometry is asserted without a browser; the component tree is read the way delivery-view.test.js -// reads its view. -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 bundle = await build({ - entryPoints: [fileURLToPath(new URL('../src/client/delivery-chart.js', import.meta.url))], - bundle: true, format: 'cjs', platform: 'node', write: false, - external: ['react'], -}) -const module = { exports: {} } -runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, setInterval, clearInterval, Date, Promise, Number, Math, String, Array, Object, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type === 'function') return type({ ...(props ?? {}), children: kids }) - return { type, props: props ?? {}, children: kids } - }, - } - }, -}) -const { - CHART_WIDTH, CHART_HEIGHT, CHART_DAYS, CHART_KEYS, chartDays, chartGeometry, dayValue, - dayLabel, pointTitle, knownDays, todayPartial, DeliveryChart, DeliveryMetrics, DELIVERY_CHART_CSS, -} = module.exports - -const walk = (node, out = []) => { - if (node === null || typeof node !== 'object') return out - out.push(node) - for (const child of node.children ?? []) walk(child, out) - return out -} -const textOf = (node) => { - if (node === null || node === undefined || typeof node !== 'object') return String(node ?? '') - return [node.props?.title ?? '', ...(node.children ?? []).map(textOf)].join(' ') -} - -// Seven Asia/Shanghai days, 2026-10-20 … 2026-10-26; the last one is today and still running. The -// contract the host half is written against: { date, partial?, , ci: { failed, counted, total } }. -const daily = [ - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 3, mergedPullRequests: 0, ci: { failed: 1, total: 10, counted: 10 } }, - { date: '2026-10-21', newIssues: 4, closedIssues: 2, newPullRequests: 1, mergedPullRequests: 2, ci: { failed: 0, total: 8, counted: 8 } }, - { date: '2026-10-22', newIssues: 1, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 3, total: 9, counted: 9 } }, - { date: '2026-10-23', newIssues: 6, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 4, ci: { failed: 0, total: 0, counted: 0 } }, - { date: '2026-10-24', newIssues: 3, closedIssues: 5, newPullRequests: 4, mergedPullRequests: 2, ci: { failed: 2, total: 5, counted: 4 } }, - { date: '2026-10-25', newIssues: 0, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 3, ci: { failed: 1, total: 6, counted: 6 } }, - { date: '2026-10-26', newIssues: 5, closedIssues: 2, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, total: 4, counted: 4 }, partial: true }, -] - -test('#1212: seven days, the rightmost is today, and a longer series keeps its end', () => { - assert.equal(CHART_DAYS, 7) - const days = chartDays(daily) - assert.equal(days.length, 7) - assert.equal(dayLabel(days[6]), '2026-10-26', 'the contract carries the date itself') - assert.equal(dayLabel({ start: '2026-10-25T16:00:00.000Z' }), '2026-10-26', 'an older ISO start still labels a day') - assert.equal(chartDays(undefined), null, 'no series is null, not seven zeros') - assert.equal(chartDays([]), null) - assert.equal(chartDays([...daily, { date: '2026-10-27', newIssues: 9 }]).length, 7) - assert.equal(todayPartial(daily), true) - assert.equal(todayPartial(daily.map(day => ({ ...day, partial: undefined }))), false) -}) - -test('#1212: 168×28, one point per day, x across the box, the largest day at the top', () => { - assert.equal(CHART_WIDTH, 168) - assert.equal(CHART_HEIGHT, 28) - const geo = chartGeometry(daily, 'newIssues') - assert.equal(geo.width, CHART_WIDTH) - assert.equal(geo.height, CHART_HEIGHT) - assert.equal(geo.dots.length, 7) - const xs = geo.dots.map(dot => dot.x) - assert.deepEqual(xs, [...xs].sort((a, b) => a - b), 'x rises with the day') - assert.equal(xs[0], 3) - assert.equal(xs[6], CHART_WIDTH - 3) - assert.equal(Math.min(...geo.dots.map(dot => dot.y)), 3, 'the largest value sits on the top padding') - assert.equal(Math.max(...geo.dots.map(dot => dot.y)), CHART_HEIGHT - 3, 'a zero day sits on the bottom padding') - assert.equal(geo.segments.length, 1) - assert.equal(geo.dots[6].title, '2026-10-26 · 5') -}) - -test('#1212: an unknown day breaks the line instead of drawing through zero', () => { - const gapped = daily.map((item, i) => (i === 3 ? { ...item, newIssues: null } : item)) - const geo = chartGeometry(gapped, 'newIssues') - assert.equal(geo.dots[3].state, 'missing', 'the day is known to be missing') - assert.equal(geo.dots[3].y, null, 'and has no point') - assert.equal(geo.known, 6) - assert.equal(geo.segments.length, 2) - assert.equal(geo.segments[0].d.split('L').length, 3, 'first stretch: three days') - assert.equal(geo.segments[1].d.split('L').length, 3, 'second stretch: the three days after the gap') - assert.equal(geo.dots[3].title, '2026-10-23 · not available') - // A real zero connects and keeps a filled dot on the baseline: never the same picture as a gap. - const zeroed = daily.map((item, i) => (i === 3 ? { ...item, newIssues: 0 } : item)) - const flat = chartGeometry(zeroed, 'newIssues') - assert.equal(flat.segments.length, 1, 'a real zero still connects') - assert.equal(flat.dots[3].state, 'full') - assert.equal(flat.dots[3].y, CHART_HEIGHT - 3, 'the zero dot sits on the baseline') -}) - -test('#1212: today is hollow and dashed only while it is still running', () => { - const partial = chartGeometry(daily, 'newIssues') - assert.equal(partial.dots[6].state, 'partial') - assert.equal(partial.dots[6].hollow, true) - assert.equal(partial.segments[0].dashed, true) - assert.equal(partial.dots.filter(dot => dot.state === 'partial').length, 1, 'only today is partial') - const finished = chartGeometry(daily.map(day => ({ ...day, partial: undefined })), 'newIssues') - assert.equal(finished.dots[6].state, 'full', 'a finished day is a filled dot') - assert.equal(finished.dots[6].hollow, false) - assert.equal(finished.segments[0].dashed, false, 'and a solid line') -}) - -test('#1212: the CI metric is the red share of the runs that counted', () => { - assert.equal(dayValue(daily[0], 'ci'), 0.1) - assert.equal(dayValue(daily[4], 'ci'), 0.5, 'counted 4 of 5 runs: 2/4') - assert.equal(dayValue(daily[3], 'ci'), null, 'no runs that day is unknown, not 0%') - const geo = chartGeometry(daily, 'ci') - assert.equal(geo.dots[3].state, 'missing') - assert.equal(geo.dots[0].title, '2026-10-20 · 10%') - assert.equal(knownDays(daily, 'ci'), 6) -}) - -test('#1244: a day counted only by total still draws the share the header row reads', () => { - // `counted: 0` means the host could not say which runs were superseded, not that the day had - // nothing to count: the line draws `failed / total`, and the header row now reads the same - // denominator instead of printing '—' beside a dot. - const countedByTotal = { date: '2026-10-26', partial: true, ci: { failed: 1, counted: 0, total: 5 } } - assert.equal(dayValue(countedByTotal, 'ci'), 0.2) - const geo = chartGeometry([countedByTotal], 'ci') - assert.equal(geo.dots[0].state, 'partial', 'today is still running, and it is still drawn') - assert.equal(geo.dots[0].title, '2026-10-26 · 20%') - // Both columns empty is still unknown: neither a dot nor a share. - assert.equal(dayValue({ date: '2026-10-26', ci: { failed: 0, counted: 0, total: 0 } }, 'ci'), null) -}) - -test('#1212: the component draws a 168×28 svg with a state and a title on every point', () => { - const node = DeliveryChart({ daily, metricKey: 'newIssues', label: 'New issues' }) - assert.equal(node.type, 'svg') - assert.equal(node.props.width, 168) - assert.equal(node.props.height, 28) - assert.equal(node.props.viewBox, '0 0 168 28') - const all = walk(node) - const circles = all.filter(item => item.type === 'circle') - assert.equal(circles.length, 7) - assert.equal(circles[6].props['data-state'], 'partial') - assert.equal(circles[6].props['data-hollow'], 'true') - assert.equal(circles[6].props.r, 2.5, 'the hollow today dot is drawn larger') - assert.equal(circles[0].props.r, 2, 'a measured day keeps the small dot') - assert.equal(circles[6].props.fill, undefined, 'hollow comes from CSS, not a hard-coded colour') - assert.equal(all.find(item => item.type === 'path').props['stroke-dasharray'], '2 2') - const titles = all.filter(item => item.type === 'title').map(item => item.children.join('')) - assert.equal(titles[6], '2026-10-26 · 5') - assert.equal(titles[3], '2026-10-23 · 6') - // A missing day is not a dot at all, so no circle can stand in for it. - const gapped = walk(DeliveryChart({ daily: daily.map((item, i) => (i === 3 ? { ...item, newIssues: null } : item)), metricKey: 'newIssues' })) - const gappedCircles = gapped.filter(item => item.type === 'circle') - assert.equal(gappedCircles.length, 6) - assert.equal(gappedCircles.some(circle => circle.props['data-state'] === 'missing'), false) - assert.equal(gappedCircles.filter(circle => circle.props['data-state'] === 'full').length, 5) -}) - -test('#1212: a metric with no readable day draws nothing', () => { - assert.equal(pointTitle({ date: '2026-10-23' }, 'newIssues'), '2026-10-23 · not available') - assert.equal(DeliveryChart({ daily: daily.map(item => ({ ...item, mergedPullRequests: null })), metricKey: 'mergedPullRequests' }), null) -}) - -test('#1212: one grey line instead of an empty chart when the snapshot has no series', () => { - const rows = CHART_KEYS.map(key => ({ key, label: key, today: '4', week: '30' })) - for (const missing of [undefined, null, [], 'nope']) { - const all = walk(DeliveryMetrics({ rows, daily: missing })) - assert.equal(all.some(item => item.type === 'svg'), false, 'no svg without data') - assert.equal(all.some(item => item.type === 'circle'), false) - const empty = all.find(item => item.props?.['data-flybot'] === 'delivery-chart-empty') - assert.ok(empty, 'the empty state is drawn') - assert.match(textOf(empty), /No 7-day data yet\.|7 天数据未取到/) - assert.equal(all.filter(item => item.props?.['data-metric'] && item.type === 'div').length, 5) - } -}) - -test('#1212: five lines, Σ totals, medians with no line and no total, 900px breakpoint', () => { - const rows = [ - { key: 'newIssues', label: 'New issues', today: '5', week: '21' }, - { key: 'closedIssues', label: 'Closed issues', today: '2', week: '14' }, - { key: 'newPullRequests', label: 'New pull requests', today: '2', week: '13' }, - { key: 'mergedPullRequests', label: 'Merged pull requests', today: '1', week: '13' }, - { key: 'ci', label: 'CI red share', today: '25%', week: '18%' }, - { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }, - { key: 'medianPullMergeMs', label: 'Median PR merge', today: '3h', week: '25h' }, - ] - const all = walk(DeliveryMetrics({ rows, daily })) - const rowNodes = all.filter(item => item.props?.className === 'bt-del-dm-row') - assert.equal(rowNodes.length, 7) - assert.equal(all.filter(item => item.type === 'svg').length, 5, 'the five countable metrics carry a line') - // The two median rows keep their numbers and get no line, no placeholder cell and no Σ. - const median = rowNodes[5] - assert.equal(median.children.length, 3, 'label, today, 7 days — no chart cell at all') - assert.equal(median.children.some(child => child.props.className === 'bt-del-dm-chart'), false) - assert.equal(median.children[2].children.join(''), '2h', 'a median is not summed') - assert.equal(rowNodes[6].children[2].children.join(''), '25h') - // One primary number per row: today is bold ink, the total is muted and prefixed. - assert.equal(rowNodes[0].children[2].props.className, 'bt-del-dm-value bt-del-dm-today') - assert.equal(rowNodes[0].children[3].props.className, 'bt-del-dm-value bt-del-dm-week') - assert.equal(rowNodes[0].children[3].children.join(''), 'Σ 21') - // A day the host could not read makes the total incomplete: it wears the star. - const gapped = walk(DeliveryMetrics({ rows, daily: daily.map((item, i) => (i === 2 ? { ...item, newIssues: null } : item)) })) - const gappedRows = gapped.filter(item => item.props?.className === 'bt-del-dm-row') - assert.equal(gappedRows[0].children[3].children.join(''), 'Σ 21*') - const foot = gapped.find(item => item.props?.['data-flybot'] === 'delivery-chart-foot') - assert.ok(foot, 'the star is explained') - assert.match(textOf(foot), /\* [0-9]+ of [0-9]+ days|天中/) - // Headers: the two value columns say today and the 7-day total; today names itself as partial. - const head = all.find(item => item.props?.['data-flybot'] === 'delivery-metrics-head') - const labels = head.children.map(child => (child.children ?? []).join('')) - // The partial day is named once, on the as-of line; the shared header keeps one word. - assert.equal(labels[2], 'Today') - assert.equal(labels[3], '7 days', 'no Σ in the shared header') - assert.match(DELIVERY_CHART_CSS, /max-width:899\.98px/, 'the line moves under its row below 900px') - assert.match(DELIVERY_CHART_CSS, /\.bt-del-dm-chart\{order:5;flex:0 0 100%/, 'the line takes its own line there') - assert.match(DELIVERY_CHART_CSS, /min-height:44px/, 'the row grows to 44px there') - assert.match(DELIVERY_CHART_CSS, /flex:0 0 168px/, 'the line does not shrink') - assert.match(DELIVERY_CHART_CSS, /stroke-width:1\.5/) - assert.match(DELIVERY_CHART_CSS, /fill:var\(--bt-accent\)/) - assert.equal(/#[0-9a-fA-F]{3,8}\b/.test(DELIVERY_CHART_CSS), false, 'no hard-coded colour') -}) - -test('#1212: a row finds its series by key, not by its position', () => { - const rows = [ - { key: 'ci', label: 'CI red share', today: '25%', week: '18%' }, - { key: 'newIssues', label: 'New issues', today: '5', week: '21' }, - { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }, - ] - const all = walk(DeliveryMetrics({ rows, daily })) - const rowNodes = all.filter(item => item.props?.className === 'bt-del-dm-row') - assert.deepEqual(rowNodes.map(node => node.props['data-metric']), ['ci', 'newIssues', undefined]) - assert.equal(rowNodes[0].children.length, 4, 'the countable row keeps its line') - assert.equal(rowNodes[0].children[2].children.join(''), '25%') - // A ratio is not a sum: the CI row keeps its number plain, while a count wears the star. - assert.equal(rowNodes[0].children[3].children.join(''), '18%', 'the CI share is a ratio, never summed') - assert.equal(rowNodes[1].children[3].children.join(''), 'Σ 21', 'the count keeps its Σ') - assert.equal(rowNodes[2].children.length, 3, 'a median row stays without a line, wherever it sits') - const svgs = all.filter(item => item.type === 'svg') - assert.equal(svgs.length, 2) -}) diff --git a/patches/ds-bot/files/test/delivery-repo.test.js b/patches/ds-bot/files/test/delivery-repo.test.js deleted file mode 100644 index 6f6c5a904..000000000 --- a/patches/ds-bot/files/test/delivery-repo.test.js +++ /dev/null @@ -1,220 +0,0 @@ -// Which repository the delivery board reads, and where that name came from (#1250): the instance -// setting, then the workspace's git remote, then the package's own repository. The host and -// `core/scripts/delivery-probe.mjs` both call this one resolver, so the two cannot drift. The knife -// for each level is one test here, so a level that silently stops being tried turns its own test red. -import assert from 'node:assert/strict' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { test } from 'node:test' -import { gitConfigPath, gitOriginRepo, instanceRepoSetting, instanceRepoSettings, NO_REPO_MESSAGE, originUrlIn, parseRepoName, resolveRepo, resolveRepos } from '../src/host/delivery-repo.js' - -const scratch = () => mkdtempSync(join(tmpdir(), 'delivery-repo-')) -const cleanup = dir => rmSync(dir, { recursive: true, force: true }) - -// A folder that is a git checkout with one `origin` url. -function workspaceWith(url, text = `[remote "origin"]\n\turl = ${url}\n`) { - const dir = scratch() - mkdirSync(join(dir, '.git')) - writeFileSync(join(dir, '.git', 'config'), text) - return dir -} - -test('delivery.repo in the instance setting answers first, and says so (#1250)', () => { - const dir = workspaceWith('https://github.com/remote/other.git') - try { - // The remote disagrees on purpose: an explicit setting beats anything inferred. - assert.deepEqual( - resolveRepo({ setting: 'acme/widget', workspace: dir, packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'acme/widget', source: 'config' }) - // A URL copied from the page, or a name with the `.git` left on, is the same setting. - assert.deepEqual(resolveRepo({ setting: 'https://github.com/acme/widget.git', workspace: dir, packageSlug: 'x/y' }), - { repo: 'acme/widget', source: 'config' }) - } finally { cleanup(dir) } -}) - -test('the workspace origin answers when there is no setting (#1250)', () => { - const dir = workspaceWith('https://github.com/acme/widget.git') - try { - assert.deepEqual( - resolveRepo({ workspace: dir, packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'acme/widget', source: 'remote' }) - } finally { cleanup(dir) } -}) - -test('an origin written as a URL, an scp path or a name with no .git reads the same (#1250)', () => { - for (const [url, expected] of [ - ['https://github.com/acme/widget.git', 'acme/widget'], - ['git@github.com:acme/widget.git', 'acme/widget'], - ['https://github.com/acme/widget', 'acme/widget'], - ['ssh://git@github.com/acme/widget.git', 'acme/widget'], - ]) { - const dir = workspaceWith(url) - try { - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: expected, source: 'remote' }, url) - } finally { cleanup(dir) } - } -}) - -test('a submodule keeps its config in the gitdir the `.git` file names (#1250)', () => { - // A submodule's `.git` is a pointer file and its own gitdir really does hold `config`. - const root = scratch() - try { - const store = join(root, 'super', '.git', 'modules', 'sub') - mkdirSync(store, { recursive: true }) - writeFileSync(join(store, 'config'), '[remote "origin"]\n\turl = git@github.com:acme/submodule.git\n') - const dir = join(root, 'super', 'sub') - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, '.git'), `gitdir: ${join('..', '.git', 'modules', 'sub')}\n`) - assert.equal(gitConfigPath(dir), join(store, 'config')) - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: 'acme/submodule', source: 'remote' }) - } finally { cleanup(root) } -}) - -test('a linked worktree follows its `commondir` to the config the shared directory holds (#1258)', () => { - // `git worktree add` writes `.git` -> `
/.git/worktrees/`, and that directory holds no - // config of its own: only `commondir`, naming the shared directory where git keeps it. Reading - // `config` beside the pointer finds nothing, so a worktree workspace fell to the package default. - const root = scratch() - try { - const shared = join(root, 'main', '.git') - mkdirSync(join(shared, 'worktrees', 'wt'), { recursive: true }) - writeFileSync(join(shared, 'config'), '[remote "origin"]\n\turl = git@github.com:acme/worktree.git\n') - writeFileSync(join(shared, 'worktrees', 'wt', 'commondir'), '../..\n') - const dir = join(root, 'checkout') - mkdirSync(dir) - writeFileSync(join(dir, '.git'), `gitdir: ${join('..', 'main', '.git', 'worktrees', 'wt')}\n`) - assert.equal(gitConfigPath(dir), join(shared, 'config')) - assert.deepEqual(resolveRepo({ workspace: dir }), { repo: 'acme/worktree', source: 'remote' }) - // A pointer with neither a config nor a commondir answers nothing rather than a wrong path. - const bare = join(root, 'bare') - mkdirSync(join(bare, 'gitdir'), { recursive: true }) - mkdirSync(join(root, 'elsewhere')) - writeFileSync(join(root, 'elsewhere', '.git'), 'gitdir: ../bare/gitdir\n') - assert.equal(gitConfigPath(join(root, 'elsewhere')), null) - assert.deepEqual(resolveRepo({ workspace: join(root, 'elsewhere') }), { repo: null, source: null }) - } finally { cleanup(root) } -}) - -test('only `origin` counts: another remote is not a union of guesses (#1250)', () => { - const both = workspaceWith('https://github.com/acme/widget.git', - '[remote "upstream"]\n\turl = https://github.com/other/place.git\n\n[remote "origin"]\n\turl = https://github.com/acme/widget.git\n') - try { assert.deepEqual(resolveRepo({ workspace: both }), { repo: 'acme/widget', source: 'remote' }) } finally { cleanup(both) } - const upstreamOnly = workspaceWith('https://github.com/other/place.git', '[remote "upstream"]\n\turl = https://github.com/other/place.git\n') - try { - assert.deepEqual(resolveRepo({ workspace: upstreamOnly }), { repo: null, source: null }) - assert.equal(gitOriginRepo(upstreamOnly), null) - } finally { cleanup(upstreamOnly) } - // A folder that is not a git checkout at all answers null instead of throwing. - assert.equal(gitOriginRepo('/nonexistent/folder/for/this/test'), null) - assert.equal(gitOriginRepo(null), null) -}) - -test('the package repository answers last, and is labelled a default (#1250)', () => { - // A real package's `repository.url` arrives with the `git+` prefix and the `.git` suffix. - assert.deepEqual(resolveRepo({ packageSlug: 'git+https://github.com/ThinkFlowLab/FlyBot.git' }), - { repo: 'ThinkFlowLab/FlyBot', source: 'package' }) - assert.deepEqual(resolveRepo({ packageSlug: 'ThinkFlowLab/FlyBot' }), - { repo: 'ThinkFlowLab/FlyBot', source: 'package' }) -}) - -test('nothing answering is null, never a name invented from nothing (#1250)', () => { - assert.deepEqual(resolveRepo({}), { repo: null, source: null }) - assert.deepEqual(resolveRepo(), { repo: null, source: null }) - assert.deepEqual(resolveRepo({ setting: '', workspace: '', packageSlug: '' }), { repo: null, source: null }) - // The failure message is one constant, so the view, the host and the CLI cannot disagree on it. - assert.equal(NO_REPO_MESSAGE, 'no repository — set delivery.repo') -}) - -test('a value that is not a GitHub repository is refused, not mined for its last two segments (#1250)', () => { - for (const value of ['not a repo', 'https://gitlab.com/acme/widget.git', 'https://github.com/acme', 'acme/widget/extra', 12]) { - assert.equal(parseRepoName(value), null, String(value)) - } - assert.equal(originUrlIn('[remote "origin"]\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n'), null) -}) - -test('a path step is not a repository name, so `.` and `..` segments are refused (#1258)', () => { - // `OWNER_NAME` allowed a dot anywhere, so `../..` answered a "repository": the board then built - // `/repos/../../issues`, which normalises to the API root. Same host, so no token left, but every - // request it made was meaningless and the "this is a repository name" invariant was gone. - for (const value of ['../..', './widget', '.a/b', 'acme/.b', 'acme/..']) { - assert.equal(parseRepoName(value), null, value) - } - // A dot inside a segment is a real repository name and stays allowed. - for (const value of ['acme/widget.js', 'a.b/c-d', 'a1-2_3/b.4']) { - assert.equal(parseRepoName(value), value, value) - } -}) - -test('the instance setting is read in all three spellings a profile can write it (#1258)', () => { - // The host passes the plugin entry through verbatim and expands no dotted key, so the hint's - // `delivery.repo` reaches the board as one of three shapes. Reading only the flat one ignored - // what a user had just written and fell through to the labelled package default. - assert.equal(instanceRepoSetting({ delivery: { repo: 'acme/widget' } }), 'acme/widget') - assert.equal(instanceRepoSetting({ 'delivery.repo': 'acme/widget' }), 'acme/widget') - assert.equal(instanceRepoSetting({ deliveryRepo: 'acme/widget' }), 'acme/widget') - // The documented spelling wins over the runtime shape when a profile happens to carry both. - assert.equal(instanceRepoSetting({ delivery: { repo: 'acme/nested' }, deliveryRepo: 'acme/flat' }), 'acme/nested') - // Nothing written, or an empty value, is "no setting", not the empty string as a repository. - assert.equal(instanceRepoSetting({}), null) - assert.equal(instanceRepoSetting({ delivery: {} }), null) - assert.equal(instanceRepoSetting({ deliveryRepo: '' }), null) - assert.equal(instanceRepoSetting(null), null) - assert.equal(instanceRepoSetting(undefined), null) - // A nested setting is a real setting end to end, not only in the helper. - assert.deepEqual(resolveRepo({ setting: instanceRepoSetting({ delivery: { repo: 'acme/widget' } }), packageSlug: 'x/y' }), - { repo: 'acme/widget', source: 'config' }) -}) - -test('the probe is a shell over this resolver, and refuses before it asks (#1250, #1265)', () => { - const source = readFileSync(new URL('../scripts/delivery-probe.mjs', import.meta.url), 'utf8') - // One resolver, imported — a second copy in the CLI is how the two drift. - assert.match(source, /import \{ NO_REPO_MESSAGE, resolveRepos \} from '\.\.\/src\/host\/delivery-repo\.js'/) - assert.match(source, /resolveRepos\(\{/) - // The same refusal the host gives, with a non-zero exit and no request before it. - assert.match(source, /if \(repos\.length === 0\) \{[\s\S]{0,240}process\.exit\(2\)/) - assert.match(source, /NO_REPO_MESSAGE/) - // Its order is --repo (repeatable), then --workspace, then the package: no third source. - assert.match(source, /setting: allOf\('--repo'\)/) - assert.match(source, /workspace: argOf\('--workspace', process\.cwd\(\)\)/) - assert.match(source, /packageSlug: repoSlug\(\)/) -}) - -test('delivery.repos is a list, and the older single spelling is a list of one (#1265)', () => { - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['acme/one', 'acme/two'] } }), ['acme/one', 'acme/two']) - assert.deepEqual(instanceRepoSettings({ 'delivery.repos': ['acme/one'] }), ['acme/one']) - assert.deepEqual(instanceRepoSettings({ deliveryRepos: ['acme/one'] }), ['acme/one']) - // Every spelling a profile written before #1265 carries still names one repository. - assert.deepEqual(instanceRepoSettings({ delivery: { repo: 'acme/widget' } }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({ 'delivery.repo': 'acme/widget' }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({ deliveryRepo: 'acme/widget' }), ['acme/widget']) - // The list is the documented spelling, so it wins when a profile carries both. - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['acme/one'], repo: 'z/z' } }), ['acme/one']) - // An empty list or a blank entry is "nothing written": the next level still answers. - assert.deepEqual(instanceRepoSettings({ delivery: { repos: [] } }), []) - assert.deepEqual(instanceRepoSettings({ delivery: { repos: ['', ' '] } }), []) - assert.deepEqual(instanceRepoSettings({ delivery: { repos: [' '] }, deliveryRepo: 'acme/widget' }), ['acme/widget']) - assert.deepEqual(instanceRepoSettings({}), []) - assert.deepEqual(instanceRepoSettings(null), []) -}) - -test('every configured repository keeps its own source, and a repeat is one repository (#1265)', () => { - const dir = workspaceWith('https://github.com/remote/other.git') - try { - assert.deepEqual(resolveRepos({ setting: ['acme/one', 'acme/two'], workspace: dir, packageSlug: 'x/y' }), - [{ repo: 'acme/one', source: 'config' }, { repo: 'acme/two', source: 'config' }]) - // The same name written twice — as a URL and as `owner/name` — is one repository, not two boards - // of the same thing. - assert.deepEqual(resolveRepos({ setting: ['acme/one', 'https://github.com/acme/one.git'] }), - [{ repo: 'acme/one', source: 'config' }]) - // A refused entry does not take the good one with it, and a list with nothing valid falls through - // as a whole: the levels answer once, they are not unioned. - assert.deepEqual(resolveRepos({ setting: ['not a repo', 'acme/one'] }), [{ repo: 'acme/one', source: 'config' }]) - assert.deepEqual(resolveRepos({ setting: ['not a repo'], workspace: dir }), [{ repo: 'remote/other', source: 'remote' }]) - assert.deepEqual(resolveRepos({ setting: ['not a repo'], workspace: '/nonexistent/folder', packageSlug: 'x/y' }), - [{ repo: 'x/y', source: 'package' }]) - assert.deepEqual(resolveRepos({}), []) - // The single-answer call the probe used to make still answers the first of the list. - assert.deepEqual(resolveRepo({ setting: ['acme/one', 'acme/two'] }), { repo: 'acme/one', source: 'config' }) - } finally { cleanup(dir) } -}) diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js deleted file mode 100644 index 6926a01ab..000000000 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ /dev/null @@ -1,862 +0,0 @@ -// The delivery board view (#1173): five columns from one snapshot, a read-only card, and the -// header numbers with the CI counted caveat. #1211 added the four states: loading with a counter, -// failed with a reason and a retry, stale with the old board and the reason beside it, partial with -// the part that was not read named. #1227 made the countdown move and the retry hints name their -// condition. The host module is tested in delivery-board.test.js. -import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { test } from 'node:test' -import { fileURLToPath } from 'node:url' -import { runInNewContext } from 'node:vm' -import { build } from 'esbuild' - -// A clock the new tests can move. `wall.t === null` means "use the real one", which is how every -// test written before #1227 keeps reading real time. -const wall = { t: null } -class FakeDate extends Date { - static now() { return wall.t ?? Date.now() } -} - -// The timers the component may start. Nothing but a component under test asks for one, and only the -// new countdown test moves them. -const timers = { - seq: 0, pending: new Map(), - setInterval(fn, ms) { const id = ++timers.seq; timers.pending.set(id, { fn, ms }); return id }, - clearInterval(id) { timers.pending.delete(id) }, - tick(ms) { wall.t = (wall.t ?? Date.now()) + ms; for (const timer of [...timers.pending.values()]) timer.fn() }, -} - -// A tiny React with real hook slots, so a re-render can be observed (#1227). A component called -// directly, as the older tests do, still gets the old no-op setter: nothing changes for them. -const live = { frame: null, frames: new Map(), frameIndex: 0, effects: [], dirty: false } -const reactStub = { - createElement: (type, props, ...children) => { - const kids = children.flat(Infinity) - if (typeof type !== 'function') return { type, props: props ?? {}, children: kids } - const key = live.frameIndex++ - let frame = live.frames.get(key) - if (!frame) { frame = { hooks: [], cleanups: [] }; live.frames.set(key, frame) } - const outer = live.frame - live.frame = frame - frame.i = 0 - try { return type({ ...(props ?? {}), children: kids }) } finally { live.frame = outer } - }, - useState(initial) { - const frame = live.frame - const value = typeof initial === 'function' ? initial() : initial - if (!frame) return [value, () => {}] - const index = frame.i++ - if (!(index in frame.hooks)) frame.hooks[index] = { value } - const slot = frame.hooks[index] - return [slot.value, next => { slot.value = typeof next === 'function' ? next(slot.value) : next; live.dirty = true }] - }, - useEffect(fn, deps) { - const frame = live.frame - if (!frame) return - const index = frame.i++ - const previous = frame.hooks[index] - const same = previous && deps && previous.deps && deps.length === previous.deps.length - && deps.every((dep, at) => Object.is(dep, previous.deps[at])) - if (same) return - frame.hooks[index] = { deps } - live.effects.push(() => { - if (typeof frame.cleanups[index] === 'function') frame.cleanups[index]() - const cleanup = fn() - frame.cleanups[index] = typeof cleanup === 'function' ? cleanup : null - }) - }, -} - -const bundle = await build({ - entryPoints: [fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url))], - bundle: true, format: 'cjs', platform: 'node', write: false, - external: ['react'], -}) -const module = { exports: {} } -runInNewContext(bundle.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, - setInterval: timers.setInterval, clearInterval: timers.clearInterval, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return reactStub - }, -}) - -// Mount a component under that little React: render, run the effects, and render again while a -// setter fired during the pass marked the tree dirty. -const mount = (Component, props) => { - live.frames = new Map() - const draw = () => { - let out = null - for (let pass = 0; pass < 5; pass += 1) { - live.dirty = false - live.frameIndex = 0 - live.effects = [] - out = reactStub.createElement(Component, props) - for (const effect of live.effects) effect() - if (!live.dirty) break - } - return out - } - draw() - return { draw, rerender: draw } -} -const { - createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, - loadingText, staleText, hintFor, missingText, probeText, stagesOf, -} = module.exports - -const text = (node) => { - if (node === null || node === undefined || typeof node !== 'object') return String(node ?? '') - return [node.props?.title ?? '', ...(node.children ?? []).map(text)].join(' ') -} -const walk = (node, out = []) => { - if (node === null || typeof node !== 'object') return out - out.push(node) - for (const child of node.children ?? []) walk(child, out) - return out -} -const buttons = (node) => walk(node).filter(item => item.type === 'button') - -const snapshot = { - asOf: '2026-10-25T04:00:00Z', - columns: { - raw: [{ kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }], - ready: [{ kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }], - working: [{ kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }], - review: [{ kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1' }], - 'done-today': [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40' }], - }, - metrics: { - dayStart: '2026-10-24T16:00:00.000Z', - today: { - newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, - medianIssueCloseMs: 3_600_000, medianPullMergeMs: 90_000_000, - ci: { failed: 2, total: 1200, counted: 1000, superseded: 3 }, - }, - week: { - newIssues: 30, closedIssues: 20, newPullRequests: 40, mergedPullRequests: 25, - medianIssueCloseMs: 7_200_000, medianPullMergeMs: 180_000_000, - ci: { failed: 10, total: 10, counted: 10, superseded: 0 }, - }, - open: { issues: 54, pullRequests: 6, needsHuman: 4, medianIssueAgeMs: 259_200_000 }, - }, -} - -// #1326: the host draws the definition's three lanes; `review` and `done-today` are stages a card -// carries, not lanes of their own. -const threeLane = { - ...snapshot, - columns: { - 'not-started': [ - { kind: 'issue', number: 10, title: 'Untriaged', labels: ['bug'], url: 'https://x/10', owner: null }, - { kind: 'issue', number: 11, title: 'Ready', labels: ['agent-ready'], url: 'https://x/11', owner: 'Dev' }, - ], - 'in-progress': [ - { kind: 'issue', number: 12, title: 'Working', labels: ['agent-working'], url: 'https://x/12', linked: 41 }, - { kind: 'pr', number: 41, title: 'Fix', labels: [], url: 'https://x/41', badge: 'inc', sha: 'abcdef1', stage: 'review' }, - ], - done: [{ kind: 'pr', number: 40, title: 'Merged', labels: [], url: 'https://x/40', stage: 'done-today' }], - }, -} - -test('three delivery columns, localized labels, badge wording (#1173, #1326)', () => { - assert.deepEqual([...DELIVERY_COLUMNS.map(column => column.id)], ['not-started', 'in-progress', 'done']) - assert.equal(badgeLabel('inc'), 'INC · rework') - assert.equal(badgeLabel('pass-enqueue'), 'PASS · waiting for enqueue') - assert.equal(badgeLabel('pass-merge'), 'PASS · waiting for merge') - assert.equal(badgeLabel('ci-red'), 'CI red') - assert.equal(badgeLabel('no-verdict'), 'No verdict') - // Unreadable checks are their own badge, not "no verdict" (#1211). - assert.equal(badgeLabel('checks-unknown'), 'Checks unknown') - assert.equal(duration(45_000), '1m') - assert.equal(duration(3_600_000), '1h') - assert.equal(duration(90_000_000), '25h') - assert.equal(duration(200_000_000), '2d') - assert.equal(duration(null), '—') -}) - -test('the header carries today, the 7-day comparison, the CI caveat and open ages (#1173)', () => { - const rows = metricRows(snapshot.metrics) - // #1212 adds the CI red share as a seventh row: it draws a line like the counts, but as a ratio - // it never carries a Σ total. - assert.equal(rows.rows.length, 7) - // #1212: every row carries the metric key the chart looks its series up by, so the expectation - // names all six — the shape change is deliberate, not something to hide from the assertion. - assert.deepEqual({ ...rows.rows[0] }, { key: 'newIssues', label: 'New issues', today: '4', week: '30' }) - assert.deepEqual({ ...rows.rows[4] }, { key: 'ci', label: 'CI red share', today: '0%', week: '100%' }) - assert.deepEqual({ ...rows.rows[5] }, { key: 'medianIssueCloseMs', label: 'Median issue close', today: '1h', week: '2h' }) - assert.deepEqual([...rows.rows.map(row => row.key)], - ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci', 'medianIssueCloseMs', 'medianPullMergeMs']) - assert.equal(rows.ci.failed, 2) - assert.equal(rows.ci.total, 1200) - assert.equal(rows.ci.caveat, true) - assert.equal(rows.ci.superseded, 3) - assert.equal(rows.open.needsHuman, 4) -}) - -test('the CI red share reaches the page as a line, and the medians draw none (#1212)', () => { - // A key the table has no row for would draw no line: the CI share has to be a row, not a key only - // the chart knows about. - const daily = [ - { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, - { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, - { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, - { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, - { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, - { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, - ] - const board = DeliveryBoard({ snapshot: { ...snapshot, metrics: { ...snapshot.metrics, daily } }, message: '' }) - const charts = walk(board).filter(node => node.props?.className === 'bt-del-chart') - assert.deepEqual([...charts.map(node => node.props['data-metric'])], - ['newIssues', 'closedIssues', 'newPullRequests', 'mergedPullRequests', 'ci']) - const rows = walk(board).filter(node => node.props?.className === 'bt-del-dm-row') - const ciRow = rows.find(node => node.props['data-metric'] === 'ci') - assert.ok(ciRow, 'the CI share is a row of the table, not only a chart key') - // A share of the week, never a sum. - assert.match(text(ciRow), /100%/) - assert.equal(text(ciRow).includes('Σ'), false) - // Medians cannot be added up and get no chart at all. - for (const key of ['medianIssueCloseMs', 'medianPullMergeMs']) { - const row = rows.find(node => node.props['data-metric'] === key) - assert.equal(walk(row).some(node => node.props?.className === 'bt-del-chart'), false) - } -}) - -test('a day the host could only count by total reads the same share in the row as on the line (#1244)', () => { - // The header row required `counted`, so a day with `counted: 0, total: 5` printed '—' while the - // 7-day line beside it drew a dot from `total`: one day, two answers. The row now uses the same - // denominator the chart draws. - const countedByTotal = { date: '2026-10-25', partial: true, newIssues: 3, closedIssues: 1, newPullRequests: 0, mergedPullRequests: 0, ci: { failed: 1, counted: 0, total: 5 } } - const metrics = { - ...snapshot.metrics, - today: { ...snapshot.metrics.today, ci: countedByTotal.ci }, - daily: [countedByTotal], - } - const rows = metricRows(metrics) - assert.equal(rows.rows[4].today, '20%', 'one red run of five is 20%, not unknown') - // The denominator the chart uses for that same day (delivery-chart.test.js pins the number). - assert.equal(countedByTotal.ci.failed / countedByTotal.ci.total, 0.2) - // A window with no runs at all still has no share: unknown is not 0%, and it is not a dot either. - assert.equal(metricRows({ ...metrics, today: { ...snapshot.metrics.today, ci: { failed: 0, counted: 0, total: 0 } } }).rows[4].today, '—') -}) - -test('a partial day prints its time once, in the task zone, instead of twice (#1244)', async () => { - // #1244: the paragraph printed `as of {timeOf}` and then appended a hint carrying its own - // `clockOf` of the same instant — the time twice, and from two different renderings. The hint now - // follows the single time, and that time is the hint's own zone rendering. - const daily = [ - { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, - { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, - { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, - { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, - { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, - { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, - { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, - ] - const snapshotWith = dailySeries => ({ ...snapshot, metrics: { ...snapshot.metrics, daily: dailySeries } }) - const asOfText = (Component, input) => { - const paragraph = walk(Component({ snapshot: input })).find(node => node.props?.className === 'bt-del-asof') - assert.ok(paragraph, 'the header carries an as-of line') - return text(paragraph).replace(/\s+/g, ' ').trim() - } - const times = words => [...words.matchAll(/\d{1,2}:\d{2}/g)].length - - const english = asOfText(DeliveryHeader, snapshotWith(daily)) - assert.equal(times(english), 1, `one time string, got: ${english}`) - assert.match(english, /today is partial/) - // The same paragraph in Chinese, through the same bundled module: the duplicate 截至 is gone too. - const { DeliveryHeader: DeliveryHeaderZh, installLocale } = await build({ - stdin: { - contents: `export { DeliveryHeader } from ${JSON.stringify(fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url)))}\nexport { installLocale } from ${JSON.stringify(fileURLToPath(new URL('../src/client/i18n.js', import.meta.url)))}`, - resolveDir: fileURLToPath(new URL('../src/client/', import.meta.url)), - loader: 'js', - }, - bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react'], - }).then(async out => { - const module = { exports: {} } - runInNewContext(out.outputFiles[0].text, { - module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, - setInterval: timers.setInterval, clearInterval: timers.clearInterval, - require: (name) => { - if (name !== 'react') throw new Error(`no platform module ${name}`) - return reactStub - }, - }) - return module.exports - }) - const dictionaries = new Map() - const off = installLocale({ - register: (namespace, locale, dict) => { dictionaries.set(`${namespace}:${locale}`, dict); return () => dictionaries.delete(`${namespace}:${locale}`) }, - bind: namespace => (key, params) => { - const words = dictionaries.get(`${namespace}:zh`)?.[key] ?? key - return params ? words.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : words - }, - getLocale: () => ({ active: 'zh' }), - }) - try { - const chinese = asOfText(DeliveryHeaderZh, snapshotWith(daily)) - assert.equal(times(chinese), 1, `one time string, got: ${chinese}`) - assert.match(chinese, /今天为部分日/) - assert.equal((chinese.match(/截至/g) ?? []).length, 1, 'the hint no longer repeats 截至') - } finally { - off() - } - // A finished day keeps the local-clock rendering and gains no hint at all. - const finished = asOfText(DeliveryHeader, snapshotWith(daily.map(day => ({ ...day, partial: false })))) - assert.equal(times(finished), 1) - assert.equal(finished.includes('today is partial'), false) -}) - -test('the board renders all five columns and a card with labels, owner and link (#1173)', () => { - const board = DeliveryBoard({ snapshot: { ...snapshot, repo: 'o/r', columns: snapshot.columns }, message: '' }) - const sections = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.deepEqual([...sections.map(node => node.props['data-column'])], ['raw', 'ready', 'working', 'review', 'done-today']) - const review = sections.find(node => node.props['data-column'] === 'review') - assert.equal(review.children[0].children[1].children[0], '1') - const card = walk(review).find(node => node.props?.['data-badge'] === 'inc') - // The card names its source and its repository-scoped identity (#1265 step 1): one issue is one - // card, keyed `owner/name#number`, whichever row of the snapshot it arrived in. - assert.equal(card.props['data-source'], 'github') - assert.equal(card.props['data-key'], 'o/r#41') - const words = text(card) - assert.match(words, /Fix/) - assert.match(words, /INC · rework/) - assert.match(words, /abcdef1/) - const workingCard = walk(sections.find(node => node.props['data-column'] === 'working')).find(node => node.type === 'article') - assert.match(text(workingCard), /linked to #41/) - assert.ok(walk(workingCard).some(node => node.type === 'a' && node.props.href === 'https://x/12')) - // A person is rendered as a person, not as a Bot. - const owner = walk(workingCard).concat(walk(sections.find(node => node.props['data-column'] === 'ready'))).find(node => node.props?.className === 'bt-del-owner') - assert.equal(owner.props['data-kind'], 'human') -}) - -test('an honest message replaces the board when there is no GitHub connection (#1173)', () => { - const board = DeliveryBoard({ snapshot: null, message: 'No GitHub connection yet; the board fills in once one is added.' }) - assert.match(text(board), /No GitHub connection yet/) - assert.equal(walk(board).some(node => node.type === 'section'), false) -}) - -test('"no snapshot yet" belongs to a host that has never tried (#1211)', () => { - const idle = DeliveryBoard({ view: { state: 'idle' } }) - assert.match(text(idle), /No snapshot yet\./) - assert.equal(idle.props['data-state'], 'idle') - // Every other state has its own words; none of them says "no snapshot". - for (const state of ['loading', 'failed', 'stale', 'partial']) { - const view = state === 'loading' ? { state, elapsedMs: 12_000 } - : state === 'failed' ? { state, error: { message: 'GitHub answered 404 for /repos/o/r/issues', status: 404, kind: 'not-found', scope: 'issues' } } - : state === 'stale' ? { ...snapshot, state, error: { message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' } } - : { ...snapshot, state, missing: [{ part: 'ci', message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' }] } - assert.doesNotMatch(text(DeliveryBoard({ view })), /No snapshot yet/, `${state} must not reuse it`) - } -}) - -test('loading counts the seconds and promises the columns (#1211)', () => { - const board = DeliveryBoard({ view: { state: 'loading', elapsedMs: 12_000 } }) - assert.equal(loadingText(12_000), 'First load in progress, 12 s elapsed') - assert.match(text(board), /First load in progress, 12 s elapsed/) - assert.equal(walk(board).some(node => node.type === 'section'), false, 'no half-built columns') - assert.equal(pollDelay('loading'), 2_000) - assert.equal(pollDelay('idle'), 2_000) - // A failed read is scheduled by the host's `retryAt`, not by a fixed minute (#1211): the old - // assertion here pinned 60_000 for `failed`, which is what made the 10 s / 30 s levels dead code. - const now = Date.parse('2026-10-25T04:00:00Z') - assert.equal(pollDelay('failed', { retryAt: now + 10_000 }, now), 10_000) - assert.equal(pollDelay('failed', { retryAt: now + 120_000 }, now), 120_000, 'the 2 min level is not flattened to a minute') - assert.equal(pollDelay('stale', { retryAt: now + 30_000 }, now), 30_000, 'a stale board waits for the same retry') - assert.equal(pollDelay('failed', { retryAt: now - 5_000 }, now), 1_000, 'a past retryAt does not spin') - for (const state of ['fresh', 'stale', 'partial', 'failed']) assert.equal(pollDelay(state, { retryAt: null }, now), 60_000, `${state} polls slowly`) -}) - -test('a failed round names the endpoint, the advice and a retry button (#1211)', () => { - const error = { message: 'GitHub answered 404 for /repos/o/r/actions/runs', endpoint: '/repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions', at: '2026-10-25T04:00:00Z' } - let retried = 0 - const board = DeliveryBoard({ view: { state: 'failed', error }, repo: 'o/r', onRetry: () => { retried += 1 } }) - const words = text(board) - assert.match(words, /GitHub answered 404 for \/repos\/o\/r\/actions\/runs/) - assert.match(words, /Give the token Actions: Read\./) - const retry = buttons(board).find(button => text(button).trim() === 'Retry') - assert.ok(retry, 'there is a retry button') - retry.props.onClick() - assert.equal(retried, 1) - assert.equal(board.props['data-state'], 'failed') -}) - -test('the five failures do not borrow each other\'s words (#1211)', () => { - const hint = (error) => hintFor(error, { repo: 'o/r' }) - assert.match(hint({ kind: 'no-token' }), /GITHUB_TOKEN/) - assert.match(hint({ kind: 'no-token' }), /o\/r/) - assert.match(hint({ kind: 'not-found', scope: 'repo' }), /Check the repository name, and that the token can see o\/r/) - assert.match(hint({ kind: 'not-found', scope: 'actions' }), /Actions: Read/) - assert.match(hint({ kind: 'not-found', scope: 'checks' }), /Checks: Read/) - assert.match(hint({ kind: 'not-found', scope: 'comments' }), /Issues: Read/) - assert.match(hint({ kind: 'timeout' }), /did not answer in time/) - // A wait is printed only when GitHub gave one: no invented "60 seconds". - assert.match(hint({ kind: 'rate-limit', retryAfterMs: null }), /10 s → 30 s → 2 min/) - assert.doesNotMatch(hint({ kind: 'rate-limit', retryAfterMs: null }), /\b60\b/) - assert.match(hint({ kind: 'rate-limit', retryAfterMs: 30_000 }), /in 30 s/) -}) - -test('a stale board keeps the columns and hangs the reason beside them (#1211)', () => { - const error = { message: 'GitHub answered 404 for /repos/o/r/actions/runs', endpoint: '/repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' } - const view = { ...snapshot, state: 'stale', error, stale: true } - const board = DeliveryBoard({ view, repo: 'o/r', onRetry: () => {} }) - assert.equal(walk(board).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5, 'the old board is still rendered') - const words = text(board) - assert.match(words, /Showing the snapshot from/) - assert.match(words, /refresh failed: GitHub answered 404 for \/repos\/o\/r\/actions\/runs/) - assert.match(staleText('2026-10-25T04:00:00Z', error), /Showing the snapshot from /) -}) - -test('a partial board names the part that was not read, and CI says unknown not zero (#1211)', () => { - const missing = [{ part: 'ci', message: 'GitHub answered 404 for /repos/o/r/actions/runs', status: 404, kind: 'not-found', scope: 'actions' }] - const metrics = { ...snapshot.metrics, today: { ...snapshot.metrics.today, ci: null } } - const view = { ...snapshot, metrics, state: 'partial', missing, partial: true } - const board = DeliveryBoard({ view }) - const words = text(board) - assert.match(words, /CI data was not fetched this time/) - assert.match(words, /CI not available this time/) - assert.doesNotMatch(words, /CI failed 0 of 0/) - assert.equal(walk(board).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - // The rows whose numbers were read still show; the CI one is the only unknown. - const rows = metricRows(metrics) - assert.equal(rows.ci, null) - assert.equal(rows.rows[0].today, '4') - assert.match(missingText(missing[0]), /\/repos\/o\/r\/actions\/runs/) -}) - -test('the diagnostic report prints the five lines the CLI prints (#1211)', () => { - const report = { - repo: 'o/r', at: '2026-10-25T04:00:00Z', - rows: [ - { key: 'repo', ok: true, status: 200, endpoint: '/repos/o/r' }, - { key: 'actions', ok: false, status: 404, endpoint: '/repos/o/r/actions/runs', kind: 'not-found' }, - ], - } - const output = probeText(report) - assert.match(output, /ok 200 {2}\/repos\/o\/r/) - assert.match(output, /FAIL 404 {2}\/repos\/o\/r\/actions\/runs {2}not-found/) -}) - -// A fake clock and a fake timer queue: the cadence is read off the timers the poller asks for, not -// off the wall clock, so the test is about the schedule and nothing else (#1211). -function clock(start = Date.parse('2026-10-25T04:00:00Z')) { - let at = start - let seq = 0 - const timers = new Map() - return { - now: () => at, - setTimer: (fn, ms) => { const id = ++seq; timers.set(id, { at: at + ms, fn }); return id }, - clearTimer: id => { timers.delete(id) }, - pending: () => [...timers.values()].map(timer => timer.at - at), - // Advance to the earliest due timer, run it, and report how far time moved. - advance: async () => { - const next = [...timers.entries()].sort((a, b) => a[1].at - b[1].at)[0] - if (!next) return null - const [id, timer] = next - timers.delete(id) - at = Math.max(at, timer.at) - timer.fn() - await new Promise(resolve => setImmediate(resolve)) - return at - start - }, - } -} - -test('a failed round retries on the host backoff, not on a fixed minute (#1211)', async () => { - const c = clock() - // A host that refuses for three rounds — 10 s, then 30 s, then two minutes — and counts the - // rounds it actually started. The client may read more often; only the rounds matter. - const backoff = [10_000, 30_000, 120_000] - let rounds = 0 - let nextAt = 0 - const started = [] - const load = async () => { - const now = c.now() - if (now < nextAt) return { state: 'failed', error: { message: 'still cooling down', kind: 'network', endpoint: null, status: null, at: null }, retryAt: nextAt } - started.push(now - Date.parse('2026-10-25T04:00:00Z')) - nextAt = now + (backoff[Math.min(rounds, backoff.length - 1)]) - rounds += 1 - return { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: nextAt } - } - const views = [] - const poller = createPoller({ load, onView: view => views.push(view.state), now: c.now, setTimer: c.setTimer, clearTimer: c.clearTimer }) - poller.start() - await new Promise(resolve => setImmediate(resolve)) - // Three advances is exactly the three backoff levels: 10 s, 30 s, then the two-minute one. - for (let i = 0; i < 3; i += 1) await c.advance() - - // The rounds the host really started: now, 10 s, 40 s (30 s later), 160 s (2 min later). - assert.deepEqual(started, [0, 10_000, 40_000, 160_000], 'each backoff level produces its own request') - assert.equal(rounds, 4) - // The screen was told about every read, and a failed read always renders as failed, never as fresh. - assert.ok(views.length >= 4) - assert.ok(views.every(state => state === 'failed')) - poller.stop() -}) - -test('Retry paints loading at once and forces the read (#1211)', async () => { - const c = clock() - const calls = [] - let release = null - const load = async (force) => { - calls.push(force) - if (!force) return { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: c.now() + 120_000 } - await new Promise(resolve => { release = resolve }) - return { state: 'fresh', columns: {}, metrics: null } - } - const views = [] - const poller = createPoller({ load, onView: view => views.push(view.state), now: c.now, setTimer: c.setTimer, clearTimer: c.clearTimer }) - poller.start() - await new Promise(resolve => setImmediate(resolve)) - assert.deepEqual(views, ['failed']) - assert.deepEqual(calls, [false]) - - poller.retry({ state: 'failed', error: { message: 'GitHub answered 503' } }) - // The screen changes before the request comes back: that is what the button looked dead without. - assert.equal(views.at(-1), 'loading') - assert.deepEqual(calls, [false, true]) - release() - await new Promise(resolve => setImmediate(resolve)) - assert.equal(views.at(-1), 'fresh') - poller.stop() -}) - -test('the countdown is the host retryAt, and it says so when there is none (#1211)', () => { - const now = Date.parse('2026-10-25T04:00:00Z') - assert.equal(retryCountdown(now + 12_400, now), 12) - assert.equal(retryCountdown(now - 1_000, now), 0) - assert.equal(retryCountdown(null, now), null) - // The component counts from the real clock, so the fixture's retryAt is relative to it too. - const board = DeliveryBoard({ - view: { state: 'failed', error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, retryAt: Date.now() + 30_000 }, - onRetry: () => {}, - }) - assert.match(text(board), /Retrying in 30 s/) - const withoutAt = DeliveryBoard({ view: { state: 'failed', error: { message: 'offline', kind: 'network' } }, onRetry: () => {} }) - assert.match(text(withoutAt), /retries on its own/) - assert.doesNotMatch(text(withoutAt), /Retrying in/) -}) - -test('the failure countdown moves every second, and only while a retry is scheduled (#1227)', () => { - wall.t = Date.parse('2026-10-25T04:00:00Z') - timers.pending.clear() - const failed = { - state: 'failed', - error: { message: 'GitHub answered 503', kind: 'server', endpoint: '/repos/o/r', status: 503, at: null }, - retryAt: wall.t + 120_000, - } - const board = mount(DeliveryBoard, { view: failed, onRetry: () => {} }) - assert.match(text(board.draw()), /Retrying in 120 s/, 'the host gave two minutes, so the screen says 120') - timers.tick(1_000) - assert.match(text(board.draw()), /Retrying in 119 s/, 'the number moves when the second passes') - // One timer, one second: the countdown is a promise about the screen, not about the request. - assert.equal(timers.pending.size, 1, 'exactly one timer while a retry is scheduled') - assert.equal([...timers.pending.values()][0].ms, 1_000, 'the countdown timer is a one-second one') - - timers.tick(118_000) - assert.match(text(board.draw()), /Retrying in 1 s/, 'and it keeps moving down to the last second') - - // A screen with nothing scheduled gets no timer at all, so a fresh board does not tick. - timers.pending.clear() - const fresh = mount(DeliveryBoard, { view: { ...snapshot, state: 'fresh' } }) - fresh.draw() - assert.equal(timers.pending.size, 0, 'a fresh board does not tick') - wall.t = null -}) - -test('the rate-limit and timeout hints name the page as the condition (#1227)', () => { - const rateLimitNoNumber = hintFor({ kind: 'rate-limit', retryAfterMs: null }) - assert.match(rateLimitNoNumber, /while this page is open it retries on its own/) - const rateLimitNumber = hintFor({ kind: 'rate-limit', retryAfterMs: 30_000 }) - assert.match(rateLimitNumber, /while this page is open it retries on its own in 30 s/) - const timeout = hintFor({ kind: 'timeout' }) - assert.match(timeout, /the next attempt happens while this page is open/) - - // Every "retries on its own" the client can print says who is doing the retrying: the open page. - // Nobody else does — the host installs no timer (#1173). - for (const file of ['src/client/delivery-view.js', 'src/client/i18n.js']) { - const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8') - for (const line of source.split('\n')) { - if (!/retries on its own|next attempt/.test(line)) continue - if (/^\s*\/\//.test(line)) continue - assert.match(line, /this page is open|页面开着时|页面开着/, `${file}: ${line.trim()}`) - } - } -}) - -test('the header says whose repository this is, and where the name came from (#1250)', () => { - const lineOf = payload => walk(DeliveryBoard({ snapshot: { ...snapshot, ...payload }, message: '' })) - .find(node => node.props?.className === 'bt-del-repo') - const configured = lineOf({ repo: 'acme/widget', repoSource: 'config' }) - assert.equal(configured.props['data-repo'], 'acme/widget') - assert.equal(configured.props['data-repo-source'], 'config') - assert.match(text(configured), /acme\/widget/) - assert.match(text(configured), /config/) - // The remote is named as where the host looked, not as something the person set. - const remote = lineOf({ repo: 'acme/widget', repoSource: 'remote' }) - assert.equal(remote.props['data-repo-source'], 'remote') - assert.match(text(remote), /remote/) - // A package default reaches the page labelled as one: that is the whole difference between an - // instance showing its own numbers and showing ours. - const packaged = lineOf({ repo: 'ThinkFlowLab/FlyBot', repoSource: 'package' }) - assert.equal(packaged.props['data-repo-source'], 'package') - assert.match(text(packaged), /default/) - // The Diagnose block names the same repository and source the header does. - assert.match(probeText({ repo: 'acme/widget', repoSource: 'config', at: '2026-10-26T00:00:00Z', rows: [] }), - /acme\/widget · config/) -}) - -test('an unknown open count is —, a real zero is 0 (#1297 INC)', () => { - const zero = { - ...snapshot, - metrics: { ...snapshot.metrics, open: { issues: 0, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: null } }, - } - assert.match(text(DeliveryHeader({ view: zero })), /Open: 0 issues/) - // A partial board answers `metrics: null` (#1297 INC): no repository could be counted, so the - // header prints '—' rather than a zero it never read. Same rule as an unreadable CI number. - const unknown = { - ...snapshot, - state: 'partial', - partial: true, - metrics: null, - repos: [ - { ...snapshot, repo: 'acme/one', repoSource: 'config' }, - { ok: false, state: 'failed', repo: 'acme/two', repoSource: 'config' }, - ], - } - const words = text(DeliveryHeader({ view: unknown })) - assert.match(words, /2 repositories/) - assert.match(words, /Open: — issues/) - assert.doesNotMatch(words, /Open: 0 issues/) -}) - -test('a host that cannot name a repository says so, and does not send the person to a token (#1250)', () => { - const error = { message: 'no repository — set delivery.repo', kind: 'no-repo', scope: 'repo', endpoint: null, status: null, at: null } - assert.equal(hintFor(error, { repo: null }), - 'Set delivery.repo in this instance\'s settings to the repository this instance works in.') - const board = DeliveryBoard({ view: { state: 'failed', repo: null, repoSource: null, error, retryAt: null }, repo: null }) - const words = text(board) - assert.match(words, /no repository — set delivery\.repo/) - assert.match(words, /Set delivery\.repo/) - // Missing settings is not a missing credential; pointing at GITHUB_TOKEN would be the wrong fix. - assert.doesNotMatch(words, /GITHUB_TOKEN/) -}) - -// #1265 step 4: one board with more than one repository. A repository keeps its own block, its own -// columns and its own state, and one that cannot be read no longer empties the page. -const repoSample = (repo, extra = {}) => ({ - repo, repoSource: 'config', state: 'fresh', ok: true, asOf: '2026-10-25T04:00:00Z', - columns: { - 'not-started': [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], - 'in-progress': [], - 'done': [], - }, - metrics: { - dayStart: '2026-10-24T16:00:00.000Z', - today: { newIssues: 1, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: null, medianPullMergeMs: null, ci: { failed: 0, total: 2, counted: 2, superseded: 0 } }, - week: { newIssues: 2, closedIssues: 0, newPullRequests: 0, mergedPullRequests: 0, medianIssueCloseMs: null, medianPullMergeMs: null, ci: { failed: 0, total: 2, counted: 2, superseded: 0 } }, - daily: null, - open: { issues: 1, pullRequests: 0, needsHuman: 0, medianIssueAgeMs: null }, - }, - ...extra, -}) - -test('the visible columns come from the payload through one entry point (#1265, #1326)', () => { - // The payload names the columns it has; the default is the definition's three lanes. - assert.equal(stagesOf({ columns: threeLane.columns }).map(column => column.id).join('|'), 'not-started|in-progress|done') - // An id this build has no label for still renders under its own id rather than disappearing. - assert.equal(stagesOf({ columns: { review: [], triage: [] } }).map(column => column.id).join('|'), 'review|triage') - assert.equal(stagesOf({}).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) -}) - -test('#1326: the stages that draw no lane are filters, and a filter narrows without reordering', () => { - const board = mount(DeliveryBoard, { snapshot: threeLane, message: '' }) - // The definition ships three lanes; review and done-today are stages, so no fourth or fifth lane. - const lanes = (tree) => walk(tree).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => node.props['data-column']) - assert.deepEqual(lanes(board.draw()), ['not-started', 'in-progress', 'done']) - const bar = walk(board.draw()).find(node => node.props?.className === 'bt-del-filters') - assert.ok(bar, 'the board draws one filter bar') - const filterButtons = walk(bar).filter(node => node.type === 'button') - assert.deepEqual(filterButtons.map(node => node.props['data-filter']), ['all', 'review', 'done-today']) - assert.deepEqual(filterButtons.map(node => node.props['aria-pressed']), ['true', 'false', 'false']) - // A finished card keeps the stage it came with, so a stage assertion needs no fifth lane. - const done = walk(board.draw()).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') - assert.equal(walk(done).some(node => node.props?.['data-stage'] === 'done-today'), true) - // Pressing a filter narrows the cards and leaves the columns in their order. - filterButtons[1].props.onClick() - const reviewOnly = board.draw() - assert.deepEqual(lanes(reviewOnly), ['not-started', 'in-progress', 'done']) - const pressed = walk(reviewOnly).find(node => node.props?.className === 'bt-del-filters') - assert.deepEqual(walk(pressed).filter(node => node.type === 'button').map(node => node.props['aria-pressed']), ['false', 'true', 'false']) - const shown = walk(reviewOnly).filter(node => node.type === 'section' && node.props?.['data-column']).map(node => walk(node).filter(item => item.props?.['data-number']).map(item => item.props['data-number'])) - assert.deepEqual(shown, [[], [41], []]) -}) - -test('#1326: a task card is the shared card, and a drop only asks the board to move it', () => { - const moved = [] - const board = mount(DeliveryBoard, { - view: { ...threeLane, state: 'fresh' }, - tasks: [{ id: 'task-abc123456', title: 'Draft the note', status: 'working', ownerBotId: 's', column: 'in-progress', priority: 'high' }], - roster: { byId: { s: { name: 'Writer' } } }, - dragging: 'task-abc123456', - onMove: (id, column) => moved.push([id, column]), - onDrag: (id) => moved.push(['drag', id]), - }) - const tree = board.draw() - const card = walk(tree).find(node => node.props?.['data-task'] === 'task-abc123456') - assert.equal(card.props['data-source'], 'task') - assert.equal(card.props['data-key'], 'task:task-abc123456') - assert.match(text(card), /Draft the note/) - assert.match(text(card), /Writer/, 'the card names the Bot that has the task') - assert.match(text(card), /High/) - // Dragging reports the card, and the drop reports the column it landed on: the host decides, the - // view never writes (#1326). - card.props.onDragStart({ dataTransfer: { setData: () => {} } }) - const done = walk(tree).find(node => node.type === 'section' && node.props?.['data-column'] === 'done') - done.props.onDrop({ preventDefault: () => {}, dataTransfer: { getData: () => 'task-abc123456' } }) - assert.deepEqual(moved, [['drag', 'task-abc123456'], ['task-abc123456', 'done'], ['drag', null]]) -}) - -test('more than one repository draws one block each, one repository draws none (#1265)', () => { - // One repository keeps today's shape: the header names it and no block wrapper is rendered. - const single = DeliveryBoard({ snapshot: { ...snapshot, repo: 'acme/one' }, repo: 'acme/one', message: '' }) - assert.equal(walk(single).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(single).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) - // The host always sends repos[] now, one entry included (#1265 step 4): that shape is still - // today's single board, with the repository named in the header rather than counted. - const singleView = repoSample('acme/one') - const one = DeliveryBoard({ view: { ...singleView, repos: [singleView] } }) - assert.equal(walk(one).filter(node => node.props?.className === 'bt-del-repo-block').length, 0) - assert.equal(walk(one).filter(node => node.type === 'section' && node.props?.['data-column']).length, 3, 'single-repo through repos[] uses workflow columns') - const oneHead = walk(one).find(node => node.props?.className === 'bt-del-repo') - assert.equal(oneHead.props['data-repo'], 'acme/one') - assert.equal(oneHead.props['data-repos'], undefined) - assert.doesNotMatch(text(oneHead), /1 repositories/) - - const board = DeliveryBoard({ - view: { state: 'fresh', repo: null, repoSource: null, asOf: '2026-10-25T04:00:00Z', repos: [repoSample('acme/one'), repoSample('acme/two')] }, - }) - // #1326: Successful repos merge into one shared column set, no per-repo blocks - const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 0, 'no repo blocks for successful repos — all cards in one shared column set') - const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.equal(columns.length, 3, 'one shared column set with 3 workflow columns') - const cards = walk(board).filter(node => node.type === 'article') - // Both repos' cards appear in the shared columns; each card keeps its repo identity - const keys = cards.map(card => card.props['data-key']).sort() - assert.ok(keys.includes('acme/one#10'), 'acme/one card present') - assert.ok(keys.includes('acme/two#10'), 'acme/two card present') - // The shared header counts repositories - const head = walk(board).find(node => node.props?.className === 'bt-del-repo') - assert.equal(head.props['data-repos'], '2') - assert.equal(head.props['data-repo'], undefined) - assert.match(text(head), /2 repositories/) -}) - -test('a failed repository keeps its block, and the readable repository still draws (#1265)', () => { - const broken = repoSample('acme/two', { - state: 'failed', columns: undefined, - error: { message: 'GitHub answered 404 for /repos/acme/two/issues', kind: 'not-found', scope: 'issues', status: 404, endpoint: '/repos/acme/two/issues' }, - }) - const board = DeliveryBoard({ - view: { - state: 'partial', partial: true, repo: null, repoSource: null, - repos: [repoSample('acme/one'), broken], - missing: [{ part: 'issues', repo: 'acme/two', message: 'GitHub answered 404 for /repos/acme/two/issues', status: 404, kind: 'not-found', scope: 'issues' }], - asOf: '2026-10-25T04:00:00Z', - }, - }) - // #1326: Successful repos merge into shared columns; failed repos shown as error blocks - const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') - assert.equal(blocks.length, 1, 'only the failed repo has a block') - assert.equal(blocks[0].props['data-state'], 'failed') - assert.equal(blocks[0].props['data-repo'], 'acme/two') - assert.equal(walk(blocks[0]).some(node => node.type === 'section' && node.props?.['data-column']), false) - assert.match(text(blocks[0]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) - // The successful repo (acme/one) draws in shared columns outside the block - const columns = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) - assert.equal(columns.length, 3, 'shared columns for successful repo') - // The partial line names whose part it was, so one repository's failure is not read as another's. - assert.match(text(board), /acme\/two · issues was not fetched this time/) -}) - -test('multi-repo with tasks: each task appears exactly once (#1326)', () => { - const task1 = { id: 'task-abc123', title: 'First task', status: 'open', ownerBotId: 'bot-one' } - const task2 = { id: 'task-def456', title: 'Second task', status: 'working', ownerBotId: 'bot-one' } - const roster = { byId: { 'bot-one': { id: 'bot-one', name: 'TestBot' } } } - const board = DeliveryBoard({ - view: { - state: 'fresh', - repo: null, - repoSource: null, - asOf: '2026-10-27T05:00:00Z', - repos: [repoSample('acme/one'), repoSample('acme/two')] - }, - tasks: [task1, task2], - roster, - }) - // Each task should appear exactly once, not duplicated per repo - const taskCards = walk(board).filter(node => node.props?.['data-task']) - assert.equal(taskCards.length, 2, 'exactly 2 task cards, not duplicated') - const taskIds = taskCards.map(card => card.props['data-task']) - assert.deepEqual(taskIds.sort(), ['task-abc123', 'task-def456'].sort()) - // GitHub cards from both repos should also be present - const githubCards = walk(board).filter(node => node.props?.['data-kind']) - assert.ok(githubCards.length > 0, 'GitHub cards from repos are present') -}) - -test('no token names the GitHub side, never an empty board (#1211, #1265)', () => { - const error = { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null } - const board = DeliveryBoard({ view: { state: 'failed', repo: null, repos: [], error, retryAt: Date.now() + 10_000 }, onRetry: () => {} }) - const words = text(board) - assert.match(words, /Add a GITHUB_TOKEN/) - assert.match(words, /no GITHUB_TOKEN/) - // A failed read is a sentence with a next step; it is not a board with nothing on it. - assert.doesNotMatch(words, /Nothing here\./) - assert.equal(board.props['data-state'], 'failed') - // More than one repository with every read failed still says one failure, not an empty page. - const all = DeliveryBoard({ - view: { - state: 'failed', repo: null, - error, - repos: [repoSample('acme/one', { state: 'failed', columns: undefined, error }), repoSample('acme/two', { state: 'failed', columns: undefined, error })], - }, - }) - 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/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index 20bb087ee..d3d75028c 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -175,36 +175,22 @@ test('a group column nests subtasks under the overall task', () => { assert.match(textOf(notStarted), /Theirs/) }) -test('one board: no tablist, heading says Board, shows both task and delivery cards (#1326)', () => { - const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => ({ ok: false, reason: 'no-github' }) } +test('the task dialog has no delivery metrics panel (#1363)', () => { + const actions = { closeBoard: () => {}, boardClose: () => {} } states.length = 0 const boardView = expand(KanbanDialog({ roster, actions })) - // No tablist — the Tasks/Delivery split is gone (#1326). - const tabs = all(boardView).filter(node => node.props?.role === 'tab') - assert.equal(tabs.length, 0, 'no tabs — one board shows both task and delivery cards') - const tablist = all(boardView).find(node => node.props?.role === 'tablist') - assert.equal(tablist, undefined, 'no tablist element') - // Heading says "Board", not "Tasks" or "Delivery". + assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) + assert.equal(all(boardView).find(node => node.props?.role === 'tablist'), undefined) const heading = all(boardView).find(node => node.props?.id === 'bt-board-heading') assert.match(textOf(heading), /Board/) - assert.doesNotMatch(textOf(heading), /Tasks/) - assert.doesNotMatch(textOf(heading), /Delivery/) - // The rail is per-Bot; task cards need it, delivery cards do not. assert.equal(byClass(boardView, 'bt-board-rail').length, 1) - // With no GitHub token, task cards still render — the board is never empty (#1211, #1265). - assert.equal(all(boardView).some(node => node.props?.className === 'bt-del-body'), true) + assert.equal(byClass(boardView, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + assert.equal(all(boardView).some(node => String(node.props?.className ?? '').includes('bt-del')), false) + assert.doesNotMatch(textOf(boardView), /from the package|Diagnose|New issues|CI red share/) }) -test('a delivery read with no token still shows task cards on the one board (#1211, #1265, #1326)', () => { - // The two halves meet in one dialog: the delivery board can only say "the GitHub side is not - // available" (core/test/delivery-view.test.js holds that sentence), and it must not take the task - // board with it — the cards are still rendered on the same board. - const failed = { - ok: false, state: 'failed', repo: null, repos: [], - error: { message: 'no GITHUB_TOKEN', kind: 'no-token', scope: 'repo', endpoint: null, status: null, at: null }, - retryAt: null, - } - const actions = { closeBoard: () => {}, boardClose: () => {}, deliveryBoard: async () => failed } +test('the task dialog shows task cards without a GitHub read (#1363)', () => { + const actions = { closeBoard: () => {}, boardClose: () => {} } const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) // scope, dragging, error, draft, then the list the board would have read from the host. states.length = 0 @@ -212,19 +198,31 @@ test('a delivery read with no token still shows task cards on the one board (#12 const boardView = expand(KanbanDialog({ roster, actions })) assert.match(textOf(boardView), /Draft the note/) assert.equal(byClass(boardView, 'bt-board-card').length, 1) - // No tabs — one board shows both (#1326). assert.equal(all(boardView).filter(node => node.props?.role === 'tab').length, 0) }) -test('Start and Complete fire from the unified board path (#1326)', () => { +test('the dialog nests a group subtask under its overall task (#1358)', () => { + const overall = task('task-aaaaaa111111', 'open', { title: 'Ship it', kind: 'group', ownerBotId: 'a', priority: 'high', updatedAt: 5 }) + const child = task('task-bbbbbb222222', 'open', { title: 'Write it', ownerBotId: 's', parentId: overall.id, parentTitle: 'Ship it', priority: 'urgent', updatedAt: 6 }) + const actions = { closeBoard: () => {}, boardClose: () => {} } + // scope, dragging, error, draft, own, groupTasks, archive + states.length = 0 + states.push({ kind: 'group', roomId: 'war' }, null, '', '', [], [overall, child], []) + const boardView = expand(KanbanDialog({ roster, actions })) + const notStarted = byClass(boardView, 'bt-board-column').find(node => node.props['data-column'] === 'not-started') + assert.match(textOf(notStarted), /Ship it/) + assert.match(textOf(notStarted), /Write it/) + assert.equal(all(notStarted).some(node => node.props?.['data-parent'] === overall.id), true) +}) + +test('Start and Complete fire from the task board', () => { const moved = [] const openTask = task('task-open123', 'open', { title: 'Start me', ownerBotId: 's' }) const workingTask = task('task-working456', 'working', { title: 'Complete me', ownerBotId: 's' }) - const actions = { - closeBoard: () => {}, + const actions = { + closeBoard: () => {}, boardClose: () => {}, taskUpdate: async ({ id, column }) => { moved.push([id, column]); return { ok: true } }, - deliveryBoard: async () => ({ ok: false, reason: 'no-github' }), } states.length = 0 // KanbanDialog states: scope, dragging, error, draft diff --git a/patches/ds-bot/files/test/no-delivery-board.test.js b/patches/ds-bot/files/test/no-delivery-board.test.js new file mode 100644 index 000000000..42a8fab9f --- /dev/null +++ b/patches/ds-bot/files/test/no-delivery-board.test.js @@ -0,0 +1,41 @@ +// The GitHub delivery board is gone (#1363): the metrics panel, its host endpoints and the +// probe script. These assertions are red while that surface is still in the tree, and red +// again if a later change puts the panel or either endpoint back. +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { test } from 'node:test' + +const url = (path) => new URL(`../${path}`, import.meta.url) +const read = (path) => readFileSync(url(path), 'utf8') + +const gone = [ + 'src/host/delivery-board.js', + 'src/host/delivery-repo.js', + 'src/client/delivery-view.js', + 'src/client/delivery-chart.js', + 'src/shared/workflow-board.js', + 'scripts/delivery-probe.mjs', +] + +test('#1363: the delivery board files are gone', () => { + for (const path of gone) assert.equal(existsSync(url(path)), false, path) +}) + +test('#1363: the host does not serve delivery-board or delivery-probe', () => { + const channel = read('src/host/channel.js') + const authz = read('src/host/authz.js') + const index = read('index.js') + for (const source of [channel, authz, index]) { + assert.doesNotMatch(source, /delivery-board/) + assert.doesNotMatch(source, /delivery-probe/) + assert.doesNotMatch(source, /deliveryBoard/) + } +}) + +test('#1363: the task board does not mount the delivery view', () => { + const kanban = read('src/client/kanban.js') + const client = read('src/client/index.js') + assert.doesNotMatch(kanban, /delivery-view|DeliveryView|deliveryBoard|DELIVERY_CSS/) + assert.doesNotMatch(client, /delivery-board|delivery-probe|deliveryBoard|deliveryProbe/) + assert.match(kanban, /h\(BoardView/) +}) diff --git a/patches/ds-bot/files/test/workflow-board.test.js b/patches/ds-bot/files/test/workflow-board.test.js deleted file mode 100644 index 4d99d204e..000000000 --- a/patches/ds-bot/files/test/workflow-board.test.js +++ /dev/null @@ -1,100 +0,0 @@ -// 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]) -})