diff --git a/changelog.d/2048-room-rail-bound.fixed.md b/changelog.d/2048-room-rail-bound.fixed.md new file mode 100644 index 000000000..a92d5325d --- /dev/null +++ b/changelog.d/2048-room-rail-bound.fixed.md @@ -0,0 +1 @@ +The task board's group rail reads a Bot's own slice of the room's cut archive page as `done/total`, so a Bot with 18 of the newest 20 finished cards says `18/18` while the room holds 45. A full page is now a lower bound for every Bot on it (`18+ done`), and a page under the limit still reads its exact counts ([#2048](https://github.com/ThinkFlowLab/FlyBot/issues/2048)). diff --git a/core/client.js b/core/client.js index 10d414d86..d0372df7a 100644 --- a/core/client.js +++ b/core/client.js @@ -9033,7 +9033,7 @@ 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 capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : 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, diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 0e328ec9f..b30bfd4c2 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -325,8 +325,10 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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 + // a fraction that reads as if the page were the history. On a group board the page is cut + // across the room, so a Bot's share of it is a slice of that Bot's history, not its total: + // any share drawn from a cut page is a lower bound. + const capped = archiveFull && (scope?.kind === 'group' ? count.done > 0 : 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 }) diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index bc960839c..3af372b4f 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -372,3 +372,35 @@ test('#2045: a page that is not full still reads as the whole history, unchanged 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') }) + +// #2045: on a group board the archive page is cut across the whole room, so one Bot's share of it +// is a slice of that Bot's history, not its total. A Bot with 18 of the 20 newest finished cards +// must not read `18/18`, which claims the room has nothing older of its, and the hedge may not be +// keyed on a Bot that happens to fill the page: any Bot whose finished rows are drawn from a cut +// page is a lower bound. +test('#2045: a Bot whose share of a full group page is drawn reads as a lower bound, not a total', () => { + const page = (owner, count, offset) => Array.from({ length: count }, (_, index) => task(`task-done-${owner}-${offset + index}`, 'accepted', { + title: `Finished ${offset + index}`, ownerBotId: owner, progress: 100, createdAt: offset + index, updatedAt: 100 + offset + index, + })) + const archive = [...page('a', 18, 0), ...page('s', 2, 18)] + const group = boardWith({ kind: 'group', roomId: 'war' }, [], archive) + assert.equal(byClass(group.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'the column still draws one page') + assert.equal(textOf(group.column('done')).includes('20 newest'), true, 'the heading still names the page as a cut') + assert.equal(group.railMeta('a'), '18+ done', 'eighteen of a cut page is at least eighteen, never a total') + assert.equal(group.railMeta('s'), '2+ done', 'a smaller share of the same cut page is a lower bound too') + // One row under the page is the whole room history, so the same rail reads its exact counts. + const shortRoom = boardWith({ kind: 'group', roomId: 'war' }, [], [...page('a', 1, 0), ...page('s', 2, 1)]) + assert.equal(shortRoom.railMeta('a'), '1/1', 'a short group page is a total, not a bound') + assert.equal(shortRoom.railMeta('s'), '2/2', 'and every Bot on it reads the same way') +}) + +// #2045: the hedge belongs to the Bot's own drawn finished rows, not to whatever its width adds up +// to. A second Bot with a page-worth of active cards and none finished is not a Bot with 20 done. +test('#2045: the rail hedge follows the Bot\'s drawn done rows, not its total', () => { + const active = Array.from({ length: ARCHIVE_SHOWN }, (_, index) => task(`task-open-${index}`, 'open', { + title: `Open ${index}`, ownerBotId: 'a', progress: 0, createdAt: index, updatedAt: index, + })) + const board = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN)) + assert.equal(board.railMeta('s'), '20+ done', 'the Bot whose own page is full reads as a cut') + assert.equal(board.railMeta('a'), '0/20', 'twenty active cards are not twenty finished ones') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 13bc24f8f..454722c2a 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. 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/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, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; 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/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index eee83c9d4..583818f88 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -10337,7 +10337,7 @@ 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 capped = archiveFull && (scope?.kind === "group" ? count.done > 0 : 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, diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 0e328ec9f..b30bfd4c2 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -325,8 +325,10 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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 + // a fraction that reads as if the page were the history. On a group board the page is cut + // across the room, so a Bot's share of it is a slice of that Bot's history, not its total: + // any share drawn from a cut page is a lower bound. + const capped = archiveFull && (scope?.kind === 'group' ? count.done > 0 : 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 }) diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index bc960839c..3af372b4f 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -372,3 +372,35 @@ test('#2045: a page that is not full still reads as the whole history, unchanged 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') }) + +// #2045: on a group board the archive page is cut across the whole room, so one Bot's share of it +// is a slice of that Bot's history, not its total. A Bot with 18 of the 20 newest finished cards +// must not read `18/18`, which claims the room has nothing older of its, and the hedge may not be +// keyed on a Bot that happens to fill the page: any Bot whose finished rows are drawn from a cut +// page is a lower bound. +test('#2045: a Bot whose share of a full group page is drawn reads as a lower bound, not a total', () => { + const page = (owner, count, offset) => Array.from({ length: count }, (_, index) => task(`task-done-${owner}-${offset + index}`, 'accepted', { + title: `Finished ${offset + index}`, ownerBotId: owner, progress: 100, createdAt: offset + index, updatedAt: 100 + offset + index, + })) + const archive = [...page('a', 18, 0), ...page('s', 2, 18)] + const group = boardWith({ kind: 'group', roomId: 'war' }, [], archive) + assert.equal(byClass(group.column('done'), 'bt-board-card').length, ARCHIVE_SHOWN, 'the column still draws one page') + assert.equal(textOf(group.column('done')).includes('20 newest'), true, 'the heading still names the page as a cut') + assert.equal(group.railMeta('a'), '18+ done', 'eighteen of a cut page is at least eighteen, never a total') + assert.equal(group.railMeta('s'), '2+ done', 'a smaller share of the same cut page is a lower bound too') + // One row under the page is the whole room history, so the same rail reads its exact counts. + const shortRoom = boardWith({ kind: 'group', roomId: 'war' }, [], [...page('a', 1, 0), ...page('s', 2, 1)]) + assert.equal(shortRoom.railMeta('a'), '1/1', 'a short group page is a total, not a bound') + assert.equal(shortRoom.railMeta('s'), '2/2', 'and every Bot on it reads the same way') +}) + +// #2045: the hedge belongs to the Bot's own drawn finished rows, not to whatever its width adds up +// to. A second Bot with a page-worth of active cards and none finished is not a Bot with 20 done. +test('#2045: the rail hedge follows the Bot\'s drawn done rows, not its total', () => { + const active = Array.from({ length: ARCHIVE_SHOWN }, (_, index) => task(`task-open-${index}`, 'open', { + title: `Open ${index}`, ownerBotId: 'a', progress: 0, createdAt: index, updatedAt: index, + })) + const board = boardWith({ kind: 'bot', botId: 's' }, active, settledTasks(ARCHIVE_SHOWN)) + assert.equal(board.railMeta('s'), '20+ done', 'the Bot whose own page is full reads as a cut') + assert.equal(board.railMeta('a'), '0/20', 'twenty active cards are not twenty finished ones') +})