From e9ad1c6a6006c95b3080a5236b660e8919d5c198 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 20:11:27 +0800 Subject: [PATCH 1/2] feat(delivery): read more than one repository on the delivery board (#1265 step 4) `delivery.repos` names a list of repositories; the older `delivery.repo` string still works and is read as a list of one. Every name resolves to its own `{ repo, source }`, so two repositories never share one source line, and the host keeps one snapshot per repository. The board draws one block per repository, each with its own state, and a repository that cannot be read leaves the others on a `partial` body instead of emptying the page. One repository keeps today's DOM: no block wrapper, the header still names it. The visible columns now come from a single entry point, `stagesOf(view)`, so a snapshot that names its own columns decides what is drawn (#1272 can change that source without touching the renderer). Refs #1265, fixes #1293 --- changelog.d/1265-multi-repo.added.md | 1 + core/client.js | 98 +- core/scripts/delivery-probe.mjs | 44 +- core/src/client/delivery-view.js | 93 +- core/src/client/i18n.js | 1 + core/src/host/delivery-board.js | 191 +- core/src/host/delivery-repo.js | 57 +- core/test/delivery-board.test.js | 132 +- core/test/delivery-repo.test.js | 53 +- core/test/delivery-view.test.js | 109 +- core/test/kanban.test.js | 20 + docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 4 +- docs/subsystems/install.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 3 +- patches/ds-bot/0008-client-bundle.patch | 2807 +++++++++-------- .../ds-bot/files/scripts/delivery-probe.mjs | 44 +- .../ds-bot/files/src/client/delivery-view.js | 93 +- .../ds-bot/files/src/host/delivery-board.js | 191 +- .../ds-bot/files/src/host/delivery-repo.js | 57 +- .../ds-bot/files/test/delivery-board.test.js | 132 +- .../ds-bot/files/test/delivery-repo.test.js | 53 +- .../ds-bot/files/test/delivery-view.test.js | 109 +- patches/ds-bot/files/test/kanban.test.js | 20 + 24 files changed, 2758 insertions(+), 1558 deletions(-) create mode 100644 changelog.d/1265-multi-repo.added.md diff --git a/changelog.d/1265-multi-repo.added.md b/changelog.d/1265-multi-repo.added.md new file mode 100644 index 00000000..129acbae --- /dev/null +++ b/changelog.d/1265-multi-repo.added.md @@ -0,0 +1 @@ +The Delivery board can read more than one repository: `delivery.repos` names a list (the older `delivery.repo` still works as a list of one), every name resolves with its own source, and the page draws one block per repository with its own state. A repository that cannot be read leaves the others on a partial board instead of emptying it (#1265, step 4). diff --git a/core/client.js b/core/client.js index 9f61e517..da5d3952 100644 --- a/core/client.js +++ b/core/client.js @@ -1406,6 +1406,7 @@ var HOST_ZH = { // #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)": "来自安装包(默认值)", @@ -8413,6 +8414,15 @@ var DELIVERY_COLUMNS = [ { id: "review", label: "In review" }, { id: "done-today", label: "Done today" } ]; +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", @@ -8467,7 +8477,8 @@ function hintFor(error, { repo = null } = {}) { 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"); - return t("{part} was not fetched this time: {message}", { part, message: `${String(entry?.message ?? "")}${status}` }); + 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 ?? ""); @@ -8603,10 +8614,13 @@ function useDeliveryBoard(actions) { } function probeText(report) { if (!report) return ""; - const lines = [`${report.repo ?? ""}${report.repoSource ? ` · ${report.repoSource}` : ""} · ${timeOf(report.at)}`]; + 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); - lines.push(`${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); + 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"); @@ -8653,6 +8667,9 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = 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", @@ -8661,9 +8678,9 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = // like a fact is what showed one instance another repository's board. (0, import_react16.createElement)( "p", - { className: "bt-del-repo", "data-repo": repoName ?? void 0, "data-repo-source": repoSource ?? void 0 }, - repoName === null ? t("no repository — set delivery.repo") : repoName, - sourceText === null ? "" : ` · ${sourceText}` + { 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", @@ -8679,9 +8696,9 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = 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: open.issues ?? 0, - needs: open.needsHuman ?? 0, - prs: open.pullRequests ?? 0 + 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)( @@ -8696,6 +8713,44 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = ) ); } +function DeliveryColumns({ view, repo = null }) { + const columns = view?.columns ?? {}; + 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 }, + (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((columns[column.id] ?? []).length)) + ), + (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) + )) + ); +} +function DeliveryRepoBlock({ entry }) { + 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 }) + ); +} function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: "fresh" } : { state: "idle" }); const state = payload.state ?? "fresh"; @@ -8709,6 +8764,7 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu return () => clearInterval(timer); }, [ticking, retryAt]); const countdown = retryCountdown(payload.retryAt, now); + const entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; if (state === "idle") { return (0, import_react16.createElement)( "div", @@ -8724,7 +8780,8 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu (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.")) ); } - if (state === "failed" || !payload.columns) { + 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", @@ -8743,7 +8800,6 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu ) ); } - const columns = payload.columns ?? {}; const missing = payload.missing ?? []; return (0, import_react16.createElement)( "div", @@ -8758,20 +8814,10 @@ function DeliveryBoard({ view, snapshot, message = "", repo = null, onRetry = nu ) : null, state !== "stale" && missing.length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-partial", role: "status" }, missing.map((entry) => missingText(entry)).join(" · ")) : null, (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), - (0, import_react16.createElement)( + entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) : (0, import_react16.createElement)( "div", - { className: "bt-del-columns" }, - DELIVERY_COLUMNS.map((column) => (0, import_react16.createElement)( - "section", - { key: column.id, className: "bt-del-column", "data-column": column.id }, - (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((columns[column.id] ?? []).length)) - ), - (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${card.kind}-${card.number}`, card, repo: payload.repo })) - )) + { className: "bt-del-repos" }, + entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) ) ); } @@ -8813,6 +8859,10 @@ var DELIVERY_CSS = ` .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} +.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} +.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} +.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} +.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} .bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} .bt-del-card-top{display:flex;align-items:center;gap:6px} .bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} diff --git a/core/scripts/delivery-probe.mjs b/core/scripts/delivery-probe.mjs index 65dc058c..ef03a659 100644 --- a/core/scripts/delivery-probe.mjs +++ b/core/scripts/delivery-probe.mjs @@ -4,15 +4,18 @@ // 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 repository it reads is the host's own question (#1250), answered by the same -// `resolveRepo()` the board installs: `--repo`, else the `origin` of `--workspace` (default the -// current folder), else the package's own `repository.url`. +// 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, resolveRepo } from '../src/host/delivery-repo.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) @@ -20,25 +23,34 @@ 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 found = resolveRepo({ - setting: argOf('--repo'), +const repos = resolveRepos({ + setting: allOf('--repo'), workspace: argOf('--workspace', process.cwd()), packageSlug: repoSlug(), }) -if (found.repo === null) { +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 repo = found.repo const tokenEnv = argOf('--token-env', 'GITHUB_TOKEN') const token = process.env[tokenEnv] ?? null -const report = await probeGitHub({ repo, token }) - -console.log(`delivery-probe ${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}`}`}`) +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 } -console.log(report.ok ? 'all five answer' : 'one or more of the five is refused; the FAIL line names which') -process.exit(report.ok ? 0 : 1) +process.exit(answered ? 0 : 1) diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index 00d84b51..eb5d5e22 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -40,6 +40,20 @@ export const DELIVERY_COLUMNS = [ { id: 'done-today', label: 'Done today' }, ] +// 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', @@ -107,13 +121,15 @@ export function hintFor(error, { repo = null } = {}) { 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). +// 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') - return t('{part} was not fetched this time: {message}', { part, message: `${String(entry?.message ?? '')}${status}` }) + 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) => { @@ -259,10 +275,13 @@ export function useDeliveryBoard(actions) { // `--probe` CLI prints (#1211). export function probeText(report) { if (!report) return '' - const lines = [`${report.repo ?? ''}${report.repoSource ? ` · ${report.repoSource}` : ''} · ${timeOf(report.at)}`] + 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) - lines.push(`${row.ok ? 'ok' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind ?? ''}`}`) + 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') @@ -302,6 +321,11 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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. @@ -309,9 +333,9 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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': repoName ?? undefined, 'data-repo-source': repoSource ?? undefined }, - repoName === null ? t('no repository — set delivery.repo') : repoName, - sourceText === null ? '' : ` · ${sourceText}`), + 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). @@ -321,7 +345,7 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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: open.issues ?? 0, needs: open.needsHuman ?? 0, prs: open.pullRequests ?? 0, + 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' }, @@ -333,6 +357,35 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on onProbe ? h(Diagnose, { onProbe }) : null)) } +// One repository's columns (#1265 step 4). The card key carries the repository, so the same issue +// number in two repositories is two cards and neither is mistaken for the other. +function DeliveryColumns({ view, repo = null }) { + const columns = view?.columns ?? {} + return h('div', { className: 'bt-del-columns' }, + stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, t(column.label)), + h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), + (columns[column.id] ?? []).length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) +} + +// 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 }) { + 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 })) +} + export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' @@ -349,6 +402,8 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet return () => clearInterval(timer) }, [ticking, retryAt]) const countdown = retryCountdown(payload.retryAt, now) + // 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. if (state === 'idle') { return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, @@ -361,7 +416,10 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet 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.'))) } - if (state === 'failed' || !payload.columns) { + // 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': 'delivery', 'data-state': state }, h('div', { className: 'bt-board-error', role: 'alert' }, @@ -373,7 +431,6 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet : 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 columns = payload.columns ?? {} const missing = payload.missing ?? [] return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, @@ -386,14 +443,10 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet ? h('p', { className: 'bt-del-partial', role: 'status' }, missing.map(entry => missingText(entry)).join(' · ')) : null, h(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), - h('div', { className: 'bt-del-columns' }, - DELIVERY_COLUMNS.map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), - (columns[column.id] ?? []).length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${card.kind}-${card.number}`, card, repo: payload.repo })))))) + entries === null + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) + : h('div', { className: 'bt-del-repos' }, + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) } export function DeliveryView({ actions }) { @@ -438,6 +491,10 @@ export const DELIVERY_CSS = ` .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} +.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} +.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} +.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} +.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} .bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} .bt-del-card-top{display:flex;align-items:center;gap:6px} .bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 8e97123c..b91668eb 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1359,6 +1359,7 @@ const HOST_ZH = { // #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)': '来自安装包(默认值)', diff --git a/core/src/host/delivery-board.js b/core/src/host/delivery-board.js index a3ae1791..1f7517dc 100644 --- a/core/src/host/delivery-board.js +++ b/core/src/host/delivery-board.js @@ -11,7 +11,7 @@ // `{ 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, instanceRepoSetting, resolveRepo } from './delivery-repo.js' +import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] @@ -744,6 +744,144 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = } } +// 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)), + } +} + +const mergeMetrics = (list) => { + if (list.length === 0) 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])), + metrics: mergeMetrics(entries.map(entry => entry.metrics).filter(Boolean)), + } +} + 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` @@ -761,7 +899,7 @@ export function install(rt) { try { return rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { return null } } const setting = () => { - try { return instanceRepoSetting(rt.config) } catch { return null } + 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 @@ -769,14 +907,16 @@ export function install(rt) { 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 cache below is keyed by its answer. + // 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 = resolveRepo({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() }) + 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). - if (found.source === 'package' && !notedDefaults.has(found.repo)) { - notedDefaults.add(found.repo) - warn('ds-bot: delivery board: no delivery.repo and no git remote in the workspace; reading the package repository %s (default)', found.repo) + 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 } @@ -791,27 +931,46 @@ export function install(rt) { // 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, since: null, attempt: 0, elapsedMs: 0, + 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.repo === null) return noRepo() - const board = boardFor(found) + 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)) - return { ...(await board.read({ force: payload?.force === true, wait: payload?.wait === true })), repoSource: found.source } + 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 }) + } + return combineRepos(views) } - // The 诊断 button and `--probe` run this one function (#1211): same reads, same statuses. + // 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.repo === null) return { ok: false, repo: null, repoSource: null, at: new Date().toISOString(), rows: [], error: NO_REPO_MESSAGE } - const board = boardFor(found) + 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)) - try { return { ...(await board.probe()), repoSource: found.source } } catch (error) { - return { ok: false, repo: found.repo, repoSource: found.source, at: new Date().toISOString(), rows: [], error: String(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 index 16ef630e..be7356c7 100644 --- a/core/src/host/delivery-repo.js +++ b/core/src/host/delivery-repo.js @@ -1,12 +1,14 @@ -// Which repository the delivery board reads, and where that name came from (#1250). +// 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. One repository is resolved here, for the host and for `scripts/delivery-probe.mjs` alike, -// in three steps, first answer wins: +// 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.repo` on the plugin entry — the host hands the entry through -// verbatim, so all three spellings people write are read (`instanceRepoSetting`); +// 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. // @@ -111,6 +113,22 @@ export function instanceRepoSetting(config) { 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) @@ -118,13 +136,28 @@ export function gitOriginRepo(folder) { try { return parseRepoName(originUrlIn(readFileSync(path, 'utf8'))) } catch { return null } } -// First answer wins, and `source` travels with the name so the page can say where it came from. -export function resolveRepo({ setting = null, workspace = null, packageSlug = null } = {}) { - const configured = parseRepoName(setting) - if (configured !== null) return { repo: configured, source: 'config' } +// 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' } + if (remote !== null) return [{ repo: remote, source: 'remote' }] const packaged = parseRepoName(packageSlug) - if (packaged !== null) return { repo: packaged, source: 'package' } - return { repo: null, source: null } + 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/test/delivery-board.test.js b/core/test/delivery-board.test.js index bd0daa8f..d40fc62a 100644 --- a/core/test/delivery-board.test.js +++ b/core/test/delivery-board.test.js @@ -10,7 +10,7 @@ import { test } from 'node:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' +import { 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' @@ -957,3 +957,133 @@ test('a repository the token cannot read fails naming the repository and the end 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.raw.map(card => card.number).sort(), [10, 13]) + assert.deepEqual(settled.repos[1].columns.raw.map(card => card.number).sort(), [10, 13]) + assert.equal(settled.columns.raw.length, 4, '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: { raw: [], ready: [], working: [], review: [], 'done-today': [] }, + counts: { raw: 0, ready: 0, working: 0, review: 0, 'done-today': 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 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']]) +}) diff --git a/core/test/delivery-repo.test.js b/core/test/delivery-repo.test.js index 5cb2b59d..6f6c5a90 100644 --- a/core/test/delivery-repo.test.js +++ b/core/test/delivery-repo.test.js @@ -7,7 +7,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { gitConfigPath, gitOriginRepo, instanceRepoSetting, NO_REPO_MESSAGE, originUrlIn, parseRepoName, resolveRepo } from '../src/host/delivery-repo.js' +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 }) @@ -166,16 +166,55 @@ test('the instance setting is read in all three spellings a profile can write it { repo: 'acme/widget', source: 'config' }) }) -test('the probe is a shell over this resolver, and refuses before it asks (#1250)', () => { +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, resolveRepo \} from '\.\.\/src\/host\/delivery-repo\.js'/) - assert.match(source, /resolveRepo\(\{/) + 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 \(found\.repo === null\) \{[\s\S]{0,240}process\.exit\(2\)/) + assert.match(source, /if \(repos\.length === 0\) \{[\s\S]{0,240}process\.exit\(2\)/) assert.match(source, /NO_REPO_MESSAGE/) - // Its order is --repo, then --workspace, then the package: no third source. - assert.match(source, /setting: argOf\('--repo'\)/) + // 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 index f50b627c..9ca91f7e 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -103,7 +103,7 @@ const mount = (Component, props) => { } const { createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, - loadingText, staleText, hintFor, missingText, probeText, + loadingText, staleText, hintFor, missingText, probeText, stagesOf, } = module.exports const text = (node) => { @@ -602,3 +602,110 @@ test('a host that cannot name a repository says so, and does not send the person // 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: { + raw: [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], + ready: [], working: [], review: [], 'done-today': [], + }, + 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)', () => { + assert.equal(stagesOf({ columns: snapshot.columns }).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) + // The snapshot says which columns exist; a definition that ships three draws three, not five. + assert.equal(stagesOf({ columns: { raw: [], working: [], 'done-today': [] } }).map(column => column.id).join('|'), 'raw|working|done-today') + // 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('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, 5) + 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')] }, + }) + const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') + assert.deepEqual(blocks.map(node => [node.props['data-repo'], node.props['data-state']]), + [['acme/one', 'fresh'], ['acme/two', 'fresh']]) + for (const block of blocks) { + assert.equal(walk(block).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + const card = walk(block).find(node => node.type === 'article') + // The same issue number in two repositories is two cards with two identities. + assert.equal(card.props['data-key'], `${block.props['data-repo']}#10`) + } + // Each block's head names the repository and where its name came from; the shared header counts. + assert.match(text(blocks[1]), /acme\/two\s+· from config/) + 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', + }, + }) + const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') + assert.equal(blocks.length, 2, 'a failed repository does not take the board with it') + assert.equal(blocks[1].props['data-state'], 'failed') + assert.equal(walk(blocks[0]).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + assert.equal(walk(blocks[1]).some(node => node.type === 'section' && node.props?.['data-column']), false) + assert.match(text(blocks[1]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + // 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('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\./) +}) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index a22f96f3..5a459279 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -193,3 +193,23 @@ test('the Delivery view drops the Bot rail, while Tasks keeps it (#1173)', () => // No snapshot yet is the honest one-line empty state until the first read lands. assert.match(textOf(deliveryView), /No snapshot yet/) }) + +test('a delivery read with no token leaves the task cards and their tab in place (#1211, #1265)', () => { + // 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 the other view, and its tab still opens them. + 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 } + const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) + // view, scope, dragging, error, draft, then the list the board would have read from the host. + states.length = 0 + states.push('tasks', { kind: 'bot', botId: 's' }, null, '', '', [mine]) + const tasksView = expand(KanbanDialog({ roster, actions })) + assert.match(textOf(tasksView), /Draft the note/) + assert.equal(byClass(tasksView, 'bt-board-card').length, 1) + assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index bd0bddc1..829f5417 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,7 +38,7 @@ 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) 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 **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, 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. Read-only: it renders, it never writes. Tests: `core/test/delivery-view.test.js`, `core/test/kanban.test.js`. | +| `core/src/client/delivery-view.js` | The **Delivery** view of the task dialog (#1173, #1211): five columns (`raw`, `ready`, `working`, `review`, `done-today`) from one host snapshot, 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. Read-only: it renders, it never writes. 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`. | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 811ffff3..11dc9bb8 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,14 +38,14 @@ 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 label (`agent-working` → working, `agent-ready` → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. 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: every read resolves `{ repo, source }` through `delivery-repo.js` — the instance setting `delivery.repo` (read as a nested, a dotted or a flat key, 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 the payload carries `repoSource` (`config` / `remote` / `package`) for the header. One board is kept per repository, so a changed setting starts its new repository 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/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 label (`agent-working` → working, `agent-ready` → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. 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, 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, 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, see **Scheduled tasks: edit, pause, missed runs** 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 repository a delivery read talks to, and where the name came from (#1250). `resolveRepo({ setting, workspace, packageSlug })` answers first-wins: the instance setting, 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. `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/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)). | diff --git a/docs/subsystems/install.md b/docs/subsystems/install.md index e7701543..75397fa5 100644 --- a/docs/subsystems/install.md +++ b/docs/subsystems/install.md @@ -38,7 +38,7 @@ 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/timeline`) and the install commands (core, social, tree; `@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` and `--token-env` (default `GITHUB_TOKEN`); since #1250 it resolves its repository through the host's own `resolveRepo()` (`core/src/host/delivery-repo.js`): `--repo`, else the `[remote "origin"]` of `--workspace ` (default the current folder), else the package's `repository.url` — and it prints that source next to the name, so the CLI and the page cannot disagree. A thin shell, never a second implementation. | +| `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 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 fea679ad..fd26691f 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1221,7 +1221,7 @@ 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,87 @@ const HOST_ZH = { +@@ -905,6 +905,88 @@ 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 不能列出文件夹', @@ -1287,6 +1287,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + // #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)': '来自安装包(默认值)', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d97f0f71..c905adeb 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -734,7 +734,7 @@ 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,88 @@ var HOST_ZH = { +@@ -951,7 +951,89 @@ var HOST_ZH = { "The model wrote nothing": "模型没有输出内容", "Write the entry to save.": "请写下要保存的内容。", "This DeepSeek Harness has no folder chooser": "这个 DeepSeek Harness 没有文件夹选择窗口", @@ -802,6 +802,7 @@ diff --git a/client.js b/client.js + // #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)": "来自安装包(默认值)", @@ -1709,7 +1710,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,3156 +1624,6904 @@ function usageOf(list, ids) { +@@ -1624,3156 +1624,6953 @@ 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 }); } @@ -4825,7 +4826,92 @@ diff --git a/client.js b/client.js + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); +} -+ + +-/* 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/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; +var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; @@ -4896,7 +4982,108 @@ diff --git a/client.js b/client.js + link.remove(); + }; +} -+ + +-.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/icons.js +var import_react5 = require("react"); +function PlusIcon() { @@ -5027,92 +5214,7 @@ 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 }) + ); +} - --/* 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/questions.js +var import_react6 = require("react"); +var keyLetter = (index) => String.fromCharCode(65 + index); @@ -5251,7 +5353,121 @@ diff --git a/client.js b/client.js + ) : null, + foot + ); -+} + } +-@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 SettledQuestion({ question, answer }) { + const options = optionsOf(question); + const prompt = question?.question ?? ""; @@ -5283,7 +5499,15 @@ diff --git a/client.js b/client.js + ); + })) + ); -+} + } +-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 QuestionCard({ matched }) { + const pending = matched; + const questions = pending.questions ?? []; @@ -5297,9 +5521,12 @@ diff --git a/client.js b/client.js + setAnswers(next); + setIndex(index + 1); + return; -+ } + } + pending.answer({ answers: next }); -+ }; + }; +- try { +- visit(sheet.cssRules); +- } catch { + return (0, import_react6.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); +} +function PendingQuestion({ sessionId, question, actions, inline }) { @@ -5331,116 +5558,23 @@ diff --git a/client.js b/client.js + 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; -+ } + } +- return names; + 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, + answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } + }))); -+} - --.bt-thumb{position:absolute;left:0;top:0;z-index:0;pointer-events:none;transition:opacity .14s ease} --.bt-thumb[data-hidden]{opacity:0} --[data-morph]{overflow:hidden!important} --.bt-veil{animation:bt-veil-in .22s ease-out} --.bt-with-icon{display:inline-flex;align-items:center;gap:4px} --.bt-with-icon svg{width:14px;height:14px} --.bt-tag-admin{display:inline-flex;align-items:center;gap:4px} --.bt-create-inline{padding:18px 16px;border:1px solid var(--bt-line);border-radius:12px;background:var(--bt-card)} --.bt-create-inline .bt-create-card{max-width:none} --.bt-panel-view>[data-move=in]{animation:bt-page-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>[data-move=out]{animation:bt-page-out ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} backwards,bt-veil-in .2s ease-out backwards} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(2){animation-delay:25ms} --.bt-panel-view>:is([data-move=in],[data-move=out]):nth-child(n+3){animation-delay:50ms} --.bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} --.bt-drawer-name-input{animation:bt-veil-in .16s ease-out} --.bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} --:is(.bt-soft,.bt-send,.bt-icon-btn,.bt-settings-close,.bt-mini,.bt-tab,.bt-nc-send,.bt-avedit-tab,.bt-shape,.bt-pick,.bt-swatch,.bt-palette-clear){transition:background-color .12s ease,color .12s ease,border-color .12s ease,opacity .12s ease,${spring(SPRING_TAP, "scale")}} --:is(.bt-icon-btn,.bt-settings-close,.bt-nc-send,.bt-shape,.bt-swatch,.bt-color,.bt-x,.bt-q-x,.bt-palette-clear):active:not(:disabled){scale:.9} --:is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} --@media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} --`; --var CONVERSATION_CSS = ` --.bt-think{font-size:13px;line-height:18px;color:var(--bt-ink-2);margin:2px 0 4px;max-width:min(560px,78%)} --.bt-think summary{cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px} --.bt-think summary::-webkit-details-marker{display:none} --.bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} --.bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} --.bt-event>span{padding:0 2px} --.bt-event button{border:0;background:none;color:inherit;font:inherit;display:inline-flex;align-items:center;gap:4px;cursor:pointer;height:24px;padding:4px 6px 4px 4px;border-radius:999px;box-sizing:border-box;transition:background-color .12s ease} --.bt-event button:hover{background:var(--bt-hover)} --.bt-event button:active{background:var(--bt-active)} --.bt-event button.bt-event-whole{min-width:0;max-width:100%;padding:4px 6px 4px 8px} --.bt-event-whole>*{flex:none} --.bt-event-whole>span:first-child{margin-right:2px} --/* One button cannot wrap like separate parts, so a long name is cut to fit the chat. */ --.bt-event-whole>.bt-event-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-time-sep{display:flex;justify-content:center;align-items:center;height:28px;margin:14px 0 8px;font-size:12px;line-height:16px;color:var(--bt-ink-2);white-space:nowrap} --[class*="_scroll"]>[class*="_column"]::before{content:var(--bt-first-sep,none);display:block;flex:none;height:28px;margin:17px 0 12px;font-size:12px;line-height:28px;text-align:center;color:var(--bt-ink-2);white-space:nowrap} --.bt-activity{display:flex;align-items:center;gap:10px;margin:8px 0 4px 2px;font-size:14px;color:var(--bt-ink-2)} --.bt-group-replies{display:contents} --/* A Bot chat is flat in every work-details mode, without changing the user's mode -- (it is a DSH-wide setting): step groups lose their header and stay open, and what a -- mode folds away in a finished turn stays shown. TurnProcessCell also opens that -- fold; these rules cover the first frame before it does. */ --[data-step-process]>div:first-child{display:none!important} --[data-step-process]>[data-step-process-body]{display:block!important;content-visibility:visible!important;max-height:none!important;overflow:visible!important;mask-image:none!important;scrollbar-gutter:auto!important} --:is([data-step-process],[data-turn-process-hidden])[hidden]{content-visibility:visible!important} --[class*="_flowItem"]:has(> [data-slot="conversation.chat.node"]:empty){display:none!important} --[class*="_header"]:has(.bt-head){border-bottom-color:transparent!important;box-shadow:none!important} --/* The transcript runs under a floating header pill. */ --header[class*="_header"]:has(.bt-head){position:absolute!important;top:0;left:0;right:0;z-index:8;display:flex!important;padding:10px 0 0!important;background:transparent!important;pointer-events:none;justify-content:center} --header[class*="_header"]:has(.bt-head)::before{content:"";position:absolute;inset:0 0 auto;height:60px;background:linear-gradient(to bottom,var(--bt-main),color-mix(in srgb,var(--bt-main) 0%,transparent));pointer-events:none;z-index:-1} --header[class*="_header"]:has(.bt-head)>[class*="_headerLeading"]{position:absolute;left:12px;top:10px;pointer-events:auto} --header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} --[class*="_body"]:has(> [class*="_scrollBody"]){--dsh-chat-content-width:min(960px,100%)} --/* DSH's turn tail keeps what plugins put in it (delivered files, the Bot's separators), -- without its copy and branch row; a tail with nothing in it takes no room. */ --[data-turn-tail]>:not([data-slot="conversation.chat.turnTail"]){display:none!important} --[class*="_flowItem"][data-chat-flow-kind="turn-tail"]:not(:has([data-slot="conversation.chat.turnTail"]:not(:empty))){display:none!important} --/* Every team Session belongs to a Bot or a group, so a blank one is an empty chat, not -- the shell's new-session hero: no headline, no workspace or mode picker, and the -- composer stays docked at the bottom. */ --[data-content-phase=hero] [class*="_scrollBody"]{justify-content:flex-end!important} --[data-content-phase=hero] [class*="_composerHero"]{align-self:stretch!important;width:auto!important;padding-bottom:0!important} --[data-content-phase=hero] [class*="_composerHero"]>[class$="_root"],[data-content-phase=hero] [class*="_heroWorkspaceRow"]{display:none!important} --[data-content-phase=hero] [class*="_composerHero"] [class*="_input"]{min-height:0!important} --/* The right sides give back the scrollbar gutter so both edges sit 16px from the column. */ --[class*="_scroll"]:has(> [class*="_column"]){padding:60px 11px 16px 16px!important} --/* The shell's running status only hosts the activity row (.bt-typing). */ --[data-chat-running]{display:block!important;width:100%;min-height:0!important;margin:0!important;padding:0!important} --[data-chat-running]>span:not([role=status]){display:none!important} --[data-chat-running]:has(> .bt-typing){margin-top:var(--dsh-chat-flow-gap,12px)!important} --.bt-typing{display:flex;align-items:center;gap:12px;height:36px;min-width:0;padding-inline-start:8px;transform-origin:0 50%;animation:bt-typing-in ${SPRING_TAP.duration}ms ${SPRING_TAP.easing} backwards} --.bt-typing[data-exiting]{animation:bt-typing-out .14s cubic-bezier(.22,1,.36,1) forwards} --.bt-typing>.bt-mark,.bt-typing>.bt-cluster{flex-shrink:0} --.bt-typing>.bt-mark{cursor:pointer;transition:filter .16s ease-out} --.bt-typing>.bt-mark:hover{filter:drop-shadow(0 2px 4px rgba(0,0,0,.18))} --.bt-typing>.bt-mark .bt-eyes{transition:scale .28s cubic-bezier(.4,.06,.18,1)} --.bt-typing>.bt-mark:hover .bt-eyes{scale:1.18} --.bt-typing-text{display:flex;align-items:center;flex:1 1 auto;min-width:0;animation:bt-typing-label .22s ease-out .2s backwards} --.bt-typing-label{display:inline-flex;align-items:center;gap:6px;min-width:0;animation:bt-typing-label .22s ease-out backwards} --.bt-typing-label>.bt-shimmer{font-size:14px;line-height:22px;font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} --.bt-typing-time{flex-shrink:0;font-size:12px;line-height:22px;color:var(--bt-ink-3);white-space:nowrap} --@keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} --@keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} --@keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} --/* Shimmer: a stepped sweep clipped to the text. */ --.bt-shimmer{color:transparent;background:linear-gradient(90deg,var(--bt-shimmer-base) 0%,var(--bt-shimmer-base) 25%,var(--bt-ink) 60%,var(--bt-shimmer-base) 75%,var(--bt-shimmer-base) 100%);background-size:200% 100%;-webkit-background-clip:text;background-clip:text;animation:bt-shimmer 2.2s steps(60) infinite;--bt-shimmer-base:color-mix(in srgb,var(--bt-ink) 40%,transparent)} --@keyframes bt-shimmer{from{background-position:200% 0}to{background-position:-200% 0}} --/* New rows slide up; a reply that replaces the activity row grows out of its corner. */ --[data-bt-enter="new"]{animation:bt-row-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} --[data-bt-enter="after-collapse"]{transform-origin:0 100%;animation:bt-row-after .24s cubic-bezier(.23,1,.32,1) .38s backwards} --@keyframes bt-row-in{from{transform:translateY(12px)}to{transform:none}} --@keyframes bt-row-after{0%{opacity:0;transform:translateY(12px) scale(.94)}55%{opacity:1}100%{opacity:1;transform:none}} --@media (prefers-reduced-motion:reduce){ --.bt-typing,.bt-typing[data-exiting]{animation-name:bt-fade-in;animation-duration:.12s;transform:none} --.bt-typing[data-exiting]{animation-name:bt-fade-out} --.bt-typing-text,.bt-typing-label{animation-name:bt-fade-in;animation-delay:0s} --.bt-shimmer{animation:none;background:none;color:var(--bt-ink-2)} --[data-bt-enter="new"]{animation:none} --[data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} + } +-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); ++ +// src/client/secret-card.js +var import_react7 = require("react"); +var MIN_VALUE = 4; @@ -5493,9 +5627,32 @@ diff --git a/client.js b/client.js + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + onEnter?.(); -+ } -+ }); -+} + } +- return compiled.get(fragment); + }); + } +-function installCompiledStyles(text, tag) { +- const style = installStyles("", tag); +- const cache = /* @__PURE__ */ new WeakMap(); +- const render = () => { +- const names = /* @__PURE__ */ new Set(); +- for (const sheet of document.styleSheets) { +- if (sheet.ownerNode?.dataset?.dshBot) continue; +- let list = cache.get(sheet); +- if (!list) { +- list = sheetClassNames(sheet); +- cache.set(sheet, list); +- } +- for (const name of list) names.add(name); +- } +- const next = compileFragmentSelectors(text, names); +- if (style.textContent !== next) style.textContent = next; +- }; +- render(); +- let timer; +- const schedule = () => { +- clearTimeout(timer); +- timer = setTimeout(render, 120); +function ScopeSwitch({ value, onChange, bot }) { + const options = [["all", t("All my Bots")], ["bot", t("Only {name}", { name: bot.name })]]; + return (0, import_react7.createElement)( @@ -5538,15 +5695,25 @@ 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)); -+ }; + }; +- const onLoad = (event) => { +- if (event.target instanceof HTMLLinkElement) schedule(); + 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 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(); + const allow = () => { + if (!busy) void run(actions.secretAllow(request.id)); -+ }; + }; + const filling = request.kind === "fill" || replacing; + const warn = insecure() ? (0, import_react7.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react7.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; + return (0, import_react7.createElement)( @@ -5584,7 +5751,44 @@ diff --git a/client.js b/client.js + filling ? (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react7.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) + ) + ); -+} + } +- +-// 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 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 }); @@ -5604,7 +5808,32 @@ diff --git a/client.js b/client.js + canceled ? (0, import_react7.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react7.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react7.createElement)(CheckIcon)) + ) + ); -+} + } +-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 RequestView({ bot, request, roster, actions }) { + return request.status === "pending" ? (0, import_react7.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react7.createElement)(SettledSecret, { bot, request, roster }); +} @@ -5663,114 +5892,7 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same2, onClick: () => onSave(next) }, t("Save access")) + ) + ); - } --@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 ValueEditor({ secret, onSave }) { + const [value, setValue] = (0, import_react7.useState)(""); + const [replaced, setReplaced] = (0, import_react7.useState)(false); @@ -5783,7 +5905,16 @@ diff --git a/client.js b/client.js + setReplaced(true); + } + }); -+ }; + }; +- 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" }) + const edit = (next) => { + setValue(next); + setReplaced(false); @@ -5799,8 +5930,10 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) + ), + value !== "" && !long ? (0, import_react7.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react7.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react7.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null -+ ); -+} + ); + } +-function 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 SecretRow({ secret, roster, actions, run, open, onToggle }) { + const [confirm, setConfirm] = (0, import_react7.useState)(false); + (0, import_react7.useEffect)(() => { @@ -5843,12 +5976,17 @@ diff --git a/client.js b/client.js + ) + ) : null + ); -+} -+function AddSecret({ actions, run, onDone }) { -+ const [name, setName] = (0, import_react7.useState)(""); -+ const [purpose, setPurpose] = (0, import_react7.useState)(""); -+ const [value, setValue] = (0, import_react7.useState)(""); -+ const ready = /^[A-Za-z][\w-]{0,63}$/.test(name.trim()) && value.trim().length >= MIN_VALUE; + } +-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 AddSecret({ actions, run, onDone }) { ++ const [name, setName] = (0, import_react7.useState)(""); ++ const [purpose, setPurpose] = (0, import_react7.useState)(""); ++ const [value, setValue] = (0, import_react7.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) => { @@ -5871,8 +6009,17 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: onDone }, t("Cancel")), + (0, import_react7.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !ready, onClick: save }, t("Save key")) + ) -+ ); -+} + ); + } +-function useSettled(state) { +- const prev = (0, import_react2.useRef)(state); +- const [settled, setSettled] = (0, import_react2.useState)(false); +- (0, import_react2.useEffect)(() => { +- const was = prev.current; +- prev.current = state; +- if (state !== "idle") { +- setSettled(false); +- return void 0; +function SecretsPage({ roster, actions, run, Section }) { + const [open, setOpen] = (0, import_react7.useState)(null); + const [adding, setAdding] = (0, import_react7.useState)(false); @@ -5959,13 +6106,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}} - `; --function installStyles(text, tag) { -- const style = document.createElement("style"); -- style.dataset.dshBot = tag; -- style.textContent = text; -- document.head.appendChild(style); -- return style; ++`; + +// src/client/face-seats.js +function faceSeats(room2, roster, { left = false } = {}) { @@ -6017,14 +6158,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"); @@ -6034,7 +6201,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 }; + } @@ -6083,8 +6264,68 @@ 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)); +- }) +- ); +-} +- +-// src/client/icons.js +-var import_react3 = require("react"); +-function PlusIcon() { +- return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function SearchIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +-} +-function PeopleIcon() { +- return (0, import_react3.createElement)( +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), +- (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), +- (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) +- ); +-} +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +-} +-function MicIcon({ size = 16 }) { +- return (0, import_react3.createElement)( +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), +- (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) +- ); +-} +-function 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 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" }) + const plain = (0, import_react8.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, @@ -6102,15 +6343,33 @@ 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 -+ ); -+} + ); + } +-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" })); + +// 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 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" })) +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(); @@ -6160,8 +6419,34 @@ diff --git a/client.js b/client.js + }, emoji))) : null + ); + }) -+ ); -+} + ); + } +-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 WAITING_OTHERS_NOTICE = "Waiting for the owner's approval"; +var REACTIONS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; +function leaveNoticeCoveredByClose(room2, text) { @@ -6175,7 +6460,31 @@ diff --git a/client.js b/client.js + if (name && (who === name || who.endsWith(` (${name})`))) return true; + } + return false; -+} + } +-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; +function useDismiss(open, close, refs) { + (0, import_react9.useEffect)(() => { + if (!open) return void 0; @@ -6183,12 +6492,25 @@ diff --git a/client.js b/client.js + if (refs.some((ref) => ref.current?.contains(event.target))) return; + close(); + }; -+ const onKey = (event) => { + 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(); + if (event.key === "Escape") { + event.stopPropagation(); + close(); -+ } -+ }; + } + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey, true); + return () => { @@ -6196,7 +6518,19 @@ diff --git a/client.js b/client.js + window.removeEventListener("keydown", onKey, true); + }; + }, [open]); -+} + } +-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]); +function MessageTools({ text, name, sessionId, react, actions }) { + const [panel, setPanel] = (0, import_react9.useState)(null); + const [place, setPlace] = (0, import_react9.useState)(null); @@ -6208,19 +6542,62 @@ diff --git a/client.js b/client.js + const open = (kind, event) => { + if (panel === kind) { + close(); -+ return; -+ } + return; + } +- onAnswer({ selected: [label] }); + 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 submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); + const copy2 = () => { + void navigator.clipboard?.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1e3); + }); -+ }; + }; +- 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 + 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` }); @@ -6354,8 +6731,19 @@ diff --git a/client.js b/client.js + (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) => { @@ -6381,20 +6769,7 @@ diff --git a/client.js b/client.js + const held = heldForm(item.text); + return held !== "" && (held === line2 || held.length >= 799 && line2.startsWith(held)); + }); - } --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); -- } -- }; -- try { -- visit(sheet.cssRules); -- } catch { ++} +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; @@ -6410,13 +6785,28 @@ diff --git a/client.js b/client.js + 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 names; +- 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)( + if (held === "held" || isHeldLine(room2, text, bot)) return null; + const lead = (0, import_react9.createElement)( + "button", @@ -6435,16 +6825,7 @@ diff --git a/client.js b/client.js + name, + (0, import_react9.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); - } --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 remoteSpeaker(source, roster, sessionId) { + const handle = source.remote.handle; + const member = (roster.remoteBots ?? []).find((bot2) => bot2.handle === handle); @@ -6457,7 +6838,7 @@ diff --git a/client.js b/client.js + ownerName: member?.homeName || member?.owner || chat?.remote?.homeName || chat?.remote?.owner, + handle, + isChief: member?.isChief ?? chat?.remote?.isChief - }); ++ }); + const bot = seat ? lookBotForRemote({ handle, displayName: name, ...seat }, roster) : chat ?? lookBotForRemote({ + handle, + displayName: name, @@ -6485,7 +6866,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-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 + { 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)( @@ -6493,7 +6881,8 @@ diff --git a/client.js b/client.js + { className: "bt-group-name", title: handle }, + person, + carrier ? (0, import_react9.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : 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)) + (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 + ); @@ -6509,89 +6898,19 @@ diff --git a/client.js b/client.js + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + name, + (0, import_react9.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react9.createElement)(GlobeMark), handle) -+ ), + ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( + (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 installCompiledStyles(text, tag) { -- const style = installStyles("", tag); -- const cache = /* @__PURE__ */ new WeakMap(); -- const render = () => { -- const names = /* @__PURE__ */ new Set(); -- for (const sheet of document.styleSheets) { -- if (sheet.ownerNode?.dataset?.dshBot) continue; -- let list = cache.get(sheet); -- if (!list) { -- list = sheetClassNames(sheet); -- cache.set(sheet, list); -- } -- for (const name of list) names.add(name); -- } -- 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(); -- }; ++} +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react9.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react9.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); - } -- --// 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 GlobeMark() { + return (0, import_react9.createElement)( + "svg", @@ -6599,32 +6918,7 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react9.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) + ); - } --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 RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react9.useState)(""); + const [error, setError] = (0, import_react9.useState)(""); @@ -6644,10 +6938,7 @@ diff --git a/client.js b/client.js + } finally { + setBusy(""); + } - }; -- 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) } })); ++ }; + return (0, import_react9.createElement)( + "div", + { className: "bt-file-chips" }, @@ -6658,22 +6949,15 @@ diff --git a/client.js b/client.js + )), + error ? (0, import_react9.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null + ); - } --function StarIcon({ size = 8 }) { -- return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++} +function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react9.createElement)( + "div", + { className: "bt-event" }, + (0, import_react9.createElement)("span", null, prefix), + room2 ? (0, import_react9.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react9.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react9.createElement)("span", null, name) : null - ); - } --function 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 MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; @@ -6687,12 +6971,7 @@ 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 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 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); +} @@ -6755,7 +7034,12 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-earlier", role: "feed", "aria-label": t("Earlier messages"), "aria-busy": view.loading }, + (0, import_react9.createElement)( -+ "div", + "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 + { ref: top, className: "bt-earlier-top" }, + view.loading ? (0, import_react9.createElement)(Shimmer, null, t("Loading earlier messages…")) : view.failed ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Couldn’t load earlier messages · Retry")) : view.cursor ? (0, import_react9.createElement)("button", { type: "button", className: "bt-soft", onClick: () => load(view.cursor) }, t("Load earlier")) : null + ), @@ -6790,15 +7074,21 @@ diff --git a/client.js b/client.js + (0, import_react9.createElement)(PartLine, { time: view.startedAt }) ); } --function useSettled(state) { -- const prev = (0, import_react2.useRef)(state); -- const [settled, setSettled] = (0, import_react2.useState)(false); -- (0, import_react2.useEffect)(() => { -- const was = prev.current; -- prev.current = state; -- if (state !== "idle") { -- setSettled(false); -- return void 0; +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) +- ) +- ); +function TriggerCell({ node, sessionId, useRoster, useSessions, useTurnClock, useUi, actions }) { + const roster = useRoster((value) => value); + const data = node.data; @@ -6833,13 +7123,7 @@ diff --git a/client.js b/client.js + if (source.remote?.handle) { + const message = (0, import_react9.createElement)(RemoteMessage, { source, text: remoteMessageText(stripHeader(contentText(data.content))), roster, actions, sessionId, msgKey: `${sessionId}:${data.turn}:remote`, useUi }); + return source.remote.firstContact === true ? (0, import_react9.createElement)(import_react9.Fragment, null, (0, import_react9.createElement)(FirstContact, { source, roster, sessionId }), message) : message; - } -- if (was === "idle" || was === "alert") return void 0; -- setSettled(true); -- const timer = setTimeout(() => setSettled(false), 1800); -- return () => clearTimeout(timer); -- }, [state]); -- return settled; ++ } + if (source.kind === "bot") { + if (source.handoff === true) return roster.roomsById[sessionId] ? null : (0, import_react9.createElement)(EarlierParts, { sessionId, roster, actions }); + if (source.role === "room" || source.role === "kickoff") return null; @@ -6884,33 +7168,10 @@ diff --git a/client.js b/client.js + if (source.kind === "user-question-reply") return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + } - } --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 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 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]]; -- } -- const half = frame / 2; -- return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; ++} +function TurnProcessCell({ node, sessionId, turnProcess, useRoster, useUi, useTurnClock, actions }) { + const open = turnProcess === void 0 || !turnProcess.foldable || turnProcess.open; + (0, import_react9.useEffect)(() => { @@ -6928,19 +7189,7 @@ diff --git a/client.js b/client.js + return !hidden && typeof visit?.seen === "number" && own?.first !== void 0 && own.first <= visit.seen && own.end !== void 0 && own.end > visit.seen && own.end <= visit.entered; + }); + return fresh ? (0, import_react9.createElement)(NewDivider) : null; - } --function 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 Nothing() { + return null; +} @@ -6993,32 +7242,41 @@ diff --git a/client.js b/client.js + if (block?.type === "tool-removal" && block.toolName) parts.push(`Tool removed: ${block.toolName}`); + else if (block?.type === "tool-addition" && block.toolName) parts.push(`Tool added: ${block.toolName}`); } -- 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)); -- }) +- 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)) +- ); +- })) - ); + if (parts.length === 0) return null; + return (0, import_react9.createElement)("div", { className: "bt-event" }, parts.join(" · ")); } -- --// 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 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 }); +var SEPARATOR_GAP_MS = 30 * 60 * 1e3; +function clockLabel(time, now = Date.now()) { + const date = new Date(time); @@ -7030,8 +7288,22 @@ 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 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 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 nextTurnStart(turns, turn) { + let next; + for (const key in turns) { @@ -7040,14 +7312,12 @@ diff --git a/client.js b/client.js + } + return next === void 0 ? void 0 : turns[next].first; } --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 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 openingTurn(turns, firstTurns, hiddenTurns) { + if (!turns || !firstTurns) return null; + const start = nextTurnStart(turns, -Infinity); @@ -7057,23 +7327,55 @@ 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 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 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; +var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +function movedOnSince(visit, turns, hiddenTurns) { + if (typeof visit?.entered !== "number") return false; + for (const key in turns ?? {}) { + if (hiddenTurns?.includes(Number(key))) continue; + if (turns[key].first !== void 0 && turns[key].first > visit.entered) return true; -+ } + } +- 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: [] } +- }))); + 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" }) +- +-// 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 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; @@ -7097,53 +7399,65 @@ diff --git a/client.js b/client.js + null, + label ? (0, import_react9.createElement)("div", { className: "bt-time-sep", role: "separator" }, label) : null, + fresh ? (0, import_react9.createElement)(NewDivider) : null - ); - } --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 Shimmer({ children }) { + return (0, import_react9.createElement)("span", { className: "bt-shimmer" }, children); - } --function ShareIcon() { -- return (0, import_react3.createElement)( -- "svg", -- { width: 16, height: 16, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, -- (0, import_react3.createElement)("path", { d: "M10 12.5V3M6.5 6.5L10 3l3.5 3.5M4 11v4.5A1.5 1.5 0 0 0 5.5 17h9a1.5 1.5 0 0 0 1.5-1.5V11" }) -- ); ++} +var 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": + }; +-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 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" }; @@ -7172,56 +7486,97 @@ diff --git a/client.js b/client.js + return { label: phrase ? t(phrase) : null, state: toolState(name) }; + } } --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 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 workPhrase(tool) { + if (!tool) return t("Thinking about it"); + return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("Using {tool}", { tool }); } --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 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 workText(tool, step) { + return step ? t("{action}: {detail}", { action: workPhrase(tool), detail: step }) : `${workPhrase(tool)}…`; } --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 SecretCard({ bot, request, roster, actions }) { +- const [value, setValue] = (0, import_react5.useState)(""); +- const [scope, setScope] = (0, import_react5.useState)("all"); +- const [replacing, setReplacing] = (0, import_react5.useState)(false); +- const [busy, setBusy] = (0, import_react5.useState)(false); +- const [error, setError] = (0, import_react5.useState)(""); +- const noteId = (0, import_react5.useId)(); +- const secret = (roster.secrets ?? []).find((entry) => entry.name === request.name); +- const run = (promise) => { +- setBusy(true); +- setError(""); +- return Promise.resolve(promise).then(() => setValue(""), (failure) => setError(failure?.message ?? String(failure))).finally(() => setBusy(false)); +- }; +- const cancel = () => run(actions.secretCancel(request.id)); +- const long = value.trim().length >= MIN_VALUE; +- const save = () => { +- if (long && !busy) void run(actions.secretSet({ requestId: request.id, value, scope })); +- }; +- const allow = () => { +- if (!busy) void run(actions.secretAllow(request.id)); +- }; +- const filling = request.kind === "fill" || replacing; +- const warn = insecure() ? (0, import_react5.createElement)("div", { className: "bt-secret-warn", role: "note" }, (0, import_react5.createElement)(WarnIcon), t("This page is not on HTTPS: the key crosses the network unencrypted.")) : null; +- return (0, import_react5.createElement)( +- "div", +- { className: "bt-q bt-secret", role: "group", "aria-describedby": noteId, "data-kind": request.kind }, +- (0, import_react5.createElement)(SecretHead, { +- bot, +- title: request.kind === "allow" ? t("Let {bot} use {name}?", { bot: bot.name, name: request.name }) : t("{name} needs a key", { name: bot.name }), +- detail: request.purpose, +- name: request.name, +- onClose: busy ? void 0 : cancel +- }), +- request.kind === "allow" ? (0, import_react5.createElement)("div", { className: "bt-secret-saved" }, (0, import_react5.createElement)(KeyIcon), t("Saved for {scope}", { scope: scopeLabel(secret?.scope, roster) })) : null, +- filling ? (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-body" }, +- (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: setValue, onEnter: save, label: t("Value of {name}", { name: request.name }), autoFocus: replacing })), +- request.kind === "fill" ? (0, import_react5.createElement)(ScopeSwitch, { value: scope, onChange: setScope, bot }) : null +- ) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-secret-note", id: noteId }, +- t("{bot} never sees the value. It reaches {bot}'s shell as ", { bot: bot.name }), +- (0, import_react5.createElement)("code", null, `$DSH_SECRET_${request.name}`), +- ".", +- filling && value !== "" && !long ? t(" At least {count} characters.", { count: MIN_VALUE }) : "" +- ), +- warn, +- error ? (0, import_react5.createElement)("div", { className: "bt-secret-error", role: "alert" }, error) : null, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-foot" }, +- request.kind === "allow" && !replacing ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: () => setReplacing(true) }, t("Replace value")) : null, +- (0, import_react5.createElement)("button", { type: "button", className: "bt-q-btn", disabled: busy, onClick: cancel }, t("Cancel")), +- filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) - ) - ); +function useLiveNote(actions, sessionId, id, entry) { @@ -7232,41 +7587,10 @@ diff --git a/client.js b/client.js + return () => actions.noteLive(sessionId, id, null); + }, [sessionId, id, key]); } --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 SettledSecret({ bot, request, roster }) { +- const canceled = request.status === "canceled"; +- const text = canceled ? t("{bot} asked for {name}", { bot: bot.name, name: request.name }) : request.status === "allowed" ? t("Allowed {bot} to use {name}", { bot: bot.name, name: request.name }) : t("Saved {name}", { name: request.name }); +- return (0, import_react5.createElement)( +function GroupReplies({ sessionId, callId, done, content, roster, actions, useUi }) { + const [live, setLive] = (0, import_react9.useState)({ replies: [] }); + (0, import_react9.useEffect)(() => { @@ -7282,50 +7606,9 @@ diff --git a/client.js b/client.js + if (entry && key !== seen) { + seen = key; + setLive({ replies: entry.replies ?? [] }); - } ++ } + timer = setTimeout(tick, document.hidden ? 5e3 : 1e3); - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); --} --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++ }; + void tick(); + return () => { + stop = true; @@ -7338,114 +7621,25 @@ diff --git a/client.js b/client.js + replies = JSON.parse(contentText(content)).replies ?? []; + } catch { + replies = []; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (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")) -- ) -- ); - } -- return (0, import_react4.createElement)( ++ } ++ } + if (replies.length === 0) return null; + const room2 = roster.roomsById[sessionId]; + return (0, import_react9.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)) -- ); -- })) +- { className: "bt-q bt-q-settled bt-secret bt-secret-done", role: "group", "aria-label": text, "data-status": request.status }, +- (0, import_react5.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react5.createElement)("span", { className: "bt-secret-who" }, (0, import_react5.createElement)(BotMark, { bot, size: 22 })), +- (0, import_react5.createElement)("div", { className: "bt-q-text" }, (0, import_react5.createElement)( +- "p", +- { className: "bt-q-title" }, +- text, +- canceled ? null : (0, import_react5.createElement)("span", { className: "bt-secret-scope-tag" }, ` · ${scopeLabel(request.scope, roster)}`) +- )), +- canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) +- ) + { className: "bt-group-replies" }, + replies.map((reply, index) => { + const stored = roster.byId[reply.botId]; @@ -7528,34 +7722,8 @@ diff --git a/client.js b/client.js + }) ); } --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 }); --} --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); +-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 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) { @@ -7567,21 +7735,17 @@ diff --git a/client.js b/client.js + seen.add(id); + calls.push(call4); + for (const child of call4.subCalls ?? []) visit(child); - }; -- return (0, import_react4.createElement)( -- "div", -- { className: inline ? "bt-q-inline" : "bt-q-dock" }, -- (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) -- ); -+ visit(root); -+ return calls; - } --function 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 }); ++ }; ++ visit(root); ++ return calls; + } +-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 useShownCards(actions, sessionId, callIds) { + const key = JSON.stringify(callIds); + (0, import_react9.useLayoutEffect)(() => { @@ -7590,12 +7754,7 @@ diff --git a/client.js b/client.js + actions.noteCards(sessionId, ids, 1); + return () => actions.noteCards(sessionId, ids, -1); + }, [sessionId, key]); - } --function AnsweredCell({ node }) { -- const data = node.data ?? {}; -- const questions = Array.isArray(data.questions) ? data.questions : []; -- if (questions.length === 0) { -- return data.text ? (0, import_react4.createElement)("div", { className: "bt-urow" }, (0, import_react4.createElement)("div", { className: "bt-ububble" }, data.text)) : null; ++} +function renderCall(call4, { sessionId, roster, actions, useUi, findBot }) { + const tool = toolName(call4); + const done = call4.kind === "tool-result"; @@ -7672,15 +7831,19 @@ diff --git a/client.js b/client.js + default: + return 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: [] } -- }))); -+} +- return null; + } +-function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { +function ToolCell({ node, sessionId, useRoster, useUi, actions }) { -+ const roster = useRoster((value) => value); + 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 }))); + const root = node.data.root; + const turn = node.location?.turn?.turn ?? root.turn; + const hidden = isHiddenTurn(roster, sessionId, turn); @@ -7694,36 +7857,8 @@ diff --git a/client.js b/client.js + if (drawn.length === 0) return null; + 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)))); - } - --// 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 ")); ++} ++ +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { @@ -7764,7 +7899,39 @@ diff --git a/client.js b/client.js + return () => clearTimeout(timer); + }, [shown, at]); + return shown; -+} + } +-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 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]"); @@ -7778,30 +7945,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 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 useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react10.useRef)(-1); + const wasSending = (0, import_react10.useRef)(false); @@ -7813,8 +7957,7 @@ diff --git a/client.js b/client.js + sendTurn.current = -1; + setReplied(false); + return void 0; - } -- }); ++ } + if (wasSending.current) { + wasSending.current = false; + sendTurn.current = lastTurnOnScreen(); @@ -7837,21 +7980,16 @@ diff --git a/client.js b/client.js + }, [remote, sending]); + return replied; } --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 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); +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react10.useState)(null); + (0, import_react10.useEffect)(() => { @@ -7876,20 +8014,7 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; - } --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 useRowEntrances(replacing) { + const check = (0, import_react10.useRef)(replacing); + check.current = replacing; @@ -7925,11 +8050,28 @@ 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); -+ }); + }); +- }; +- 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 +- ); + scan(); + return () => { + cancelAnimationFrame(frame); @@ -7937,29 +8079,14 @@ diff --git a/client.js b/client.js + }; + }, []); } --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; +-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)( +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); @@ -8049,79 +8176,57 @@ diff --git a/client.js b/client.js + const remote = bot?.remote !== void 0; + const host = useRunningHost(running || remote && phase !== null, running || !remote ? RUNNING_HOST : TAIL_HOST); + const drawn = (0, import_react10.useRef)(false); -+ const carry = drawn.current; -+ (0, import_react10.useEffect)(() => { -+ drawn.current = Boolean(host && phase !== null && shown); -+ }, [host, phase, shown]); -+ 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_react10.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, ++ const carry = drawn.current; ++ (0, import_react10.useEffect)(() => { ++ drawn.current = Boolean(host && phase !== null && shown); ++ }, [host, phase, shown]); ++ 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_react10.createElement)( + "div", +- { className: "bt-secret-item", "data-open": open ? "" : void 0 }, - (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")) +- "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)( + { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, + bot ? (0, import_react10.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react10.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), + (0, import_react10.createElement)( + "span", + { className: "bt-typing-text" }, + (0, import_react10.createElement)( -+ "span", + "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")) + { 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 -+ ) - ) -- ); + ) +- ) : null ++ ) + ), host); - } --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)) -- ) -- ); ++} + +// src/client/dsh-offer.js +var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); @@ -8131,9 +8236,7 @@ diff --git a/client.js b/client.js + 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 RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); ++} + +// src/client/overlay-hooks.js +var import_react11 = require("react"); @@ -8152,16 +8255,7 @@ diff --git a/client.js b/client.js + if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); + }; + }, []); - } --function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { -- const bot = roster.byId[sessionId]; -- if (!bot) return null; -- const request = (roster.secretRequests?.[bot.id] ?? []).find((entry) => entry.callId === callId && (entry.sessionId ?? sessionId) === sessionId); -- if (request) return (0, import_react5.createElement)("div", { className: "bt-q-inline" }, (0, import_react5.createElement)(RequestView, { bot, request, roster, actions })); -- if (done && /already saved/.test(contentText(content))) { -- return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); -- } -- return null; ++} +function usePaneLeft() { + const [paneLeft, setPaneLeft] = (0, import_react11.useState)(280); + (0, import_react11.useLayoutEffect)(() => { @@ -8169,17 +8263,7 @@ diff --git a/client.js b/client.js + if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); + }, []); + return paneLeft; - } --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 }))); ++} + +// src/client/member-status.js +var import_react12 = require("react"); @@ -8202,72 +8286,11 @@ diff --git a/client.js b/client.js + const approvals = askedIn(room2, work, id, doing); + return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); - } --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 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 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 -- ); ++} +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); @@ -8279,48 +8302,7 @@ diff --git a/client.js b/client.js + const room2 = roster.roomsById[roomId]; + return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; + }); - } --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 summaryText(busy) { + const asks = busy.filter((entry) => entry.state === "ask"); + const workers = busy.filter((entry) => entry.state === "work"); @@ -8554,6 +8536,8 @@ diff --git a/client.js b/client.js - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey, true); +- }; +- }, [open]); +function MemberDock({ sessionId, useRoster, useWork, actions }) { + const roster = useRoster((value) => value); + const work = useWork((value) => value); @@ -8587,24 +8571,6 @@ diff --git a/client.js b/client.js + now, + onOpen: () => actions.openSession(item.room.id) + }))); -+} -+function useFlowColumn(ref, active, key) { -+ (0, import_react12.useLayoutEffect)(() => { -+ const node = ref.current; -+ const flow = document.querySelector("[data-chat-flow]"); -+ if (!active || !node || !flow) return void 0; -+ const place = () => { -+ node.style.marginInlineStart = "0px"; -+ const column = flow.getBoundingClientRect(); -+ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; -+ node.style.width = `${Math.round(column.width)}px`; - }; -- }, [open]); -+ place(); -+ const observer = new ResizeObserver(place); -+ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); -+ return () => observer.disconnect(); -+ }, [active, key]); } -function MessageTools({ text, name, sessionId, react, actions }) { - const [panel, setPanel] = (0, import_react6.useState)(null); @@ -8635,6 +8601,23 @@ diff --git a/client.js b/client.js - const link = Object.assign(document.createElement("a"), { href: url, download: `${name}-message.md` }); - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 1e3); ++function useFlowColumn(ref, active, key) { ++ (0, import_react12.useLayoutEffect)(() => { ++ const node = ref.current; ++ const flow = document.querySelector("[data-chat-flow]"); ++ if (!active || !node || !flow) return void 0; ++ const place = () => { ++ node.style.marginInlineStart = "0px"; ++ const column = flow.getBoundingClientRect(); ++ node.style.marginInlineStart = `${Math.round(column.left - node.getBoundingClientRect().left)}px`; ++ node.style.width = `${Math.round(column.width)}px`; ++ }; ++ place(); ++ const observer = new ResizeObserver(place); ++ for (const target of [flow, flow.parentElement, node.parentElement]) if (target) observer.observe(target); ++ return () => observer.disconnect(); ++ }, [active, key]); ++} +function useNow(active) { + const [now, setNow] = (0, import_react12.useState)(Date.now); + (0, import_react12.useEffect)(() => { @@ -9914,6 +9897,26 @@ diff --git a/client.js b/client.js + return Math.min(1, Math.max(0, failed / denominator)); } - return next === void 0 ? void 0 : turns[next].first; +-} +-function openingTurn(turns, firstTurns, hiddenTurns) { +- if (!turns || !firstTurns) return null; +- const start = nextTurnStart(turns, -Infinity); +- if (start === void 0) return null; +- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); +- const listed = firstTurns.split(",").map(Number); +- const earlier = listed.filter((entry) => entry < turn); +- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; +-} +-var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; +-function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { +- const turn = location2.turn; +- const time = location2.end.time; +- (0, import_react6.useEffect)(() => { +- actions.noteTurnTime(sessionId, turn, "end", time); +- }, [sessionId, turn, time]); +- const label = useTurnClock((state) => { +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; + const value = day[key]; + return Number.isFinite(value) ? value : null; +} @@ -9958,7 +9961,12 @@ diff --git a/client.js b/client.js + title: pointTitle(day, key), + value + }; -+ }); + }); +- const visit = useUi((state) => state.visits?.[sessionId]); +- const fresh = useTurnClock((state) => { +- if (typeof visit?.seen !== "number" || time > visit.seen) return false; +- const start = nextTurnStart(state[sessionId] ?? {}, turn); +- return start !== void 0 && start > visit.seen && start <= visit.entered; + const segments = []; + let run = null; + for (const dot of dots) { @@ -9985,15 +9993,7 @@ diff --git a/client.js b/client.js + today: segment.dots.some((dot) => dot.today) + })) + }; - } --function openingTurn(turns, firstTurns, hiddenTurns) { -- if (!turns || !firstTurns) return null; -- const start = nextTurnStart(turns, -Infinity); -- if (start === void 0) return null; -- const turn = Number(Object.keys(turns).filter((key) => turns[key].first === start)[0]); -- const listed = firstTurns.split(",").map(Number); -- const earlier = listed.filter((entry) => entry < turn); -- return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; ++} +function DeliveryChart({ daily, metricKey, label }) { + const geo = chartGeometry(daily, metricKey); + if (!geo || !geo.known) return null; @@ -10027,23 +10027,7 @@ diff --git a/client.js b/client.js + r: dot.hollow ? HOLLOW_RADIUS : DOT_RADIUS + }, (0, import_react15.createElement)("title", null, dot.title))) + ); - } --var unseenSince = (visit, time) => typeof visit?.seen === "number" && time > visit.seen && time <= visit.entered; --function TurnTailCell({ turn: location2, sessionId, useTurnClock, useUi, actions }) { -- const turn = location2.turn; -- const time = location2.end.time; -- (0, import_react6.useEffect)(() => { -- actions.noteTurnTime(sessionId, turn, "end", time); -- }, [sessionId, turn, time]); -- const label = useTurnClock((state) => { -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start - time >= SEPARATOR_GAP_MS ? clockLabel(start) : ""; -- }); -- const visit = useUi((state) => state.visits?.[sessionId]); -- const fresh = useTurnClock((state) => { -- if (typeof visit?.seen !== "number" || time > visit.seen) return false; -- const start = nextTurnStart(state[sessionId] ?? {}, turn); -- return start !== void 0 && start > visit.seen && start <= visit.entered; ++} +function DeliveryMetrics({ rows = [], daily }) { + const days = chartDays(daily); + const short = CHART_KEYS.map((key) => knownDays(days, key)).filter((count) => count < (days?.length ?? 0)); @@ -10164,6 +10148,38 @@ diff --git a/client.js b/client.js - 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 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", @@ -10211,14 +10227,15 @@ diff --git a/client.js b/client.js + 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"); -+ return t("{part} was not fetched this time: {message}", { part, message: `${String(entry?.message ?? "")}${status}` }); ++ 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 ?? ""); @@ -10267,7 +10284,10 @@ diff --git a/client.js b/client.js + }, + open: snapshot?.open ?? {} + }; -+} + } +-function workPhrase(tool) { +- if (!tool) return t("Thinking about it"); +- return toolActivity(tool, () => void 0, () => void 0)?.label ?? t("On it"); +function createPoller({ load, onView, now = () => Date.now(), setTimer = (fn, ms) => setTimeout(fn, ms), clearTimer = (id) => clearTimeout(id) }) { + let stopped = false; + let timer = null; @@ -10301,12 +10321,7 @@ diff --git a/client.js b/client.js + 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: () => { @@ -10329,31 +10344,37 @@ diff --git a/client.js b/client.js + 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 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 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 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 }); +function useDeliveryBoard(actions) { + const [view, setView] = (0, import_react16.useState)({ state: "idle" }); + const [retry, setRetry] = (0, import_react16.useState)(() => () => { @@ -10365,7 +10386,22 @@ diff --git a/client.js b/client.js + onView: (next) => { + current = next; + setView(next); -+ } + } +- 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 = []; +- } + }); + setRetry(() => () => poller.retry(current)); + poller.start(); @@ -10375,17 +10411,22 @@ diff --git a/client.js b/client.js +} +function probeText(report) { + if (!report) return ""; -+ const lines = [`${report.repo ?? ""}${report.repoSource ? ` · ${report.repoSource}` : ""} · ${timeOf(report.at)}`]; ++ 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); -+ lines.push(`${row.ok ? "ok" : "FAIL"} ${status.padEnd(4)} ${row.endpoint}${row.ok ? "" : ` ${row.kind ?? ""}`}`); ++ 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 ?? ""}`}`); } +- 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)( + 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); @@ -10417,14 +10458,7 @@ diff --git a/client.js b/client.js + (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; @@ -10435,6 +10469,9 @@ diff --git a/client.js b/client.js + 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", @@ -10443,9 +10480,9 @@ diff --git a/client.js b/client.js + // like a fact is what showed one instance another repository's board. + (0, import_react16.createElement)( + "p", -+ { className: "bt-del-repo", "data-repo": repoName ?? void 0, "data-repo-source": repoSource ?? void 0 }, -+ repoName === null ? t("no repository — set delivery.repo") : repoName, -+ sourceText === null ? "" : ` · ${sourceText}` ++ { 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", @@ -10461,9 +10498,9 @@ diff --git a/client.js b/client.js + 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: open.issues ?? 0, -+ needs: open.needsHuman ?? 0, -+ prs: open.pullRequests ?? 0 ++ 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)( @@ -10477,64 +10514,11 @@ diff --git a/client.js b/client.js + 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 }); -- } -- 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 = []; -- } -+function DeliveryBoard({ view, snapshot, message = "", repo = 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); -+ if (state === "idle") { -+ return (0, import_react16.createElement)( -+ "div", -+ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, -+ 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.")) -+ ); - } -- 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", ++} ++function DeliveryColumns({ view, repo = null }) { ++ const columns = view?.columns ?? {}; ++ 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" }; @@ -10589,6 +10573,61 @@ diff --git a/client.js b/client.js - const bot = roster.byId[id]; - if (!bot) return null; - return (0, import_react6.createElement)( ++ { className: "bt-del-columns" }, ++ stagesOf(view).map((column) => (0, import_react16.createElement)( ++ "section", ++ { key: column.id, className: "bt-del-column", "data-column": column.id }, ++ (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((columns[column.id] ?? []).length)) ++ ), ++ (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${repo ?? ""}-${card.kind}-${card.number}`, card, repo })) ++ )) ++ ); ++} ++function DeliveryRepoBlock({ entry }) { ++ 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 }) ++ ); ++} ++function DeliveryBoard({ view, snapshot, message = "", repo = 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 entries2 = Array.isArray(payload.repos) && payload.repos.length > 1 ? payload.repos : null; ++ if (state === "idle") { ++ return (0, import_react16.createElement)( ++ "div", ++ { className: "bt-del-body", "data-view": "delivery", "data-state": state }, ++ 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", @@ -10597,7 +10636,8 @@ diff --git a/client.js b/client.js + (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.")) + ); + } -+ if (state === "failed" || !payload.columns) { ++ 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", @@ -10621,7 +10661,6 @@ diff --git a/client.js b/client.js + ) + ); + } -+ const columns = payload.columns ?? {}; + const missing = payload.missing ?? []; + return (0, import_react16.createElement)( + "div", @@ -10636,20 +10675,10 @@ diff --git a/client.js b/client.js + ) : null, + state !== "stale" && missing.length > 0 ? (0, import_react16.createElement)("p", { className: "bt-del-partial", role: "status" }, missing.map((entry) => missingText(entry)).join(" · ")) : null, + (0, import_react16.createElement)(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), -+ (0, import_react16.createElement)( ++ entries2 === null ? (0, import_react16.createElement)(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) : (0, import_react16.createElement)( + "div", -+ { className: "bt-del-columns" }, -+ DELIVERY_COLUMNS.map((column) => (0, import_react16.createElement)( -+ "section", -+ { key: column.id, className: "bt-del-column", "data-column": column.id }, -+ (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((columns[column.id] ?? []).length)) -+ ), -+ (columns[column.id] ?? []).length === 0 ? (0, import_react16.createElement)("p", { className: "bt-board-empty" }, t("Nothing here.")) : (columns[column.id] ?? []).map((card) => (0, import_react16.createElement)(DeliveryCard, { key: `${card.kind}-${card.number}`, card, repo: payload.repo })) -+ )) ++ { className: "bt-del-repos" }, ++ entries2.map((entry) => (0, import_react16.createElement)(DeliveryRepoBlock, { key: entry.repo ?? "unknown", entry })) + ) ); } @@ -10782,6 +10811,10 @@ diff --git a/client.js b/client.js +.bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} +.bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} +.bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} ++.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} ++.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} ++.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} ++.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} +.bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} +.bt-del-card-top{display:flex;align-items:center;gap:6px} +.bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} diff --git a/patches/ds-bot/files/scripts/delivery-probe.mjs b/patches/ds-bot/files/scripts/delivery-probe.mjs index 65dc058c..ef03a659 100644 --- a/patches/ds-bot/files/scripts/delivery-probe.mjs +++ b/patches/ds-bot/files/scripts/delivery-probe.mjs @@ -4,15 +4,18 @@ // 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 repository it reads is the host's own question (#1250), answered by the same -// `resolveRepo()` the board installs: `--repo`, else the `origin` of `--workspace` (default the -// current folder), else the package's own `repository.url`. +// 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, resolveRepo } from '../src/host/delivery-repo.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) @@ -20,25 +23,34 @@ 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 found = resolveRepo({ - setting: argOf('--repo'), +const repos = resolveRepos({ + setting: allOf('--repo'), workspace: argOf('--workspace', process.cwd()), packageSlug: repoSlug(), }) -if (found.repo === null) { +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 repo = found.repo const tokenEnv = argOf('--token-env', 'GITHUB_TOKEN') const token = process.env[tokenEnv] ?? null -const report = await probeGitHub({ repo, token }) - -console.log(`delivery-probe ${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}`}`}`) +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 } -console.log(report.ok ? 'all five answer' : 'one or more of the five is refused; the FAIL line names which') -process.exit(report.ok ? 0 : 1) +process.exit(answered ? 0 : 1) diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index 00d84b51..eb5d5e22 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -40,6 +40,20 @@ export const DELIVERY_COLUMNS = [ { id: 'done-today', label: 'Done today' }, ] +// 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', @@ -107,13 +121,15 @@ export function hintFor(error, { repo = null } = {}) { 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). +// 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') - return t('{part} was not fetched this time: {message}', { part, message: `${String(entry?.message ?? '')}${status}` }) + 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) => { @@ -259,10 +275,13 @@ export function useDeliveryBoard(actions) { // `--probe` CLI prints (#1211). export function probeText(report) { if (!report) return '' - const lines = [`${report.repo ?? ''}${report.repoSource ? ` · ${report.repoSource}` : ''} · ${timeOf(report.at)}`] + 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) - lines.push(`${row.ok ? 'ok' : 'FAIL'} ${status.padEnd(4)} ${row.endpoint}${row.ok ? '' : ` ${row.kind ?? ''}`}`) + 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') @@ -302,6 +321,11 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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. @@ -309,9 +333,9 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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': repoName ?? undefined, 'data-repo-source': repoSource ?? undefined }, - repoName === null ? t('no repository — set delivery.repo') : repoName, - sourceText === null ? '' : ` · ${sourceText}`), + 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). @@ -321,7 +345,7 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on 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: open.issues ?? 0, needs: open.needsHuman ?? 0, prs: open.pullRequests ?? 0, + 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' }, @@ -333,6 +357,35 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on onProbe ? h(Diagnose, { onProbe }) : null)) } +// One repository's columns (#1265 step 4). The card key carries the repository, so the same issue +// number in two repositories is two cards and neither is mistaken for the other. +function DeliveryColumns({ view, repo = null }) { + const columns = view?.columns ?? {} + return h('div', { className: 'bt-del-columns' }, + stagesOf(view).map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, + h('h3', { className: 'bt-board-column-head' }, + h('span', null, t(column.label)), + h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), + (columns[column.id] ?? []).length === 0 + ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) + : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${repo ?? ''}-${card.kind}-${card.number}`, card, repo }))))) +} + +// 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 }) { + 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 })) +} + export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRetry = null, onProbe = null }) { const payload = view ?? (snapshot ? { ...snapshot, state: 'fresh' } : { state: 'idle' }) const state = payload.state ?? 'fresh' @@ -349,6 +402,8 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet return () => clearInterval(timer) }, [ticking, retryAt]) const countdown = retryCountdown(payload.retryAt, now) + // 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. if (state === 'idle') { return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, @@ -361,7 +416,10 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet 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.'))) } - if (state === 'failed' || !payload.columns) { + // 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': 'delivery', 'data-state': state }, h('div', { className: 'bt-board-error', role: 'alert' }, @@ -373,7 +431,6 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet : 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 columns = payload.columns ?? {} const missing = payload.missing ?? [] return h('div', { className: 'bt-del-body', 'data-view': 'delivery', 'data-state': state }, message ? h('p', { className: 'bt-board-error', role: 'alert' }, message) : null, @@ -386,14 +443,10 @@ export function DeliveryBoard({ view, snapshot, message = '', repo = null, onRet ? h('p', { className: 'bt-del-partial', role: 'status' }, missing.map(entry => missingText(entry)).join(' · ')) : null, h(DeliveryHeader, { view: payload, repo, onRetry, onProbe }), - h('div', { className: 'bt-del-columns' }, - DELIVERY_COLUMNS.map(column => h('section', { key: column.id, className: 'bt-del-column', 'data-column': column.id }, - h('h3', { className: 'bt-board-column-head' }, - h('span', null, t(column.label)), - h('span', { className: 'bt-board-count' }, String((columns[column.id] ?? []).length))), - (columns[column.id] ?? []).length === 0 - ? h('p', { className: 'bt-board-empty' }, t('Nothing here.')) - : (columns[column.id] ?? []).map(card => h(DeliveryCard, { key: `${card.kind}-${card.number}`, card, repo: payload.repo })))))) + entries === null + ? h(DeliveryColumns, { view: payload, repo: repo ?? payload.repo ?? null }) + : h('div', { className: 'bt-del-repos' }, + entries.map(entry => h(DeliveryRepoBlock, { key: entry.repo ?? 'unknown', entry })))) } export function DeliveryView({ actions }) { @@ -438,6 +491,10 @@ export const DELIVERY_CSS = ` .bt-del-diag-text{margin:0;font-size:11px;line-height:15px;white-space:pre-wrap;word-break:break-all;color:var(--bt-ink-2);max-height:220px;overflow:auto} .bt-del-columns{flex:1 1 auto;min-height:0;display:flex;gap:10px;overflow:auto} .bt-del-column{flex:1 1 0;min-width:200px;display:flex;flex-direction:column;gap:8px;padding:8px;border-radius:14px;background:var(--bt-hover);overflow:auto} +.bt-del-repos{flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:14px;overflow:auto} +.bt-del-repo-block{display:flex;flex-direction:column;gap:8px;min-height:0} +.bt-del-repo-head{position:sticky;top:0;z-index:1;margin:0;padding:2px 4px;font-size:13px;line-height:18px;color:var(--bt-ink-2);background:var(--bt-main,var(--bt-card));border-radius:8px} +.bt-del-block-state{margin:0;padding:12px 8px;font-size:12px;line-height:16px;color:var(--bt-ink-3)} .bt-del-card{display:flex;flex-direction:column;gap:5px;padding:10px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} .bt-del-card-top{display:flex;align-items:center;gap:6px} .bt-del-id{font-size:12px;line-height:16px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} diff --git a/patches/ds-bot/files/src/host/delivery-board.js b/patches/ds-bot/files/src/host/delivery-board.js index a3ae1791..1f7517dc 100644 --- a/patches/ds-bot/files/src/host/delivery-board.js +++ b/patches/ds-bot/files/src/host/delivery-board.js @@ -11,7 +11,7 @@ // `{ 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, instanceRepoSetting, resolveRepo } from './delivery-repo.js' +import { NO_REPO_MESSAGE, instanceRepoSettings, resolveRepos } from './delivery-repo.js' export const DELIVERY_COLUMNS = ['raw', 'ready', 'working', 'review', 'done-today'] @@ -744,6 +744,144 @@ export function createDeliveryBoard({ repo, repoSource = null, token, request = } } +// 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)), + } +} + +const mergeMetrics = (list) => { + if (list.length === 0) 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])), + metrics: mergeMetrics(entries.map(entry => entry.metrics).filter(Boolean)), + } +} + 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` @@ -761,7 +899,7 @@ export function install(rt) { try { return rt.ctx?.workspaceRegistry?.get?.(rt.state?.workspaceId)?.path ?? null } catch { return null } } const setting = () => { - try { return instanceRepoSetting(rt.config) } catch { return null } + 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 @@ -769,14 +907,16 @@ export function install(rt) { 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 cache below is keyed by its answer. + // 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 = resolveRepo({ setting: setting(), workspace: workspace(), packageSlug: packageSlug() }) + 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). - if (found.source === 'package' && !notedDefaults.has(found.repo)) { - notedDefaults.add(found.repo) - warn('ds-bot: delivery board: no delivery.repo and no git remote in the workspace; reading the package repository %s (default)', found.repo) + 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 } @@ -791,27 +931,46 @@ export function install(rt) { // 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, since: null, attempt: 0, elapsedMs: 0, + 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.repo === null) return noRepo() - const board = boardFor(found) + 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)) - return { ...(await board.read({ force: payload?.force === true, wait: payload?.wait === true })), repoSource: found.source } + 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 }) + } + return combineRepos(views) } - // The 诊断 button and `--probe` run this one function (#1211): same reads, same statuses. + // 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.repo === null) return { ok: false, repo: null, repoSource: null, at: new Date().toISOString(), rows: [], error: NO_REPO_MESSAGE } - const board = boardFor(found) + 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)) - try { return { ...(await board.probe()), repoSource: found.source } } catch (error) { - return { ok: false, repo: found.repo, repoSource: found.source, at: new Date().toISOString(), rows: [], error: String(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 index 16ef630e..be7356c7 100644 --- a/patches/ds-bot/files/src/host/delivery-repo.js +++ b/patches/ds-bot/files/src/host/delivery-repo.js @@ -1,12 +1,14 @@ -// Which repository the delivery board reads, and where that name came from (#1250). +// 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. One repository is resolved here, for the host and for `scripts/delivery-probe.mjs` alike, -// in three steps, first answer wins: +// 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.repo` on the plugin entry — the host hands the entry through -// verbatim, so all three spellings people write are read (`instanceRepoSetting`); +// 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. // @@ -111,6 +113,22 @@ export function instanceRepoSetting(config) { 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) @@ -118,13 +136,28 @@ export function gitOriginRepo(folder) { try { return parseRepoName(originUrlIn(readFileSync(path, 'utf8'))) } catch { return null } } -// First answer wins, and `source` travels with the name so the page can say where it came from. -export function resolveRepo({ setting = null, workspace = null, packageSlug = null } = {}) { - const configured = parseRepoName(setting) - if (configured !== null) return { repo: configured, source: 'config' } +// 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' } + if (remote !== null) return [{ repo: remote, source: 'remote' }] const packaged = parseRepoName(packageSlug) - if (packaged !== null) return { repo: packaged, source: 'package' } - return { repo: null, source: null } + 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/test/delivery-board.test.js b/patches/ds-bot/files/test/delivery-board.test.js index bd0daa8f..d40fc62a 100644 --- a/patches/ds-bot/files/test/delivery-board.test.js +++ b/patches/ds-bot/files/test/delivery-board.test.js @@ -10,7 +10,7 @@ import { test } from 'node:test' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { createDeliveryBoard, dailySeries, DELIVERY_COLUMNS, describeResponse, install, median, probeGitHub, startOfDay, BUILD_BUDGET_MS, BACKOFF_MS } from '../src/host/delivery-board.js' +import { 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' @@ -957,3 +957,133 @@ test('a repository the token cannot read fails naming the repository and the end 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.raw.map(card => card.number).sort(), [10, 13]) + assert.deepEqual(settled.repos[1].columns.raw.map(card => card.number).sort(), [10, 13]) + assert.equal(settled.columns.raw.length, 4, '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: { raw: [], ready: [], working: [], review: [], 'done-today': [] }, + counts: { raw: 0, ready: 0, working: 0, review: 0, 'done-today': 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 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']]) +}) diff --git a/patches/ds-bot/files/test/delivery-repo.test.js b/patches/ds-bot/files/test/delivery-repo.test.js index 5cb2b59d..6f6c5a90 100644 --- a/patches/ds-bot/files/test/delivery-repo.test.js +++ b/patches/ds-bot/files/test/delivery-repo.test.js @@ -7,7 +7,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' -import { gitConfigPath, gitOriginRepo, instanceRepoSetting, NO_REPO_MESSAGE, originUrlIn, parseRepoName, resolveRepo } from '../src/host/delivery-repo.js' +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 }) @@ -166,16 +166,55 @@ test('the instance setting is read in all three spellings a profile can write it { repo: 'acme/widget', source: 'config' }) }) -test('the probe is a shell over this resolver, and refuses before it asks (#1250)', () => { +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, resolveRepo \} from '\.\.\/src\/host\/delivery-repo\.js'/) - assert.match(source, /resolveRepo\(\{/) + 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 \(found\.repo === null\) \{[\s\S]{0,240}process\.exit\(2\)/) + assert.match(source, /if \(repos\.length === 0\) \{[\s\S]{0,240}process\.exit\(2\)/) assert.match(source, /NO_REPO_MESSAGE/) - // Its order is --repo, then --workspace, then the package: no third source. - assert.match(source, /setting: argOf\('--repo'\)/) + // 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 index f50b627c..9ca91f7e 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -103,7 +103,7 @@ const mount = (Component, props) => { } const { createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, - loadingText, staleText, hintFor, missingText, probeText, + loadingText, staleText, hintFor, missingText, probeText, stagesOf, } = module.exports const text = (node) => { @@ -602,3 +602,110 @@ test('a host that cannot name a repository says so, and does not send the person // 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: { + raw: [{ kind: 'issue', number: 10, title: `${repo} untriaged`, labels: [], url: `https://x/${repo}/10` }], + ready: [], working: [], review: [], 'done-today': [], + }, + 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)', () => { + assert.equal(stagesOf({ columns: snapshot.columns }).map(column => column.id).join('|'), DELIVERY_COLUMNS.map(column => column.id).join('|')) + // The snapshot says which columns exist; a definition that ships three draws three, not five. + assert.equal(stagesOf({ columns: { raw: [], working: [], 'done-today': [] } }).map(column => column.id).join('|'), 'raw|working|done-today') + // 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('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, 5) + 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')] }, + }) + const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') + assert.deepEqual(blocks.map(node => [node.props['data-repo'], node.props['data-state']]), + [['acme/one', 'fresh'], ['acme/two', 'fresh']]) + for (const block of blocks) { + assert.equal(walk(block).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + const card = walk(block).find(node => node.type === 'article') + // The same issue number in two repositories is two cards with two identities. + assert.equal(card.props['data-key'], `${block.props['data-repo']}#10`) + } + // Each block's head names the repository and where its name came from; the shared header counts. + assert.match(text(blocks[1]), /acme\/two\s+· from config/) + 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', + }, + }) + const blocks = walk(board).filter(node => node.props?.className === 'bt-del-repo-block') + assert.equal(blocks.length, 2, 'a failed repository does not take the board with it') + assert.equal(blocks[1].props['data-state'], 'failed') + assert.equal(walk(blocks[0]).filter(node => node.type === 'section' && node.props?.['data-column']).length, 5) + assert.equal(walk(blocks[1]).some(node => node.type === 'section' && node.props?.['data-column']), false) + assert.match(text(blocks[1]), /GitHub answered 404 for \/repos\/acme\/two\/issues/) + // 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('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\./) +}) diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index a22f96f3..5a459279 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -193,3 +193,23 @@ test('the Delivery view drops the Bot rail, while Tasks keeps it (#1173)', () => // No snapshot yet is the honest one-line empty state until the first read lands. assert.match(textOf(deliveryView), /No snapshot yet/) }) + +test('a delivery read with no token leaves the task cards and their tab in place (#1211, #1265)', () => { + // 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 the other view, and its tab still opens them. + 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 } + const mine = task('task-abcdef123456', 'open', { title: 'Draft the note', ownerBotId: 's', priority: 'high' }) + // view, scope, dragging, error, draft, then the list the board would have read from the host. + states.length = 0 + states.push('tasks', { kind: 'bot', botId: 's' }, null, '', '', [mine]) + const tasksView = expand(KanbanDialog({ roster, actions })) + assert.match(textOf(tasksView), /Draft the note/) + assert.equal(byClass(tasksView, 'bt-board-card').length, 1) + assert.deepEqual(all(tasksView).filter(node => node.props?.role === 'tab').map(node => node.props['data-view']), ['tasks', 'delivery']) +}) From e3ded8e4dd81546880a9f31daa4eb5befa64c04d Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 26 Oct 2026 20:45:33 +0800 Subject: [PATCH 2/2] fix(delivery): a partial board's totals stay unknown (#1297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `combineRepos` merged `metrics` with a filter that deleted every repository which had not read, so the surviving metrics were the readable half's and the header printed them under "2 repositories" — a number that looks like the board's total but counted one repository. Hand the whole entry list to `mergeMetrics` so a missing entry makes the board's `metrics` `null`; the view already prints `—` for an unknown number. Pin that, and pin the related "unknown open count is `—`, a real zero is `0`" rule so it cannot drift back to `0`. Part of #1265 (step 4, multi-repository). Fixes #1293. --- core/src/host/delivery-board.js | 9 +++++-- core/test/delivery-board.test.js | 8 +++++++ core/test/delivery-view.test.js | 24 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- .../ds-bot/files/src/host/delivery-board.js | 9 +++++-- .../ds-bot/files/test/delivery-board.test.js | 8 +++++++ .../ds-bot/files/test/delivery-view.test.js | 24 +++++++++++++++++++ 7 files changed, 79 insertions(+), 5 deletions(-) diff --git a/core/src/host/delivery-board.js b/core/src/host/delivery-board.js index 1f7517dc..3958dc75 100644 --- a/core/src/host/delivery-board.js +++ b/core/src/host/delivery-board.js @@ -800,8 +800,11 @@ const mergeWindow = (windows) => { } } +// 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) return null + 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, @@ -878,7 +881,9 @@ export function combineRepos(views) { missing, columns, counts: columns === undefined ? null : Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, columns[id].length])), - metrics: mergeMetrics(entries.map(entry => entry.metrics).filter(Boolean)), + // 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)), } } diff --git a/core/test/delivery-board.test.js b/core/test/delivery-board.test.js index d40fc62a..66c2b9c5 100644 --- a/core/test/delivery-board.test.js +++ b/core/test/delivery-board.test.js @@ -1078,6 +1078,14 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi // 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([ diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index 9ca91f7e..eefd3daa 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -591,6 +591,30 @@ test('the header says whose repository this is, and where the name came from (#1 /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 }), diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 11dc9bb8..b1d960dc 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,7 +38,7 @@ 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 label (`agent-working` → working, `agent-ready` → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. 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, 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/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 label (`agent-working` → working, `agent-ready` → ready, else original), pull requests in review, and pull requests merged, and issues or pull requests closed, since the day start in done today — a pull request closed without merging counts, so finished work never sits in no column. 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). | diff --git a/patches/ds-bot/files/src/host/delivery-board.js b/patches/ds-bot/files/src/host/delivery-board.js index 1f7517dc..3958dc75 100644 --- a/patches/ds-bot/files/src/host/delivery-board.js +++ b/patches/ds-bot/files/src/host/delivery-board.js @@ -800,8 +800,11 @@ const mergeWindow = (windows) => { } } +// 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) return null + 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, @@ -878,7 +881,9 @@ export function combineRepos(views) { missing, columns, counts: columns === undefined ? null : Object.fromEntries(DELIVERY_COLUMNS.map(id => [id, columns[id].length])), - metrics: mergeMetrics(entries.map(entry => entry.metrics).filter(Boolean)), + // 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)), } } diff --git a/patches/ds-bot/files/test/delivery-board.test.js b/patches/ds-bot/files/test/delivery-board.test.js index d40fc62a..66c2b9c5 100644 --- a/patches/ds-bot/files/test/delivery-board.test.js +++ b/patches/ds-bot/files/test/delivery-board.test.js @@ -1078,6 +1078,14 @@ test('the aggregation rules: oldest snapshot, partial body, and no invented medi // 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([ diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index 9ca91f7e..eefd3daa 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -591,6 +591,30 @@ test('the header says whose repository this is, and where the name came from (#1 /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 }),