diff --git a/changelog.d/2045-board-archive-counts.fixed.md b/changelog.d/2045-board-archive-counts.fixed.md new file mode 100644 index 000000000..7b0e4e0e6 --- /dev/null +++ b/changelog.d/2045-board-archive-counts.fixed.md @@ -0,0 +1 @@ +The task board's Done column is one archive page of 20 cards, and until now it presented that page as the whole story: a Bot with 45 finished tasks read **Done 20**, and its rail row read **20/22** as if 22 were everything. When the archive page is full the column heading now reads **20 newest** and the rail row reads **20+ done** — what was drawn, and that there is more; a page under the limit, and the empty board, read exactly as before ([#2045](https://github.com/ThinkFlowLab/FlyBot/issues/2045)). diff --git a/core/client.js b/core/client.js index 0d1fdff4b..c9ab889ad 100644 --- a/core/client.js +++ b/core/client.js @@ -259,6 +259,8 @@ var ZH = { "{from} → {to}": "{from} → {to}", "+{count} more": "另有 {count} 条", "{count} more not shown": "还有 {count} 条未显示", + "{count} newest": "最新 {count} 张", + "{count}+ done": "{count}+ 已完成", "Reading the decision…": "正在读取决策…", "not listed": "不在列表里", delegated: "委托转达", @@ -8952,7 +8954,7 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) function cardProps(task, roster, onMove, onDrag, partOf2) { return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { const tree = lanes ? columnTree(tasks) : null; const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; @@ -8977,7 +8979,9 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) "h3", { className: "bt-board-column-head" }, (0, import_react15.createElement)("span", null, columnTitle(column)), - (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) + // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the + // owner has, so a full page says `20 newest` instead of claiming the page is the total. + (0, import_react15.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) ), dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, @@ -9000,7 +9004,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) ); } -function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { const columns = boardColumns(tasks); const lanes = scope?.kind === "group"; const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -9014,12 +9018,14 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { const count = counts2?.[row.id] ?? { done: 0, total: 0 }; + const capped = archiveFull && count.done >= ARCHIVE_SHOWN; + const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); return (0, import_react15.createElement)(RailButton, { key: row.id, row, selected: row.id === selectedBot, onSelect, - meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" + meta }); }), rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, @@ -9050,7 +9056,8 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro lanes, dragging, onMove, - onDrag + onDrag, + archiveFull })) ) ) @@ -9098,8 +9105,9 @@ function KanbanDialog({ roster, actions, leaving = false }) { if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); }, []); + const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; const counts2 = {}; - for (const task of [...own, ...archive]) { + for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { const id = task.ownerBotId; const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); if (task.status === "cancelled" || task.status === "failed") continue; @@ -9154,6 +9162,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { dragging, error, draft, + archiveFull, onDraft: setDraft, onSelect: setScope, onMove: move, diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 1bb3b594e..68137745f 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -226,6 +226,8 @@ const ZH = { '{from} → {to}': '{from} → {to}', '+{count} more': '另有 {count} 条', '{count} more not shown': '还有 {count} 条未显示', + '{count} newest': '最新 {count} 张', + '{count}+ done': '{count}+ 已完成', 'Reading the decision…': '正在读取决策…', 'not listed': '不在列表里', delegated: '委托转达', diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 8eec81c03..0e328ec9f 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -278,7 +278,7 @@ function cardProps(task, roster, onMove, onDrag, partOf) { return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { const tree = lanes ? columnTree(tasks) : null const linked = tree ? tree.loose.filter(task => task.parentId || task.parentTitle) : [] const plain = tree ? tree.loose.filter(task => !task.parentId && !task.parentTitle) : tasks @@ -296,7 +296,9 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) }, h('h3', { className: 'bt-board-column-head' }, h('span', null, columnTitle(column)), - h('span', { className: 'bt-board-count' }, String(tasks.length))), + // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the + // owner has, so a full page says `20 newest` instead of claiming the page is the total. + h('span', { className: 'bt-board-count' }, column === 'done' && archiveFull ? t('{count} newest', { count: tasks.length }) : String(tasks.length))), dragging ? h('div', { className: 'bt-board-drop' }, dropLabel(column)) : null, tasks.length === 0 && !dragging ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, tree ? tree.nests.map(nest => h('div', { key: nest.task.id, className: 'bt-board-nest' }, @@ -311,7 +313,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { +export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -321,9 +323,16 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { const count = counts?.[row.id] ?? { done: 0, total: 0 } + // #2045: a Bot whose Done column is a full archive page has at least that many finished + // cards, and the total the rail adds up is only the page — so it says `20+ done` rather than + // a fraction that reads as if the page were the history. + const capped = archiveFull && count.done >= ARCHIVE_SHOWN + const meta = count.total === 0 + ? '' + : capped ? t('{count}+ done', { count: count.done }) : t('{done}/{total}', { done: count.done, total: count.total }) return h(RailButton, { key: row.id, row, selected: row.id === selectedBot, onSelect, - meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', + meta, }) }), rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, @@ -336,7 +345,7 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, h('div', { className: 'bt-board-columns' }, ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, }))))) } @@ -379,8 +388,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (kind === 'bot' && roster.byId?.[id]) setScope({ kind: 'bot', botId: id }) if (kind === 'group' && roster.roomsById?.[id]) setScope({ kind: 'group', roomId: id }) }, []) + // #2045: a full page is a cut — the host answers the newest `ARCHIVE_SHOWN` finished rows and no + // total — so this is the only signal the board has that more may exist. + const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN const counts = {} - for (const task of [...own, ...archive]) { + // #2045: the rail counts what the board draws, so the archive contributes at most its one page. + // The list can hold a row more than the page after an optimistic move, and counting that row would + // read as a total the board never showed. + for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { const id = task.ownerBotId const bucket = counts[id] ?? (counts[id] = { done: 0, total: 0 }) if (task.status === 'cancelled' || task.status === 'failed') continue @@ -420,7 +435,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index d3d75028c..bc960839c 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -311,3 +311,64 @@ test('#1252: a Bot board asks the host for that Bot\'s archive, not the whole sc const done = boardColumns(boardTasksFor([], [otherBotCard].filter(task => task.ownerBotId === 'a'))).done assert.equal(done.length, 1, 'the Bot\'s own finished card fills the Done column') }) + +// #2045: the Done column is one archive page (`ARCHIVE_SHOWN` cards), and the page is a cut, not a +// count. The heading prints `tasks.length`, so a Bot with 45 finished tasks reads `Done · 20`, and +// the rail adds the page to the active list, so it reads `20/22`. Neither number is what the owner +// has; when the page is full the board must not pretend it is the total. It may still say how much +// it drew — `20 newest` — and that there are more where those came from — `20+ done`. A page that +// is not full is the whole history, so those two numbers stay exactly as they are today. +const settledTasks = (count, extra = {}) => Array.from({ length: count }, (_, index) => task(`task-done-${index}`, 'accepted', { + title: `Finished ${index}`, progress: 100, createdAt: index, updatedAt: 100 + index, ...extra, +})) +const boardWith = (scope, own, archive) => { + states.length = 0 + states.push(scope, null, '', '', own, [], archive) + const view = expand(KanbanDialog({ roster, actions: { closeBoard: () => {}, boardClose: () => {} } })) + const column = name => byClass(view, 'bt-board-column').find(node => node.props['data-column'] === name) + const railMeta = id => { + const row = all(view).find(node => node.props?.['data-id'] === id) + const meta = byClass(row, 'bt-board-rail-meta')[0] + return meta ? textOf(meta) : '' + } + return { view, column, railMeta } +} + +test('#2045: a full archive page says how many rows were drawn and that there are more, on a Bot board and a group board', () => { + const active = [ + task('task-open-1', 'open', { title: 'Still to do', ownerBotId: 's' }), + task('task-work-1', 'working', { title: 'Being done', ownerBotId: 's' }), + ] + const botBoard = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(45)) + const done = botBoard.column('done') + assert.equal(byClass(done, 'bt-board-card').length, ARCHIVE_SHOWN, 'the column still draws one archive page') + assert.equal(textOf(done).includes('20 newest'), true, 'the heading says these are the newest twenty, not the total') + // The other two columns are the active list, not a page, so their counts stay plain numbers. + assert.equal(textOf(botBoard.column('not-started')).includes('newest'), false, 'the open column keeps a plain count') + assert.equal(textOf(botBoard.column('in-progress')).includes('newest'), false, 'the working column keeps a plain count') + assert.equal(botBoard.railMeta('s'), '20+ done', 'the rail says at least twenty are done, and stops claiming a total') + + // The group board reads the same archive page and must say the same thing. + const groupBoard = boardWith({ kind: 'group', roomId: 'war' }, [], settledTasks(45, { ownerBotId: 'a' })) + const groupDone = groupBoard.column('done') + assert.equal(byClass(groupDone, 'bt-board-card').length, ARCHIVE_SHOWN) + assert.equal(textOf(groupDone).includes('20 newest'), true, 'a full group page is marked the same way') +}) + +test('#2045: a page that is not full still reads as the whole history, unchanged', () => { + const active = [ + task('task-open-1', 'open', { title: 'Still to do', ownerBotId: 's' }), + task('task-work-1', 'working', { title: 'Being done', ownerBotId: 's' }), + ] + const short = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(3)) + assert.equal(byClass(short.column('done'), 'bt-board-card').length, 3) + assert.equal(textOf(short.column('done')).includes('newest'), false, 'three rows are three rows, with no hedge') + assert.equal(short.railMeta('s'), '3/5', 'the rail keeps {done}/{total} when nothing was cut') + + // Exactly one page full is the boundary: the board cannot know whether a 21st card exists, so the + // full-page wording has to start at the page size itself, never a row later. + const exact = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN)) + assert.equal(exact.railMeta('s'), '20+ done', 'a page at the limit is treated as cut') + const under = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN - 1)) + assert.equal(under.railMeta('s'), '19/21', 'one row under the limit is the whole history') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 982d7ab86..b05f1f717 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -45,7 +45,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history; a page under the limit keeps `{done}/{total}` exactly as before. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 6ddfdfd97..09e9409a0 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -748,7 +748,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Content: '内容', 'Referenced {labels}': '引用了 {labels}', ', ': '、', -@@ -41,22 +41,76 @@ const ZH = { +@@ -41,22 +41,78 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -805,6 +805,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '{from} → {to}': '{from} → {to}', + '+{count} more': '另有 {count} 条', + '{count} more not shown': '还有 {count} 条未显示', ++ '{count} newest': '最新 {count} 张', ++ '{count}+ done': '{count}+ 已完成', + 'Reading the decision…': '正在读取决策…', + 'not listed': '不在列表里', + delegated: '委托转达', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d8f59377d..2f6a33f5b 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -157,7 +157,7 @@ diff --git a/client.js b/client.js Content: "内容", "Referenced {labels}": "引用了 {labels}", ", ": "、", -@@ -78,21 +78,75 @@ var ZH = { +@@ -78,21 +78,77 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -213,6 +213,8 @@ diff --git a/client.js b/client.js + "{from} → {to}": "{from} → {to}", + "+{count} more": "另有 {count} 条", + "{count} more not shown": "还有 {count} 条未显示", ++ "{count} newest": "最新 {count} 张", ++ "{count}+ done": "{count}+ 已完成", + "Reading the decision…": "正在读取决策…", + "not listed": "不在列表里", + delegated: "委托转达", @@ -5131,7 +5133,7 @@ diff --git a/client.js b/client.js .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}} -@@ -2595,2914 +2595,6754 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2595,2914 +2595,6761 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} :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}} `; @@ -10224,7 +10226,7 @@ diff --git a/client.js b/client.js +function cardProps(task, roster, onMove, onDrag, partOf2) { + return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; +} -+function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { ++function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { + const tree = lanes ? columnTree(tasks) : null; + const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; + const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; @@ -10249,7 +10251,9 @@ diff --git a/client.js b/client.js + "h3", + { className: "bt-board-column-head" }, + (0, import_react15.createElement)("span", null, columnTitle(column)), -+ (0, import_react15.createElement)("span", { className: "bt-board-count" }, String(tasks.length)) ++ // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the ++ // owner has, so a full page says `20 newest` instead of claiming the page is the total. ++ (0, import_react15.createElement)("span", { className: "bt-board-count" }, column === "done" && archiveFull ? t("{count} newest", { count: tasks.length }) : String(tasks.length)) + ), + dragging ? (0, import_react15.createElement)("div", { className: "bt-board-drop" }, dropLabel(column)) : null, + tasks.length === 0 && !dragging ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("Nothing in this column.")) : null, @@ -10299,7 +10303,7 @@ diff --git a/client.js b/client.js - }); - }; - return (0, import_react5.createElement)( -+function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { ++function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -10318,12 +10322,14 @@ diff --git a/client.js b/client.js + (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Your Bots")), + rail.bots.length === 0 ? (0, import_react15.createElement)("p", { className: "bt-board-empty" }, t("No tasks yet.")) : rail.bots.map((row) => { + const count = counts2?.[row.id] ?? { done: 0, total: 0 }; ++ const capped = archiveFull && count.done >= ARCHIVE_SHOWN; ++ const meta = count.total === 0 ? "" : capped ? t("{count}+ done", { count: count.done }) : t("{done}/{total}", { done: count.done, total: count.total }); + return (0, import_react15.createElement)(RailButton, { + key: row.id, + row, + selected: row.id === selectedBot, + onSelect, -+ meta: count.total > 0 ? t("{done}/{total}", { done: count.done, total: count.total }) : "" ++ meta + }); + }), + rail.groups.length > 0 ? (0, import_react15.createElement)("h3", { className: "bt-board-rail-head" }, t("Groups you admin")) : null, @@ -10358,7 +10364,8 @@ diff --git a/client.js b/client.js + lanes, + dragging, + onMove, -+ onDrag ++ onDrag, ++ archiveFull + })) + ) ) @@ -10514,8 +10521,9 @@ diff --git a/client.js b/client.js + if (kind === "bot" && roster.byId?.[id]) setScope({ kind: "bot", botId: id }); + if (kind === "group" && roster.roomsById?.[id]) setScope({ kind: "group", roomId: id }); + }, []); ++ const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN; + const counts2 = {}; -+ for (const task of [...own, ...archive]) { ++ for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { + const id = task.ownerBotId; + const bucket = counts2[id] ?? (counts2[id] = { done: 0, total: 0 }); + if (task.status === "cancelled" || task.status === "failed") continue; @@ -10600,6 +10608,7 @@ diff --git a/client.js b/client.js + dragging, + error, + draft, ++ archiveFull, + onDraft: setDraft, + onSelect: setScope, + onMove: move, diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 8eec81c03..0e328ec9f 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -278,7 +278,7 @@ function cardProps(task, roster, onMove, onDrag, partOf) { return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { const tree = lanes ? columnTree(tasks) : null const linked = tree ? tree.loose.filter(task => task.parentId || task.parentTitle) : [] const plain = tree ? tree.loose.filter(task => !task.parentId && !task.parentTitle) : tasks @@ -296,7 +296,9 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) }, h('h3', { className: 'bt-board-column-head' }, h('span', null, columnTitle(column)), - h('span', { className: 'bt-board-count' }, String(tasks.length))), + // #2045: the Done column is one archive page. `tasks.length` is what was drawn, not what the + // owner has, so a full page says `20 newest` instead of claiming the page is the total. + h('span', { className: 'bt-board-count' }, column === 'done' && archiveFull ? t('{count} newest', { count: tasks.length }) : String(tasks.length))), dragging ? h('div', { className: 'bt-board-drop' }, dropLabel(column)) : null, tasks.length === 0 && !dragging ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, tree ? tree.nests.map(nest => h('div', { key: nest.task.id, className: 'bt-board-nest' }, @@ -311,7 +313,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag }) : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate }) { +export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -321,9 +323,16 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('h3', { className: 'bt-board-rail-head' }, t('Your Bots')), rail.bots.length === 0 ? h('p', { className: 'bt-board-empty' }, t('No tasks yet.')) : rail.bots.map(row => { const count = counts?.[row.id] ?? { done: 0, total: 0 } + // #2045: a Bot whose Done column is a full archive page has at least that many finished + // cards, and the total the rail adds up is only the page — so it says `20+ done` rather than + // a fraction that reads as if the page were the history. + const capped = archiveFull && count.done >= ARCHIVE_SHOWN + const meta = count.total === 0 + ? '' + : capped ? t('{count}+ done', { count: count.done }) : t('{done}/{total}', { done: count.done, total: count.total }) return h(RailButton, { key: row.id, row, selected: row.id === selectedBot, onSelect, - meta: count.total > 0 ? t('{done}/{total}', { done: count.done, total: count.total }) : '', + meta, }) }), rail.groups.length > 0 ? h('h3', { className: 'bt-board-rail-head' }, t('Groups you admin')) : null, @@ -336,7 +345,7 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, h('div', { className: 'bt-board-columns' }, ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, }))))) } @@ -379,8 +388,14 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (kind === 'bot' && roster.byId?.[id]) setScope({ kind: 'bot', botId: id }) if (kind === 'group' && roster.roomsById?.[id]) setScope({ kind: 'group', roomId: id }) }, []) + // #2045: a full page is a cut — the host answers the newest `ARCHIVE_SHOWN` finished rows and no + // total — so this is the only signal the board has that more may exist. + const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN const counts = {} - for (const task of [...own, ...archive]) { + // #2045: the rail counts what the board draws, so the archive contributes at most its one page. + // The list can hold a row more than the page after an optimistic move, and counting that row would + // read as a total the board never showed. + for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) { const id = task.ownerBotId const bucket = counts[id] ?? (counts[id] = { done: 0, total: 0 }) if (task.status === 'cancelled' || task.status === 'failed') continue @@ -420,7 +435,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index d3d75028c..bc960839c 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -311,3 +311,64 @@ test('#1252: a Bot board asks the host for that Bot\'s archive, not the whole sc const done = boardColumns(boardTasksFor([], [otherBotCard].filter(task => task.ownerBotId === 'a'))).done assert.equal(done.length, 1, 'the Bot\'s own finished card fills the Done column') }) + +// #2045: the Done column is one archive page (`ARCHIVE_SHOWN` cards), and the page is a cut, not a +// count. The heading prints `tasks.length`, so a Bot with 45 finished tasks reads `Done · 20`, and +// the rail adds the page to the active list, so it reads `20/22`. Neither number is what the owner +// has; when the page is full the board must not pretend it is the total. It may still say how much +// it drew — `20 newest` — and that there are more where those came from — `20+ done`. A page that +// is not full is the whole history, so those two numbers stay exactly as they are today. +const settledTasks = (count, extra = {}) => Array.from({ length: count }, (_, index) => task(`task-done-${index}`, 'accepted', { + title: `Finished ${index}`, progress: 100, createdAt: index, updatedAt: 100 + index, ...extra, +})) +const boardWith = (scope, own, archive) => { + states.length = 0 + states.push(scope, null, '', '', own, [], archive) + const view = expand(KanbanDialog({ roster, actions: { closeBoard: () => {}, boardClose: () => {} } })) + const column = name => byClass(view, 'bt-board-column').find(node => node.props['data-column'] === name) + const railMeta = id => { + const row = all(view).find(node => node.props?.['data-id'] === id) + const meta = byClass(row, 'bt-board-rail-meta')[0] + return meta ? textOf(meta) : '' + } + return { view, column, railMeta } +} + +test('#2045: a full archive page says how many rows were drawn and that there are more, on a Bot board and a group board', () => { + const active = [ + task('task-open-1', 'open', { title: 'Still to do', ownerBotId: 's' }), + task('task-work-1', 'working', { title: 'Being done', ownerBotId: 's' }), + ] + const botBoard = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(45)) + const done = botBoard.column('done') + assert.equal(byClass(done, 'bt-board-card').length, ARCHIVE_SHOWN, 'the column still draws one archive page') + assert.equal(textOf(done).includes('20 newest'), true, 'the heading says these are the newest twenty, not the total') + // The other two columns are the active list, not a page, so their counts stay plain numbers. + assert.equal(textOf(botBoard.column('not-started')).includes('newest'), false, 'the open column keeps a plain count') + assert.equal(textOf(botBoard.column('in-progress')).includes('newest'), false, 'the working column keeps a plain count') + assert.equal(botBoard.railMeta('s'), '20+ done', 'the rail says at least twenty are done, and stops claiming a total') + + // The group board reads the same archive page and must say the same thing. + const groupBoard = boardWith({ kind: 'group', roomId: 'war' }, [], settledTasks(45, { ownerBotId: 'a' })) + const groupDone = groupBoard.column('done') + assert.equal(byClass(groupDone, 'bt-board-card').length, ARCHIVE_SHOWN) + assert.equal(textOf(groupDone).includes('20 newest'), true, 'a full group page is marked the same way') +}) + +test('#2045: a page that is not full still reads as the whole history, unchanged', () => { + const active = [ + task('task-open-1', 'open', { title: 'Still to do', ownerBotId: 's' }), + task('task-work-1', 'working', { title: 'Being done', ownerBotId: 's' }), + ] + const short = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(3)) + assert.equal(byClass(short.column('done'), 'bt-board-card').length, 3) + assert.equal(textOf(short.column('done')).includes('newest'), false, 'three rows are three rows, with no hedge') + assert.equal(short.railMeta('s'), '3/5', 'the rail keeps {done}/{total} when nothing was cut') + + // Exactly one page full is the boundary: the board cannot know whether a 21st card exists, so the + // full-page wording has to start at the page size itself, never a row later. + const exact = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN)) + assert.equal(exact.railMeta('s'), '20+ done', 'a page at the limit is treated as cut') + const under = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN - 1)) + assert.equal(under.railMeta('s'), '19/21', 'one row under the limit is the whole history') +})