From 3ae312aac50992503a76fde999b7fd25a10d277f Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:45:49 +0800 Subject: [PATCH] Inbox Done group opens the finished rows past the cap (#2050) The group counted the rows #2042's cap hid but dropped them from the model, so with 29 finished tasks the ninth..twenty-ninth were unreachable in the UI. The model now carries doneAll and the group draws it in place on request. --- changelog.d/2050-inbox-show-all.fixed.md | 1 + core/client.js | 21 ++++++- core/src/client/i18n.js | 2 + core/src/client/inbox.js | 21 ++++++- core/test/inbox.test.js | 80 +++++++++++++++++++++++- docs/subsystems/core-client.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0008-client-bundle.patch | 25 ++++++-- patches/ds-bot/files/src/client/inbox.js | 21 ++++++- patches/ds-bot/files/test/inbox.test.js | 80 +++++++++++++++++++++++- 10 files changed, 237 insertions(+), 20 deletions(-) create mode 100644 changelog.d/2050-inbox-show-all.fixed.md diff --git a/changelog.d/2050-inbox-show-all.fixed.md b/changelog.d/2050-inbox-show-all.fixed.md new file mode 100644 index 000000000..60051a390 --- /dev/null +++ b/changelog.d/2050-inbox-show-all.fixed.md @@ -0,0 +1 @@ +The Inbox counted the finished tasks its Done group left out but gave no way to see them: with 29 finished tasks the group drew 20, said `9 more not shown`, and those nine were unreachable anywhere in the UI. The model now keeps every finished row and the Done group has a **Show all** / **Show fewer** control that draws the rest in place; collapsed, and at or under the cap, the group reads exactly as before. diff --git a/core/client.js b/core/client.js index c9ab889ad..10d414d86 100644 --- a/core/client.js +++ b/core/client.js @@ -758,6 +758,8 @@ var ZH = { Inbox: "收件箱", "Inbox, {count} need you": "收件箱,{count} 项需要你", "Close inbox": "关闭收件箱", + "Show all": "显示全部", + "Show fewer": "收起", "Your Bots": "你的 Bot", "Needs you": "需要你", "In progress": "进行中", @@ -8229,7 +8231,7 @@ function inboxModel({ roster, work = {}, tasks = [], sessions, workflows = [], n card.calls = toolCalls(card, { roster, work }); } const settled = done.sort(newest); - return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneTotal: settled.length }; + return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length }; } var needsCount = (roster, work, tasks, now, workflows = []) => inboxModel({ roster, work, tasks, now, workflows }).needs.length; function startedAtOf(task) { @@ -8498,6 +8500,7 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) const [busy, setBusy] = (0, import_react13.useState)(""); const [errors, setErrors] = (0, import_react13.useState)({}); const [feedback, setFeedback] = (0, import_react13.useState)(""); + const [allDone, setAllDone] = (0, import_react13.useState)(false); const close = (0, import_react13.useCallback)(() => actions.closeInbox(), []); useEscape(close); (0, import_react13.useEffect)(() => { @@ -8542,7 +8545,9 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) { className: "bt-mem-body bt-inbox-body" }, (0, import_react13.createElement)("div", { className: "bt-inbox-feedback", role: "status", "aria-live": "polite" }, feedback), GROUPS.map((group) => { - const rows = groups[group.id]; + const settled = group.id === "done" ? model.doneAll ?? groups.done : null; + const canOpen = group.id === "done" && settled.length > DONE_MAX; + const rows = canOpen && allDone ? settled : groups[group.id]; const hidden = group.id === "done" ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0; return (0, import_react13.createElement)( "section", @@ -8563,7 +8568,14 @@ function InboxDialog({ roster, useWork, useSessions, actions, leaving = false }) onAnswer: answer, onOpen: open }))), - hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null + hidden > 0 ? (0, import_react13.createElement)("div", { key: "more", className: "bt-inbox-more" }, t("{count} more not shown", { count: hidden })) : null, + canOpen ? (0, import_react13.createElement)("button", { + key: "all", + type: "button", + className: "bt-inbox-show-all", + "aria-expanded": allDone, + onClick: () => setAllDone((value) => !value) + }, t(allDone ? "Show fewer" : "Show all")) : null ); }) ) @@ -8586,6 +8598,9 @@ var INBOX_CSS = ` .bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} .bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} .bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} +.bt-inbox-show-all:hover{text-decoration:underline} +.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} .bt-inbox-list{display:flex;flex-direction:column;gap:6px} .bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} .bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 68137745f..8560fc202 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -702,6 +702,8 @@ const ZH = { Inbox: '收件箱', 'Inbox, {count} need you': '收件箱,{count} 项需要你', 'Close inbox': '关闭收件箱', + 'Show all': '显示全部', + 'Show fewer': '收起', 'Your Bots': '你的 Bot', 'Needs you': '需要你', 'In progress': '进行中', diff --git a/core/src/client/inbox.js b/core/src/client/inbox.js index 912525646..72a9d7a19 100644 --- a/core/src/client/inbox.js +++ b/core/src/client/inbox.js @@ -357,8 +357,10 @@ export function inboxModel({ roster, work = {}, tasks = [], sessions, workflows } // #2042: Done is capped, and the cap used to be silent — the group counted the rows it drew, // so 25 finished tasks read as 20. The sorted list keeps the count it had before the cut. + // #2050: the cap is a page, not the history — the rows past it stay in the model (`doneAll`), + // so the group can draw them in place instead of only saying how many it left out. const settled = done.sort(newest) - return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneTotal: settled.length } + return { needs: needs.sort(newest), progress: progress.sort(newest), done: settled.slice(0, DONE_MAX), doneAll: settled, doneTotal: settled.length } } // The row's badge: how many cards need me. The Done list needs no Session list for this. @@ -611,6 +613,8 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f const [busy, setBusy] = useState('') const [errors, setErrors] = useState({}) const [feedback, setFeedback] = useState('') + // #2050: Done's cap is a page of the history; this draws the rest in place, on demand. + const [allDone, setAllDone] = useState(false) const close = useCallback(() => actions.closeInbox(), []) useEscape(close) useEffect(() => { @@ -652,7 +656,11 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f h('div', { className: 'bt-mem-body bt-inbox-body' }, h('div', { className: 'bt-inbox-feedback', role: 'status', 'aria-live': 'polite' }, feedback), GROUPS.map(group => { - const rows = groups[group.id] + // #2050: the model keeps every finished row, so the group can draw the page's remainder + // in place. Below the cap there is nothing to open, and it draws exactly what it did. + const settled = group.id === 'done' ? (model.doneAll ?? groups.done) : null + const canOpen = group.id === 'done' && settled.length > DONE_MAX + const rows = canOpen && allDone ? settled : groups[group.id] // #2042: Done draws DONE_MAX rows at most. When the model had more, one line under the // rows says how many it left out, in the words the project board's capped list uses (#2021). const hidden = group.id === 'done' ? Math.max(0, (model.doneTotal ?? rows.length) - rows.length) : 0 @@ -665,7 +673,11 @@ export function InboxDialog({ roster, useWork, useSessions, actions, leaving = f : h('div', { className: 'bt-inbox-list' }, rows.map(card => h(InboxCard, { key: card.id, card, roster, now, busy: busy === card.id, error: errors[card.id], onAnswer: answer, onOpen: open, }))), - hidden > 0 ? h('div', { key: 'more', className: 'bt-inbox-more' }, t('{count} more not shown', { count: hidden })) : null) + hidden > 0 ? h('div', { key: 'more', className: 'bt-inbox-more' }, t('{count} more not shown', { count: hidden })) : null, + canOpen ? h('button', { + key: 'all', type: 'button', className: 'bt-inbox-show-all', 'aria-expanded': allDone, + onClick: () => setAllDone(value => !value), + }, t(allDone ? 'Show fewer' : 'Show all')) : null) })))) } @@ -685,6 +697,9 @@ export const INBOX_CSS = ` .bt-inbox-head-count{font-weight:500;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} .bt-inbox-empty{margin:0;font-size:13px;line-height:18px;color:var(--bt-ink-3)} .bt-inbox-more{margin:0;font-size:12.5px;line-height:18px;color:var(--bt-ink-3);font-variant-numeric:tabular-nums} +.bt-inbox-show-all{align-self:flex-start;padding:0;border:0;background:none;font:inherit;font-size:12.5px;line-height:18px;color:var(--bt-accent);cursor:pointer} +.bt-inbox-show-all:hover{text-decoration:underline} +.bt-inbox-show-all:focus-visible{outline:2px solid color-mix(in srgb,var(--bt-accent) 45%,transparent);outline-offset:2px;border-radius:4px} .bt-inbox-list{display:flex;flex-direction:column;gap:6px} .bt-inbox-card{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border:.5px solid var(--bt-line-2);border-radius:12px;background:var(--bt-card,var(--bt-main))} .bt-inbox-card:focus-within{box-shadow:0 0 0 2px color-mix(in srgb,var(--bt-accent) 35%,transparent)} diff --git a/core/test/inbox.test.js b/core/test/inbox.test.js index 21a1fc81d..b3d46cfc9 100644 --- a/core/test/inbox.test.js +++ b/core/test/inbox.test.js @@ -17,13 +17,23 @@ const module = { exports: {} } // A hook's effects are collected, never run: a test that drives a load calls the one it needs. // Timers are stubs so a driven load does not arm a background poll. const effects = [] +// #2050: a hook's state lives per render pass, so a control that flips one can be tested without +// React. A draw rewinds the cursor and a setter writes the slot the next pass reads back; the +// `useState` calls before a component's own slots run in the same order on both passes. +let hookState = [] +let hookCursor = 0 +const resetInboxHooks = () => { hookState = []; hookCursor = 0 } runInNewContext(bundle.outputFiles[0].text, { module, exports: module.exports, setTimeout: () => 0, clearTimeout: () => {}, setInterval: () => 0, clearInterval: () => {}, Date, Promise, require: (name) => { const platform = { react: { createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), - useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], + useState: initial => { + const index = hookCursor++ + if (!(index in hookState)) hookState[index] = typeof initial === 'function' ? initial() : initial + return [hookState[index], value => { hookState[index] = typeof value === 'function' ? value(hookState[index]) : value }] + }, useEffect: fn => { effects.push(fn) }, useLayoutEffect: () => {}, useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', }, 'react-dom': {}, @@ -815,14 +825,21 @@ const emptyInbox = extra => inboxModel({ roster, work: {}, sessions: undefined, const serveInboxTasks = async (rows) => { const actions = { closeInbox: () => {}, tasks: () => Promise.resolve(rows) } effects.length = 0 + hookCursor = 0 useTasks(actions) effects.pop()() await new Promise(resolve => setImmediate(resolve)) return actions } -const drawInboxDialog = actions => InboxDialog({ roster, useWork: pick => pick({}), useSessions: pick => pick({}), actions }) +// A fresh draw starts from the initial state; `keep` redraws on the state a click just wrote. +const drawInboxDialog = (actions, { keep = false } = {}) => { + if (keep) hookCursor = 0 + else resetInboxHooks() + return InboxDialog({ roster, useWork: pick => pick({}), useSessions: pick => pick({}), actions }) +} const resetInboxTasks = async () => { effects.length = 0 + hookCursor = 0 useTasks({ closeInbox: () => {}, tasks: () => Promise.resolve([]) }) effects.pop()() await new Promise(resolve => setImmediate(resolve)) @@ -884,3 +901,62 @@ test('#2042: the Done group says how many rows the cap hid, and says nothing whe await resetInboxTasks() }) + +// #2050: #2042 made the group say how many rows it hid, but the model dropped them, so the count +// line was dead text — nothing in the product could open the rows past the cap, and 9 of 29 +// finished tasks were unreachable. The model keeps them now, and the group can draw them in place. +test('#2050: the model keeps the finished rows past the cap, in the order the Done group draws', () => { + const capped = emptyInbox({ tasks: freshTasks(29) }) + assert.equal(capped.done.length, 20, 'the cap still draws one page') + assert.equal(capped.doneAll.length, 29, 'and the model carries every finished row') + assert.equal(capped.doneTotal, 29, 'the count is unchanged') + assert.deepEqual(plain(ids(capped.doneAll.slice(0, 20))), plain(ids(capped.done)), 'the page is the head of that list, same order') + assert.deepEqual(plain(ids(capped.doneAll)), plain(freshTasks(29).map(row => `task:${row.id}`)), 'newest settled first, as the group draws them') + + const short = emptyInbox({ tasks: freshTasks(5) }) + assert.deepEqual(plain(ids(short.doneAll)), plain(ids(short.done)), 'a short list keeps exactly what it draws') + assert.deepEqual(plain(emptyInbox({ tasks: [] }).doneAll), [], 'an empty list keeps nothing') +}) + +test('#2050: the Done group opens every row it counted, and folds back to the cap', async () => { + const more = tree => byClass(tree, 'bt-inbox-more') + const control = tree => byClass(tree, 'bt-inbox-show-all') + const doneRows = tree => cardsIn(tree).filter(node => node.props.card.id.startsWith('task:d')) + const groupIds = (tree, id) => cardsIn(all(tree).find(node => node.type === 'section' && node.props['aria-labelledby'] === `bt-inbox-${id}`)).map(node => node.props.card.id) + + const rows = [task('n1', 'failed', { actors: [{ status: 'failed', why: 'the responsible Bot was deleted' }] }), task('p1', 'working'), ...freshTasks(29)] + const actions = await serveInboxTasks(rows) + + const collapsed = drawInboxDialog(actions) + assert.equal(doneRows(collapsed).length, 20, 'the cap still draws 20 rows') + assert.equal(textOf(more(collapsed)[0]), '9 more not shown', 'and still says what it hid') + const open = control(collapsed) + assert.equal(open.length, 1, 'one control offers the hidden rows') + assert.equal(open[0].type, 'button', 'it is a control, not dead text') + assert.equal(textOf(open[0]), 'Show all') + assert.equal(open[0].props['aria-expanded'], false, 'and reads as folded') + + open[0].props.onClick() + const expanded = drawInboxDialog(actions, { keep: true }) + assert.equal(doneRows(expanded).length, 29, 'every finished row is drawn in place') + assert.deepEqual(plain(doneRows(expanded).map(node => node.props.card.id)), plain(freshTasks(29).map(row => `task:${row.id}`)), 'the rows and their order are the ones the group already had') + assert.equal(more(expanded).length, 0, 'nothing is hidden, so the count line is gone') + assert.equal(textOf(control(expanded)[0]), 'Show fewer') + assert.equal(control(expanded)[0].props['aria-expanded'], true) + assert.deepEqual(plain(groupIds(expanded, 'needs')), plain(groupIds(collapsed, 'needs')), 'Needs you is untouched') + assert.deepEqual(plain(groupIds(expanded, 'progress')), plain(groupIds(collapsed, 'progress')), 'nor is In progress') + + control(expanded)[0].props.onClick() + const folded = drawInboxDialog(actions, { keep: true }) + assert.equal(doneRows(folded).length, 20, 'closing the toggle returns to the cap') + assert.equal(textOf(more(folded)[0]), '9 more not shown') + assert.equal(textOf(control(folded)[0]), 'Show all') + + // Nothing is hidden at or under the cap, so there is no control and the group is as it was. + const short = drawInboxDialog(await serveInboxTasks(freshTasks(20))) + assert.equal(doneRows(short).length, 20) + assert.equal(control(short).length, 0, 'a page with nothing past it offers no control') + assert.equal(more(short).length, 0) + + await resetInboxTasks() +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b05f1f717..13bc24f8f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -40,7 +40,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/approval-waiting.js` | The one rule for "this hold still waits for the owner's decision" (#734, #754): `isApprovalWaiting(status)` is true for `pending` and `pending_approval` and false for every settled spelling (`approved`, `auto`, `failed`, `rejected`, `stale`, missing). The host writes `pending`; Bot Social's own outbound hold arrives as `pending_approval`. Host and client both import this one file — the held-reply card, the approvals overlay and the chat bubble on this side, `hooks.js` (`decide()`, `heldOf()`, the cap, the in-flight filter, the cleanup) and `channel.js` (a row's wait badge) on the other — and the overlay asks the host for `[...WAITING_STATUSES, 'failed']`, so the two spellings cannot drift apart again on one side only. Tests: `core/test/approval-waiting.test.js`, `core/test/host-approval-status.test.js`. | | `core/src/shared/held-retry.js` | The one rule for "this failure can still be retried" (#968): `canRetry(record)` is false for `retry: false`, for `noRetry: true`, and for a record whose stored `error`/`code` names a terminal reason (`no such remote bot` / `is not shared with your Bots` / `is no longer shared with you` / `has not granted chat.public` / `bot_not_shared` / `bot_not_found`; a bare `capability_not_granted` is not one, #988), true otherwise. The host states the same answer in two spellings (`noRetry` on an approval, `retry: false` on a held reply), so both cards and Bot Social's own dock ask this one function instead of keeping a copy each; a hold written before the flag existed is read from its stored sentence at render time, with no state migration. Tests: `core/test/held-retry.test.js`. | | `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**; a new task opens with no target chosen — **Send to** reads "Pick a Bot or group chat" and **Create task** stays disabled until one is picked, so one click cannot create the task for the first Bot (#1334); an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. A repeat whose interval is not a whole number of minutes is stated as seconds (`Every 90 seconds`, #1651): the edit form opens holding that exact value in the **seconds** unit, and saving a task whose repeat was not touched sends the stored interval back unchanged instead of a rounded number of minutes. The details panel's line (`ScheduleSummary`) lists every task of that Bot, paused and finished ones included, because a Bot whose tasks are all paused has tasks while a Bot with none is the only empty state; each row answers when — `Next: