diff --git a/changelog.d/2273-board-readonly-hint.fixed.md b/changelog.d/2273-board-readonly-hint.fixed.md new file mode 100644 index 00000000..940b99b2 --- /dev/null +++ b/changelog.d/2273-board-readonly-hint.fixed.md @@ -0,0 +1 @@ +A read-only window's task board now says inside the board itself why Add task, Start, Complete and dragging are gone: one line above the columns carries the host's own read-only sentence, which the full-screen board had covered until now. A writable board draws nothing new. ([#2273](https://github.com/ThinkFlowLab/FlyBot/issues/2273)) diff --git a/core/client.js b/core/client.js index 73c42031..26ab9de3 100644 --- a/core/client.js +++ b/core/client.js @@ -9733,6 +9733,9 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro (0, import_react16.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) ) : null, + // #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint, + // so the lock's own sentence is drawn here too, from the host, not in a second wording. + locked ? (0, import_react16.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, (0, import_react16.createElement)( "div", { className: "bt-board-columns" }, diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 0fc5e2c6..2902a926 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -7,7 +7,7 @@ import { taskCard } from '../shared/board-card.js' import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' -import { t } from './i18n.js' +import { hostText, t } from './i18n.js' import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -366,6 +366,9 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, scope?.kind === 'bot' && onCreate && !locked ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, + // #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint, + // so the lock's own sentence is drawn here too, from the host, not in a second wording. + locked ? h('p', { className: 'bt-board-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : 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, archiveFull, locked, diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index b1d45bd3..8c0ca5da 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -842,6 +842,9 @@ test('P-455: a read-only window draws no write on the task board', async () => { assert.match(textOf(tree), /Working card/) assert.match(textOf(tree), /Done card/) assert.equal(byClass(tree, 'bt-board-when').length, 1) + // P-474: the 1080px board overlay covers the sidebar banner and the Settings hint, so the lock's + // own sentence is drawn inside the board — one line, the host's text, and still no write beside it. + assert.deepEqual(byClass(tree, 'bt-read-only').map(node => [node.props.role, textOf(node)]), [['status', lockedRoster.readOnly]]) // A drop on a column writes nothing either, even when a drag value is handed to the handler. board.column('in-progress').props.onDrop({ preventDefault() {}, dataTransfer: { getData: () => open.id } }) assert.deepEqual(writes, []) @@ -868,6 +871,8 @@ test('counter-proof: a writable board keeps all four writes', async () => { assert.equal(byClass(board.tree, 'bt-board-add').length, 1) const draggable = byClass(board.tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']).sort() assert.deepEqual(draggable, ['task-open123456', 'task-working456789']) + // P-474 counter-proof: a writable board draws no read-only line of any kind. + assert.deepEqual(byClass(board.tree, 'bt-read-only').map(textOf), []) const start = all(board.tree).find(node => node.type === 'button' && textOf(node) === 'Start') start.props.onClick() await board.settle() diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index c5c3cbe8..2c827e16 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -50,7 +50,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, 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`), 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. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. 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. A card in the Done column also says when it finished — a relative time beside the assignee (`bt-board-when`, `relativeShort`), on a Bot's board and a group's alike (#2066). The stamp is the **first** terminal entry in the card's `actors` (`accepted`, `cancelled` or `failed`), never `updatedAt`: `record()` bumps `updatedAt` on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to `updatedAt`, and a card with neither a finite `at` nor a finite `updatedAt` draws no node at all — `relativeShort(undefined)` would print `Invalid Date` and `0` would print 1970. The other columns keep the footer they had. #2248: a read-only window (`roster.readOnly` a non-empty string, read once in `KanbanDialog`) draws no board write the host would refuse — no **Add task** form, no **Start** / **Complete**, no `draggable` card, and a drop on a column runs no `taskUpdate` — while the readable board (rail, three columns, cards, archive, Done stamp) and a writable window are unchanged. 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. 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`), 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. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. 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. A card in the Done column also says when it finished — a relative time beside the assignee (`bt-board-when`, `relativeShort`), on a Bot's board and a group's alike (#2066). The stamp is the **first** terminal entry in the card's `actors` (`accepted`, `cancelled` or `failed`), never `updatedAt`: `record()` bumps `updatedAt` on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to `updatedAt`, and a card with neither a finite `at` nor a finite `updatedAt` draws no node at all — `relativeShort(undefined)` would print `Invalid Date` and `0` would print 1970. The other columns keep the footer they had. #2248: a read-only window (`roster.readOnly` a non-empty string, read once in `KanbanDialog`) draws no board write the host would refuse — no **Add task** form, no **Start** / **Complete**, no `draggable` card, and a drop on a column runs no `taskUpdate` — while the readable board (rail, three columns, cards, archive, Done stamp) and a writable window are unchanged. Since #2273 that board also says inside itself why the writes are gone: because the board is a full-screen overlay that covers the sidebar banner and the Settings hint, `BoardView` draws the host's own read-only sentence as one `role="status"` line above the columns (`bt-board-hint`), the same wording those two surfaces use and never a second one, and a writable window draws no such line. 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 b735e5db..5535ebcc 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5023,7 +5023,7 @@ diff --git a/client.js b/client.js @keyframes bt-mem-line{from{opacity:0;transform:translateY(4px)}} .bt-mem-skel{display:flex;flex-direction:column;gap:24px} .bt-mem-skel-block{display:flex;flex-direction:column;gap:10px} -@@ -2574,2935 +2574,7469 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2574,2935 +2574,7472 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @media (max-width:640px){.bt-mem{width:100vw;height:100%;border-radius:0;border:0}.bt-mem-body{padding:18px 18px 20px}.bt-mem-foot{padding:8px 12px 12px}} @media (prefers-reduced-motion:reduce){.bt-mem,.bt-mem-scrim,.bt-mem-summary>*,.bt-mem-empty,.bt-mem-reply,.bt-mem-skel-block span,.bt-mem-entries,.bt-mem-send>*,.bt-mem-changes li svg{animation:none}} @@ -11180,6 +11180,9 @@ diff --git a/client.js b/client.js + (0, import_react16.createElement)("input", { "aria-label": t("Task title"), placeholder: t("New task"), value: draft ?? "", onChange: (event) => onDraft?.(event.target.value) }), + (0, import_react16.createElement)("button", { type: "submit", className: "bt-soft bt-soft-sm" }, t("Add task")) + ) : null, ++ // #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint, ++ // so the lock's own sentence is drawn here too, from the host, not in a second wording. ++ locked ? (0, import_react16.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + (0, import_react16.createElement)( + "div", + { className: "bt-board-columns" }, diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 0fc5e2c6..2902a926 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -7,7 +7,7 @@ import { taskCard } from '../shared/board-card.js' import { BoardCard as BoardCardFrame, TASK_SKIN } from './board-card.js' import { CloseIcon } from './icons.js' import { useEscape } from './overlay-hooks.js' -import { t } from './i18n.js' +import { hostText, t } from './i18n.js' import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -366,6 +366,9 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, scope?.kind === 'bot' && onCreate && !locked ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, + // #2273: the board is a 1080px overlay and covers the sidebar banner and the Settings hint, + // so the lock's own sentence is drawn here too, from the host, not in a second wording. + locked ? h('p', { className: 'bt-board-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : 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, archiveFull, locked, diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index b1d45bd3..8c0ca5da 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -842,6 +842,9 @@ test('P-455: a read-only window draws no write on the task board', async () => { assert.match(textOf(tree), /Working card/) assert.match(textOf(tree), /Done card/) assert.equal(byClass(tree, 'bt-board-when').length, 1) + // P-474: the 1080px board overlay covers the sidebar banner and the Settings hint, so the lock's + // own sentence is drawn inside the board — one line, the host's text, and still no write beside it. + assert.deepEqual(byClass(tree, 'bt-read-only').map(node => [node.props.role, textOf(node)]), [['status', lockedRoster.readOnly]]) // A drop on a column writes nothing either, even when a drag value is handed to the handler. board.column('in-progress').props.onDrop({ preventDefault() {}, dataTransfer: { getData: () => open.id } }) assert.deepEqual(writes, []) @@ -868,6 +871,8 @@ test('counter-proof: a writable board keeps all four writes', async () => { assert.equal(byClass(board.tree, 'bt-board-add').length, 1) const draggable = byClass(board.tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']).sort() assert.deepEqual(draggable, ['task-open123456', 'task-working456789']) + // P-474 counter-proof: a writable board draws no read-only line of any kind. + assert.deepEqual(byClass(board.tree, 'bt-read-only').map(textOf), []) const start = all(board.tree).find(node => node.type === 'button' && textOf(node) === 'Start') start.props.onClick() await board.settle()