diff --git a/changelog.d/1244-delivery-header.fixed.md b/changelog.d/1244-delivery-header.fixed.md new file mode 100644 index 000000000..08259e44f --- /dev/null +++ b/changelog.d/1244-delivery-header.fixed.md @@ -0,0 +1 @@ +The delivery header prints its "as of" time once while today is still running: the partial-day hint now follows the single time instead of repeating it from a second clock rendering, in both English and Chinese. A day whose runs the host could only count by their total now shows the same red share in the header row as the point drawn on the line beside it, instead of a dash next to a dot ([#1244](https://github.com/ThinkFlowLab/FlyBot/issues/1244)). diff --git a/core/client.js b/core/client.js index 548a30796..08cdfd603 100644 --- a/core/client.js +++ b/core/client.js @@ -1384,7 +1384,7 @@ var HOST_ZH = { "{label} over the last 7 days": "{label}近 7 天", "Last 7 days": "近 7 天", "so far": "截至现在", - "today is partial · as of {time}": "今天为部分日 · 截至 {time}", + "today is partial": "今天为部分日", "* {count} of 7 days had data": "* 7 天中 {count} 天有数", "* {count} of {days} days had data": "* {days} 天中 {count} 天有数", "CI red share": "CI 红占比", @@ -8346,7 +8346,11 @@ function metricRows(snapshot) { const today = snapshot?.today ?? {}; const week = snapshot?.week ?? {}; const ci = today.ci ?? null; - const share = (entry) => entry && Number.isFinite(entry.counted) && entry.counted > 0 ? `${Math.round(entry.failed / entry.counted * 100)}%` : "—"; + const share = (entry) => { + if (!entry || !Number.isFinite(entry.failed)) return "—"; + const denominator = Number.isFinite(entry.counted) && entry.counted > 0 ? entry.counted : Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null; + return denominator === null ? "—" : `${Math.round(entry.failed / denominator * 100)}%`; + }; const count = (value) => value === null || value === void 0 ? "—" : String(value); const rows = [ { label: t("New issues"), key: "newIssues" }, @@ -8512,6 +8516,7 @@ 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 partial = state !== "loading" && todayPartial(metricsPayload.daily); return (0, import_react15.createElement)( "header", { className: "bt-del-head" }, @@ -8545,8 +8550,8 @@ function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, onProbe = (0, import_react15.createElement)( "p", { className: "bt-del-asof" }, - state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: timeOf(payload.asOf) }), - state !== "loading" && todayPartial(metricsPayload.daily) ? ` · ${t("today is partial · as of {time}", { time: clockOf(payload.asOf) })}` : "", + state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), + partial ? ` · ${t("today is partial")}` : "", payload.refreshing ? ` · ${t("refreshing…")}` : "", error ? ` · ${hintFor(error, { repo })}` : "", onRetry ? (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, diff --git a/core/src/client/delivery-view.js b/core/src/client/delivery-view.js index cfdfb0f4c..9d56377e1 100644 --- a/core/src/client/delivery-view.js +++ b/core/src/client/delivery-view.js @@ -132,9 +132,16 @@ export function metricRows(snapshot) { const week = snapshot?.week ?? {} const ci = today.ci ?? null // The CI share is a ratio of the runs that counted, so it is written as a percentage and is - // never summed; a window with nothing counted reads '—', not 0%. - const share = (entry) => (entry && Number.isFinite(entry.counted) && entry.counted > 0 - ? `${Math.round((entry.failed / entry.counted) * 100)}%` : '—') + // never summed. A day the host could only count by `total` still has a denominator — the one the + // 7-day line beside this row draws — so the two halves read the same day the same way (#1244); + // only a window with no runs at all reads '—', never 0%. + const share = (entry) => { + if (!entry || !Number.isFinite(entry.failed)) return '—' + const denominator = Number.isFinite(entry.counted) && entry.counted > 0 + ? entry.counted + : (Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null) + return denominator === null ? '—' : `${Math.round((entry.failed / denominator) * 100)}%` + } const count = (value) => (value === null || value === undefined ? '—' : String(value)) const rows = [ { label: t('New issues'), key: 'newIssues' }, @@ -293,6 +300,10 @@ 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) + // One time, one source (#1244): while today is still running the hint follows the time, so the + // paragraph prints the zone-clocked time once instead of repeating a local-clock rendering of the + // same instant — the two can disagree, not merely repeat. + const partial = state !== 'loading' && todayPartial(metricsPayload.daily) return h('header', { className: 'bt-del-head' }, // Whose numbers these are, and where the name came from (#1250): a package default that looks // like a fact is what showed one instance another repository's board. @@ -312,8 +323,8 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined ? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')), h('p', { className: 'bt-del-asof' }, - state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: timeOf(payload.asOf) }), - state !== 'loading' && todayPartial(metricsPayload.daily) ? ` · ${t('today is partial · as of {time}', { time: clockOf(payload.asOf) })}` : '', + state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), + partial ? ` · ${t('today is partial')}` : '', payload.refreshing ? ` · ${t('refreshing…')}` : '', error ? ` · ${hintFor(error, { repo })}` : '', onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 06ac1cb50..8e97123ce 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -1337,7 +1337,7 @@ const HOST_ZH = { '{label} over the last 7 days': '{label}近 7 天', 'Last 7 days': '近 7 天', 'so far': '截至现在', - 'today is partial · as of {time}': '今天为部分日 · 截至 {time}', + 'today is partial': '今天为部分日', '* {count} of 7 days had data': '* 7 天中 {count} 天有数', '* {count} of {days} days had data': '* {days} 天中 {count} 天有数', 'CI red share': 'CI 红占比', diff --git a/core/test/delivery-chart.test.js b/core/test/delivery-chart.test.js index 013f8ee80..a16eb3dd2 100644 --- a/core/test/delivery-chart.test.js +++ b/core/test/delivery-chart.test.js @@ -126,6 +126,19 @@ test('#1212: the CI metric is the red share of the runs that counted', () => { assert.equal(knownDays(daily, 'ci'), 6) }) +test('#1244: a day counted only by total still draws the share the header row reads', () => { + // `counted: 0` means the host could not say which runs were superseded, not that the day had + // nothing to count: the line draws `failed / total`, and the header row now reads the same + // denominator instead of printing '—' beside a dot. + const countedByTotal = { date: '2026-10-26', partial: true, ci: { failed: 1, counted: 0, total: 5 } } + assert.equal(dayValue(countedByTotal, 'ci'), 0.2) + const geo = chartGeometry([countedByTotal], 'ci') + assert.equal(geo.dots[0].state, 'partial', 'today is still running, and it is still drawn') + assert.equal(geo.dots[0].title, '2026-10-26 · 20%') + // Both columns empty is still unknown: neither a dot nor a share. + assert.equal(dayValue({ date: '2026-10-26', ci: { failed: 0, counted: 0, total: 0 } }, 'ci'), null) +}) + test('#1212: the component draws a 168×28 svg with a state and a title on every point', () => { const node = DeliveryChart({ daily, metricKey: 'newIssues', label: 'New issues' }) assert.equal(node.type, 'svg') diff --git a/core/test/delivery-view.test.js b/core/test/delivery-view.test.js index 75e58ce86..3e288e074 100644 --- a/core/test/delivery-view.test.js +++ b/core/test/delivery-view.test.js @@ -102,7 +102,7 @@ const mount = (Component, props) => { return { draw, rerender: draw } } const { - createPoller, DELIVERY_COLUMNS, DeliveryBoard, badgeLabel, duration, metricRows, pollDelay, retryCountdown, + createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, loadingText, staleText, hintFor, missingText, probeText, } = module.exports @@ -207,6 +207,91 @@ test('the CI red share reaches the page as a line, and the medians draw none (#1 } }) +test('a day the host could only count by total reads the same share in the row as on the line (#1244)', () => { + // The header row required `counted`, so a day with `counted: 0, total: 5` printed '—' while the + // 7-day line beside it drew a dot from `total`: one day, two answers. The row now uses the same + // denominator the chart draws. + const countedByTotal = { date: '2026-10-25', partial: true, newIssues: 3, closedIssues: 1, newPullRequests: 0, mergedPullRequests: 0, ci: { failed: 1, counted: 0, total: 5 } } + const metrics = { + ...snapshot.metrics, + today: { ...snapshot.metrics.today, ci: countedByTotal.ci }, + daily: [countedByTotal], + } + const rows = metricRows(metrics) + assert.equal(rows.rows[4].today, '20%', 'one red run of five is 20%, not unknown') + // The denominator the chart uses for that same day (delivery-chart.test.js pins the number). + assert.equal(countedByTotal.ci.failed / countedByTotal.ci.total, 0.2) + // A window with no runs at all still has no share: unknown is not 0%, and it is not a dot either. + assert.equal(metricRows({ ...metrics, today: { ...snapshot.metrics.today, ci: { failed: 0, counted: 0, total: 0 } } }).rows[4].today, '—') +}) + +test('a partial day prints its time once, in the task zone, instead of twice (#1244)', async () => { + // #1244: the paragraph printed `as of {timeOf}` and then appended a hint carrying its own + // `clockOf` of the same instant — the time twice, and from two different renderings. The hint now + // follows the single time, and that time is the hint's own zone rendering. + const daily = [ + { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, + { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, + { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, + { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, + { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, + { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, + { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, + ] + const snapshotWith = dailySeries => ({ ...snapshot, metrics: { ...snapshot.metrics, daily: dailySeries } }) + const asOfText = (Component, input) => { + const paragraph = walk(Component({ snapshot: input })).find(node => node.props?.className === 'bt-del-asof') + assert.ok(paragraph, 'the header carries an as-of line') + return text(paragraph).replace(/\s+/g, ' ').trim() + } + const times = words => [...words.matchAll(/\d{1,2}:\d{2}/g)].length + + const english = asOfText(DeliveryHeader, snapshotWith(daily)) + assert.equal(times(english), 1, `one time string, got: ${english}`) + assert.match(english, /today is partial/) + // The same paragraph in Chinese, through the same bundled module: the duplicate 截至 is gone too. + const { DeliveryHeader: DeliveryHeaderZh, installLocale } = await build({ + stdin: { + contents: `export { DeliveryHeader } from ${JSON.stringify(fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url)))}\nexport { installLocale } from ${JSON.stringify(fileURLToPath(new URL('../src/client/i18n.js', import.meta.url)))}`, + resolveDir: fileURLToPath(new URL('../src/client/', import.meta.url)), + loader: 'js', + }, + bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react'], + }).then(async out => { + const module = { exports: {} } + runInNewContext(out.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, + setInterval: timers.setInterval, clearInterval: timers.clearInterval, + require: (name) => { + if (name !== 'react') throw new Error(`no platform module ${name}`) + return reactStub + }, + }) + return module.exports + }) + const dictionaries = new Map() + const off = installLocale({ + register: (namespace, locale, dict) => { dictionaries.set(`${namespace}:${locale}`, dict); return () => dictionaries.delete(`${namespace}:${locale}`) }, + bind: namespace => (key, params) => { + const words = dictionaries.get(`${namespace}:zh`)?.[key] ?? key + return params ? words.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : words + }, + getLocale: () => ({ active: 'zh' }), + }) + try { + const chinese = asOfText(DeliveryHeaderZh, snapshotWith(daily)) + assert.equal(times(chinese), 1, `one time string, got: ${chinese}`) + assert.match(chinese, /今天为部分日/) + assert.equal((chinese.match(/截至/g) ?? []).length, 1, 'the hint no longer repeats 截至') + } finally { + off() + } + // A finished day keeps the local-clock rendering and gains no hint at all. + const finished = asOfText(DeliveryHeader, snapshotWith(daily.map(day => ({ ...day, partial: false })))) + assert.equal(times(finished), 1) + assert.equal(finished.includes('today is partial'), false) +}) + test('the board renders all five columns and a card with labels, owner and link (#1173)', () => { const board = DeliveryBoard({ snapshot: { ...snapshot, columns: snapshot.columns }, message: '' }) const sections = walk(board).filter(node => node.type === 'section' && node.props?.['data-column']) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index e8a76919f..af6c29466 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,8 +38,8 @@ 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. 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-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. 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/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-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/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress. The dialog has two tabs (#1173), **Tasks** and **Delivery**; the Delivery view is repository-wide, so its Bot rail is not rendered and it cannot be pointed at one Bot. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 71a652bb3..fa3a32704 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1265,7 +1265,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '{label} over the last 7 days': '{label}近 7 天', + 'Last 7 days': '近 7 天', + 'so far': '截至现在', -+ 'today is partial · as of {time}': '今天为部分日 · 截至 {time}', ++ 'today is partial': '今天为部分日', + '* {count} of 7 days had data': '* 7 天中 {count} 天有数', + '* {count} of {days} days had data': '* {days} 天中 {count} 天有数', + 'CI red share': 'CI 红占比', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 6ac254b6f..269799eb5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -780,7 +780,7 @@ diff --git a/client.js b/client.js + "{label} over the last 7 days": "{label}近 7 天", + "Last 7 days": "近 7 天", + "so far": "截至现在", -+ "today is partial · as of {time}": "今天为部分日 · 截至 {time}", ++ "today is partial": "今天为部分日", + "* {count} of 7 days had data": "* 7 天中 {count} 天有数", + "* {count} of {days} days had data": "* {days} 天中 {count} 天有数", + "CI red share": "CI 红占比", @@ -3205,7 +3205,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2574,2206 +2574,4872 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2574,2206 +2574,4877 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @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}} @@ -7481,8 +7481,6 @@ diff --git a/client.js b/client.js -var scrollParent = (node) => { - for (let at = node.parentElement; at; at = at.parentElement) { - if (/(auto|scroll)/.test(getComputedStyle(at).overflowY)) return at; -- } -- return null; +var MEMBER_CSS = ` +.bt-mw-dock-stack{box-sizing:border-box;display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:var(--dsh-chat-content-width,min(960px,100%));margin:0 auto 6px;pointer-events:none} +.bt-mw-row{pointer-events:auto;display:flex;align-items:center;gap:10px;min-width:0;max-width:calc(100% + 6px);height:32px;margin-inline-start:-6px;padding:0 12px 0 6px;border:0;border-radius:16px;background:none;color:var(--bt-ink-2);font:inherit;font-size:14px;cursor:pointer;animation:bt-mw-in .32s cubic-bezier(.23,1,.32,1) backwards;transition:background-color .16s ease-out} @@ -7525,26 +7523,7 @@ diff --git a/client.js b/client.js +var line = (d) => (0, import_react13.createElement)("path", { d }); +var icon = (...paths) => function StatusIcon() { + return (0, import_react13.createElement)("svg", { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true }, paths.map(line)); - }; --function EarlierParts({ sessionId, roster, actions }) { -- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); -- const top = (0, import_react6.useRef)(null); -- const busy = (0, import_react6.useRef)(false); -- const alive = (0, import_react6.useRef)(true); -- const restore = (0, import_react6.useRef)(null); -- const load = (cursor) => { -- if (busy.current) return; -- busy.current = true; -- setView((current) => ({ ...current, loading: true, failed: false })); -- actions.earlier(sessionId, cursor).then((page) => { -- busy.current = false; -- if (!alive.current) return; -- const scroller = top.current && scrollParent(top.current); -- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; -- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); -- }, () => { -- busy.current = false; -- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); ++}; +var RING = "M10 3.25a6.75 6.75 0 1 1 0 13.5 6.75 6.75 0 0 1 0-13.5z"; +var InboxIcon = icon("M3.5 11.5l1.9-6.1A1.5 1.5 0 0 1 6.83 4.35h6.34a1.5 1.5 0 0 1 1.43 1.05l1.9 6.1", "M3.5 11.5v3.25a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V11.5", "M3.5 11.5h3.75l1 1.75h3.5l1-1.75h3.75"); +var AskIcon = icon(RING, "M10 6.75v3.75", "M10 13.25h.01"); @@ -7588,14 +7567,9 @@ diff --git a/client.js b/client.js + }).catch(() => { + }).finally(() => { + if (!stopped) timer = setTimeout(load, POLL_MS2); - }); -- }; -- (0, import_react6.useEffect)(() => { -- alive.current = true; -- load(void 0); ++ }); + load(); - return () => { -- alive.current = false; ++ return () => { + stopped = true; + clearTimeout(timer); + taskListeners.delete(setTasks); @@ -7620,9 +7594,29 @@ diff --git a/client.js b/client.js + if (question == null || typeof question !== "object" || roster.roomsById?.[key]) continue; + if (question.question !== text) continue; + if (key === bot.id || key === currentPart(bot)) return bot.id; -+ } + } +- return null; + return void 0; -+}; + }; +-function EarlierParts({ sessionId, roster, actions }) { +- const [view, setView] = (0, import_react6.useState)({ items: [], cursor: void 0, loading: true, failed: false, startedAt: null }); +- const top = (0, import_react6.useRef)(null); +- const busy = (0, import_react6.useRef)(false); +- const alive = (0, import_react6.useRef)(true); +- const restore = (0, import_react6.useRef)(null); +- const load = (cursor) => { +- if (busy.current) return; +- busy.current = true; +- setView((current) => ({ ...current, loading: true, failed: false })); +- actions.earlier(sessionId, cursor).then((page) => { +- busy.current = false; +- if (!alive.current) return; +- const scroller = top.current && scrollParent(top.current); +- if (scroller) restore.current = { scroller, fromEnd: scroller.scrollHeight - scroller.scrollTop }; +- setView((current) => ({ items: [...page.items, ...current.items], cursor: page.cursor, loading: false, failed: false, startedAt: page.startedAt ?? current.startedAt })); +- }, () => { +- busy.current = false; +- if (alive.current) setView((current) => ({ ...current, loading: false, failed: true })); +var placeKey = (botId, roomId) => `${botId}|${roomId ?? ""}`; +var partOf = (roster, chat) => roster.roomsById?.[chat] || !roster.byId?.[chat] ? chat : currentPart(roster.byId[chat]); +var runningIn = (work, botId, roomId) => roomId === void 0 ? work.bots?.[botId]?.roomId === void 0 ? work.bots?.[botId] : void 0 : (work.rooms?.[roomId] ?? []).find((entry) => entry.botId === botId) ?? (work.bots?.[botId]?.roomId === roomId ? work.bots[botId] : void 0); @@ -7732,8 +7726,13 @@ diff --git a/client.js b/client.js + place: placeOf(roster, roomId), + chat: roomId ?? bot.id, + ...people(roster, bot.id) -+ }); -+ }; + }); + }; +- (0, import_react6.useEffect)(() => { +- alive.current = true; +- load(void 0); +- return () => { +- alive.current = false; + for (const [botId, entry] of Object.entries(work.bots ?? {})) running(botId, entry, entry.roomId); + for (const [roomId, entries2] of Object.entries(work.rooms ?? {})) for (const entry of entries2) running(entry.botId, entry, roomId); + const lastEnded = /* @__PURE__ */ new Map(); @@ -8736,7 +8735,11 @@ diff --git a/client.js b/client.js + const today = snapshot?.today ?? {}; + const week = snapshot?.week ?? {}; + const ci = today.ci ?? null; -+ const share = (entry) => entry && Number.isFinite(entry.counted) && entry.counted > 0 ? `${Math.round(entry.failed / entry.counted * 100)}%` : "—"; ++ const share = (entry) => { ++ if (!entry || !Number.isFinite(entry.failed)) return "—"; ++ const denominator = Number.isFinite(entry.counted) && entry.counted > 0 ? entry.counted : Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null; ++ return denominator === null ? "—" : `${Math.round(entry.failed / denominator * 100)}%`; ++ }; + const count = (value) => value === null || value === void 0 ? "—" : String(value); + const rows = [ + { label: t("New issues"), key: "newIssues" }, @@ -8933,6 +8936,7 @@ diff --git a/client.js b/client.js + const repoName = payload.repo ?? repo ?? null; + const repoSource = payload.repoSource ?? null; + const sourceText = repoSourceText(repoSource); ++ const partial = state !== "loading" && todayPartial(metricsPayload.daily); + return (0, import_react15.createElement)( + "header", + { className: "bt-del-head" }, @@ -8966,8 +8970,8 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)( + "p", + { className: "bt-del-asof" }, -+ state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: timeOf(payload.asOf) }), -+ state !== "loading" && todayPartial(metricsPayload.daily) ? ` · ${t("today is partial · as of {time}", { time: clockOf(payload.asOf) })}` : "", ++ state === "loading" ? loadingText(payload.elapsedMs) : t("as of {time}", { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), ++ partial ? ` · ${t("today is partial")}` : "", + payload.refreshing ? ` · ${t("refreshing…")}` : "", + error ? ` · ${hintFor(error, { repo })}` : "", + onRetry ? (0, import_react15.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm bt-del-retry", onClick: onRetry }, t("Retry")) : null, diff --git a/patches/ds-bot/files/src/client/delivery-view.js b/patches/ds-bot/files/src/client/delivery-view.js index cfdfb0f4c..9d56377e1 100644 --- a/patches/ds-bot/files/src/client/delivery-view.js +++ b/patches/ds-bot/files/src/client/delivery-view.js @@ -132,9 +132,16 @@ export function metricRows(snapshot) { const week = snapshot?.week ?? {} const ci = today.ci ?? null // The CI share is a ratio of the runs that counted, so it is written as a percentage and is - // never summed; a window with nothing counted reads '—', not 0%. - const share = (entry) => (entry && Number.isFinite(entry.counted) && entry.counted > 0 - ? `${Math.round((entry.failed / entry.counted) * 100)}%` : '—') + // never summed. A day the host could only count by `total` still has a denominator — the one the + // 7-day line beside this row draws — so the two halves read the same day the same way (#1244); + // only a window with no runs at all reads '—', never 0%. + const share = (entry) => { + if (!entry || !Number.isFinite(entry.failed)) return '—' + const denominator = Number.isFinite(entry.counted) && entry.counted > 0 + ? entry.counted + : (Number.isFinite(entry.total) && entry.total > 0 ? entry.total : null) + return denominator === null ? '—' : `${Math.round((entry.failed / denominator) * 100)}%` + } const count = (value) => (value === null || value === undefined ? '—' : String(value)) const rows = [ { label: t('New issues'), key: 'newIssues' }, @@ -293,6 +300,10 @@ 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) + // One time, one source (#1244): while today is still running the hint follows the time, so the + // paragraph prints the zone-clocked time once instead of repeating a local-clock rendering of the + // same instant — the two can disagree, not merely repeat. + const partial = state !== 'loading' && todayPartial(metricsPayload.daily) return h('header', { className: 'bt-del-head' }, // Whose numbers these are, and where the name came from (#1250): a package default that looks // like a fact is what showed one instance another repository's board. @@ -312,8 +323,8 @@ export function DeliveryHeader({ view, snapshot, repo = null, onRetry = null, on }), open.medianIssueAgeMs !== null && open.medianIssueAgeMs !== undefined ? ` · ${t('median open age {age}', { age: duration(open.medianIssueAgeMs) })}` : '')), h('p', { className: 'bt-del-asof' }, - state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: timeOf(payload.asOf) }), - state !== 'loading' && todayPartial(metricsPayload.daily) ? ` · ${t('today is partial · as of {time}', { time: clockOf(payload.asOf) })}` : '', + state === 'loading' ? loadingText(payload.elapsedMs) : t('as of {time}', { time: partial ? clockOf(payload.asOf) : timeOf(payload.asOf) }), + partial ? ` · ${t('today is partial')}` : '', payload.refreshing ? ` · ${t('refreshing…')}` : '', error ? ` · ${hintFor(error, { repo })}` : '', onRetry ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm bt-del-retry', onClick: onRetry }, t('Retry')) : null, diff --git a/patches/ds-bot/files/test/delivery-chart.test.js b/patches/ds-bot/files/test/delivery-chart.test.js index 013f8ee80..a16eb3dd2 100644 --- a/patches/ds-bot/files/test/delivery-chart.test.js +++ b/patches/ds-bot/files/test/delivery-chart.test.js @@ -126,6 +126,19 @@ test('#1212: the CI metric is the red share of the runs that counted', () => { assert.equal(knownDays(daily, 'ci'), 6) }) +test('#1244: a day counted only by total still draws the share the header row reads', () => { + // `counted: 0` means the host could not say which runs were superseded, not that the day had + // nothing to count: the line draws `failed / total`, and the header row now reads the same + // denominator instead of printing '—' beside a dot. + const countedByTotal = { date: '2026-10-26', partial: true, ci: { failed: 1, counted: 0, total: 5 } } + assert.equal(dayValue(countedByTotal, 'ci'), 0.2) + const geo = chartGeometry([countedByTotal], 'ci') + assert.equal(geo.dots[0].state, 'partial', 'today is still running, and it is still drawn') + assert.equal(geo.dots[0].title, '2026-10-26 · 20%') + // Both columns empty is still unknown: neither a dot nor a share. + assert.equal(dayValue({ date: '2026-10-26', ci: { failed: 0, counted: 0, total: 0 } }, 'ci'), null) +}) + test('#1212: the component draws a 168×28 svg with a state and a title on every point', () => { const node = DeliveryChart({ daily, metricKey: 'newIssues', label: 'New issues' }) assert.equal(node.type, 'svg') diff --git a/patches/ds-bot/files/test/delivery-view.test.js b/patches/ds-bot/files/test/delivery-view.test.js index 75e58ce86..3e288e074 100644 --- a/patches/ds-bot/files/test/delivery-view.test.js +++ b/patches/ds-bot/files/test/delivery-view.test.js @@ -102,7 +102,7 @@ const mount = (Component, props) => { return { draw, rerender: draw } } const { - createPoller, DELIVERY_COLUMNS, DeliveryBoard, badgeLabel, duration, metricRows, pollDelay, retryCountdown, + createPoller, DELIVERY_COLUMNS, DeliveryBoard, DeliveryHeader, badgeLabel, duration, metricRows, pollDelay, retryCountdown, loadingText, staleText, hintFor, missingText, probeText, } = module.exports @@ -207,6 +207,91 @@ test('the CI red share reaches the page as a line, and the medians draw none (#1 } }) +test('a day the host could only count by total reads the same share in the row as on the line (#1244)', () => { + // The header row required `counted`, so a day with `counted: 0, total: 5` printed '—' while the + // 7-day line beside it drew a dot from `total`: one day, two answers. The row now uses the same + // denominator the chart draws. + const countedByTotal = { date: '2026-10-25', partial: true, newIssues: 3, closedIssues: 1, newPullRequests: 0, mergedPullRequests: 0, ci: { failed: 1, counted: 0, total: 5 } } + const metrics = { + ...snapshot.metrics, + today: { ...snapshot.metrics.today, ci: countedByTotal.ci }, + daily: [countedByTotal], + } + const rows = metricRows(metrics) + assert.equal(rows.rows[4].today, '20%', 'one red run of five is 20%, not unknown') + // The denominator the chart uses for that same day (delivery-chart.test.js pins the number). + assert.equal(countedByTotal.ci.failed / countedByTotal.ci.total, 0.2) + // A window with no runs at all still has no share: unknown is not 0%, and it is not a dot either. + assert.equal(metricRows({ ...metrics, today: { ...snapshot.metrics.today, ci: { failed: 0, counted: 0, total: 0 } } }).rows[4].today, '—') +}) + +test('a partial day prints its time once, in the task zone, instead of twice (#1244)', async () => { + // #1244: the paragraph printed `as of {timeOf}` and then appended a hint carrying its own + // `clockOf` of the same instant — the time twice, and from two different renderings. The hint now + // follows the single time, and that time is the hint's own zone rendering. + const daily = [ + { date: '2026-10-19', newIssues: 1, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 0, ci: { failed: 0, counted: 10 } }, + { date: '2026-10-20', newIssues: 2, closedIssues: 1, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 2, counted: 10 } }, + { date: '2026-10-21', newIssues: 0, closedIssues: 3, newPullRequests: 2, mergedPullRequests: 1, ci: { failed: 1, counted: 4 } }, + { date: '2026-10-22', newIssues: 4, closedIssues: 2, newPullRequests: 0, mergedPullRequests: 2, ci: { failed: null, counted: null } }, + { date: '2026-10-23', newIssues: 3, closedIssues: 3, newPullRequests: 1, mergedPullRequests: 1, ci: { failed: 0, counted: 8 } }, + { date: '2026-10-24', newIssues: 5, closedIssues: 4, newPullRequests: 3, mergedPullRequests: 2, ci: { failed: 3, counted: 12 } }, + { date: '2026-10-25', partial: true, newIssues: 4, closedIssues: 2, newPullRequests: 5, mergedPullRequests: 3, ci: { failed: 2, counted: 10 } }, + ] + const snapshotWith = dailySeries => ({ ...snapshot, metrics: { ...snapshot.metrics, daily: dailySeries } }) + const asOfText = (Component, input) => { + const paragraph = walk(Component({ snapshot: input })).find(node => node.props?.className === 'bt-del-asof') + assert.ok(paragraph, 'the header carries an as-of line') + return text(paragraph).replace(/\s+/g, ' ').trim() + } + const times = words => [...words.matchAll(/\d{1,2}:\d{2}/g)].length + + const english = asOfText(DeliveryHeader, snapshotWith(daily)) + assert.equal(times(english), 1, `one time string, got: ${english}`) + assert.match(english, /today is partial/) + // The same paragraph in Chinese, through the same bundled module: the duplicate 截至 is gone too. + const { DeliveryHeader: DeliveryHeaderZh, installLocale } = await build({ + stdin: { + contents: `export { DeliveryHeader } from ${JSON.stringify(fileURLToPath(new URL('../src/client/delivery-view.js', import.meta.url)))}\nexport { installLocale } from ${JSON.stringify(fileURLToPath(new URL('../src/client/i18n.js', import.meta.url)))}`, + resolveDir: fileURLToPath(new URL('../src/client/', import.meta.url)), + loader: 'js', + }, + bundle: true, format: 'cjs', platform: 'node', write: false, external: ['react'], + }).then(async out => { + const module = { exports: {} } + runInNewContext(out.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, Date: FakeDate, Promise, Number, Math, String, Array, Object, + setInterval: timers.setInterval, clearInterval: timers.clearInterval, + require: (name) => { + if (name !== 'react') throw new Error(`no platform module ${name}`) + return reactStub + }, + }) + return module.exports + }) + const dictionaries = new Map() + const off = installLocale({ + register: (namespace, locale, dict) => { dictionaries.set(`${namespace}:${locale}`, dict); return () => dictionaries.delete(`${namespace}:${locale}`) }, + bind: namespace => (key, params) => { + const words = dictionaries.get(`${namespace}:zh`)?.[key] ?? key + return params ? words.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : words + }, + getLocale: () => ({ active: 'zh' }), + }) + try { + const chinese = asOfText(DeliveryHeaderZh, snapshotWith(daily)) + assert.equal(times(chinese), 1, `one time string, got: ${chinese}`) + assert.match(chinese, /今天为部分日/) + assert.equal((chinese.match(/截至/g) ?? []).length, 1, 'the hint no longer repeats 截至') + } finally { + off() + } + // A finished day keeps the local-clock rendering and gains no hint at all. + const finished = asOfText(DeliveryHeader, snapshotWith(daily.map(day => ({ ...day, partial: false })))) + assert.equal(times(finished), 1) + assert.equal(finished.includes('today is partial'), false) +}) + test('the board renders all five columns and a card with labels, owner and link (#1173)', () => { const board = DeliveryBoard({ snapshot: { ...snapshot, columns: snapshot.columns }, message: '' }) const sections = walk(board).filter(node => node.type === 'section' && node.props?.['data-column'])