Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2063-room-rail-active-cards.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A room board's rail counted only the newest page of the room's finished cards, so a Bot whose four cards were all still open read `0/1` on a board that also held one finished card, and the numbers disagreed with the cards drawn in the column beside them. The rail now counts the room's active cards as well, so the same Bot reads `1/4` and every Bot on the page reads the same total ([#2063](https://github.com/ThinkFlowLab/FlyBot/issues/2063)).
6 changes: 3 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8894,7 +8894,7 @@ function useBoardTasks(actions, scope) {
setArchive((list) => done2 ? [task, ...without(list, task.id)] : without(list, task.id));
};
const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId);
return { own, tasks, archive: done, replace };
return { own, groupTasks, tasks, archive: done, replace };
}
function RailButton({ row, selected, meta, onSelect }) {
return (0, import_react15.createElement)(
Expand Down Expand Up @@ -9107,7 +9107,7 @@ function KanbanDialog({ roster, actions, leaving = false }) {
const [dragging, setDragging] = (0, import_react15.useState)(null);
const [error, setError] = (0, import_react15.useState)("");
const [draft, setDraft] = (0, import_react15.useState)("");
const { own, tasks, archive, replace } = useBoardTasks(actions, scope);
const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope);
const boardTasks = boardTasksFor(tasks, archive);
const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []);
useEscape(close);
Expand All @@ -9122,7 +9122,7 @@ function KanbanDialog({ roster, actions, leaving = false }) {
}, []);
const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN;
const counts2 = {};
for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) {
for (const task of [...own, ...scope?.kind === "group" ? groupTasks : [], ...(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;
Expand Down
9 changes: 5 additions & 4 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export function useBoardTasks(actions, scope) {
setArchive(list => done ? [task, ...without(list, task.id)] : without(list, task.id))
}
const done = scope?.kind === 'group' ? archive : archive.filter(task => !scope?.botId || task.ownerBotId === scope.botId)
return { own, tasks, archive: done, replace }
return { own, groupTasks, tasks, archive: done, replace }
}

function RailButton({ row, selected, meta, onSelect }) {
Expand Down Expand Up @@ -375,7 +375,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
const [dragging, setDragging] = useState(null)
const [error, setError] = useState('')
const [draft, setDraft] = useState('')
const { own, tasks, archive, replace } = useBoardTasks(actions, scope)
const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope)
const boardTasks = boardTasksFor(tasks, archive)
const close = useCallback(() => actions.closeBoard(), [])
useEscape(close)
Expand All @@ -396,8 +396,9 @@ export function KanbanDialog({ roster, actions, leaving = false }) {
const counts = {}
// #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)]) {
// read as a total the board never showed. P-364: a room board's active cards live in `groupTasks`;
// `own` is only ever filled on a Bot board, so adding up `own` alone counted the archive page.
for (const task of [...own, ...(scope?.kind === 'group' ? groupTasks : []), ...(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
Expand Down
36 changes: 31 additions & 5 deletions core/test/kanban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,11 @@ test('#1252: a Bot board asks the host for that Bot\'s archive, not the whole sc
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) => {
const boardWith = (scope, own, archive, groupActive = []) => {
states.length = 0
states.push(scope, null, '', '', own, [], archive)
// A room board's active list lives in the second of the three board-task slots (P-364); a Bot
// board's lives in the first. Both are preset here rather than loaded, like the archive page.
states.push(scope, null, '', '', own, groupActive, 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 => {
Expand Down Expand Up @@ -389,9 +391,15 @@ test('#2045: a Bot whose share of a full group page is drawn reads as a lower bo
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')
// P-364 rewrote this pair: the room's active cards used to fall out of the rail altogether (the
// `own` list is only ever filled on a Bot board), so `1/1` and `2/2` counted the archive page
// alone — the defect this test now pins. Two active cards are part of the same totals.
const shortRoom = boardWith({ kind: 'group', roomId: 'war' }, [], [...page('a', 1, 0), ...page('s', 2, 1)], [
task('task-open-a', 'open', { title: 'Open for a', ownerBotId: 'a' }),
task('task-work-s', 'working', { title: 'Work for s', ownerBotId: 's' }),
])
assert.equal(shortRoom.railMeta('a'), '1/2', 'a short group page is a total, and the active card is in it')
assert.equal(shortRoom.railMeta('s'), '2/3', '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
Expand All @@ -404,3 +412,21 @@ test('#2045: the rail hedge follows the Bot\'s drawn done rows, not its total',
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')
})

// P-364: the rail counts what the board draws (#2045), but the room scope only ever fills the
// board's active list, and the rail was adding up `own` — a list that stays empty on a room board.
// So a room rail counted the archive page alone: a Bot with four drawn cards under one finished one
// read `1/1`, and a Bot whose cards are all still active read nothing at all.
test('P-364: a room board rail counts the room\'s active cards, not only its archive page', () => {
const room = boardWith({ kind: 'group', roomId: 'war' }, [], [task('task-done-s', 'accepted', {
title: 'Finished for s', ownerBotId: 's', progress: 100, createdAt: 1, updatedAt: 2,
})], [
task('task-open-s', 'open', { title: 'Open for s', ownerBotId: 's', createdAt: 3, updatedAt: 4 }),
task('task-working-s', 'working', { title: 'Working for s', ownerBotId: 's', createdAt: 5, updatedAt: 6 }),
task('task-open-s-2', 'open', { title: 'Another for s', ownerBotId: 's', createdAt: 7, updatedAt: 8 }),
task('task-open-a', 'open', { title: 'Open for a', ownerBotId: 'a', createdAt: 9, updatedAt: 10 }),
])
assert.equal(byClass(room.column('in-progress'), 'bt-board-card').length, 1, 'the working card is drawn')
assert.equal(room.railMeta('s'), '1/4', 'four drawn cards, one of them finished: {done}/{total}, not {done}/{done}')
assert.equal(room.railMeta('a'), '0/1', 'a Bot with an active card and none finished is 0 of 1, not a blank rail')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<untrusted-message>` 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:<id>`). 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:<id>` 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:<id>` 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/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:<id>` 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. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`) — `own` is only ever filled on a Bot board — so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. 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`. |

Expand Down
6 changes: 3 additions & 3 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -10142,7 +10142,7 @@ diff --git a/client.js b/client.js
- ),
- value !== "" && !long ? (0, import_react5.createElement)("div", { className: "bt-set-hint" }, t("At least {count} characters.", { count: MIN_VALUE })) : replaced ? (0, import_react5.createElement)("div", { className: "bt-set-hint bt-secret-ok", role: "status" }, (0, import_react5.createElement)(CheckIcon), t("Replaced. Bots get the new value from their next command.")) : null
+ const done = scope?.kind === "group" ? archive : archive.filter((task) => !scope?.botId || task.ownerBotId === scope.botId);
+ return { own, tasks, archive: done, replace };
+ return { own, groupTasks, tasks, archive: done, replace };
+}
+function RailButton({ row, selected, meta, onSelect }) {
+ return (0, import_react15.createElement)(
Expand Down Expand Up @@ -10523,7 +10523,7 @@ diff --git a/client.js b/client.js
+ const [dragging, setDragging] = (0, import_react15.useState)(null);
+ const [error, setError] = (0, import_react15.useState)("");
+ const [draft, setDraft] = (0, import_react15.useState)("");
+ const { own, tasks, archive, replace } = useBoardTasks(actions, scope);
+ const { own, groupTasks, tasks, archive, replace } = useBoardTasks(actions, scope);
+ const boardTasks = boardTasksFor(tasks, archive);
+ const close = (0, import_react15.useCallback)(() => actions.closeBoard(), []);
+ useEscape(close);
Expand All @@ -10538,7 +10538,7 @@ diff --git a/client.js b/client.js
+ }, []);
+ const archiveFull = Array.isArray(archive) && archive.length >= ARCHIVE_SHOWN;
+ const counts2 = {};
+ for (const task of [...own, ...(archive ?? []).slice(0, ARCHIVE_SHOWN)]) {
+ for (const task of [...own, ...scope?.kind === "group" ? groupTasks : [], ...(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;
Expand Down
Loading
Loading