From 81b9a7ac1940eb5949bb7b50e4b462e9be3dc742 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:25:29 +0800 Subject: [PATCH] client: an overlay's read-only sentence keeps its Reload entry --- .../2521-overlay-readonly-reload.fixed.md | 1 + core/client.js | 18 +- core/src/client/kanban.js | 12 +- core/src/client/new-chat.js | 7 +- core/src/client/project-board.js | 7 +- core/test/readonly-reload-lines.test.js | 47 +- docs/subsystems/core-client.md | 8 +- patches/ds-bot/0003-flybot-api-client.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 629 +++++++++--------- patches/ds-bot/files/src/client/kanban.js | 12 +- .../ds-bot/files/src/client/project-board.js | 7 +- .../files/test/readonly-reload-lines.test.js | 47 +- 12 files changed, 451 insertions(+), 354 deletions(-) create mode 100644 changelog.d/2521-overlay-readonly-reload.fixed.md diff --git a/changelog.d/2521-overlay-readonly-reload.fixed.md b/changelog.d/2521-overlay-readonly-reload.fixed.md new file mode 100644 index 000000000..1e8beb2d2 --- /dev/null +++ b/changelog.d/2521-overlay-readonly-reload.fixed.md @@ -0,0 +1 @@ +In a read-only window the Board, the New chat pane and the Project settings panel now draw the host's sentence with a **Reload** beside it: each is a full-screen overlay that covers the sidebar's own entry, so the reader had to close it to answer the sentence at all. The entry runs the client's own team read (never a page reload), one per overlay, and a writable window is unchanged. diff --git a/core/client.js b/core/client.js index 28b97fc21..cabefd52d 100644 --- a/core/client.js +++ b/core/client.js @@ -9872,7 +9872,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a )) : plain.map((task) => (0, import_react17.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) ); } -function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { +function BoardView({ roster, actions, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks); const lanes = scope?.kind === "group"; const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -9914,8 +9914,9 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro (0, import_react17.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_react17.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + // so the lock's own sentence is drawn here too, from the host, not in a second wording — + // with the shared Reload beside it, since the sidebar's entry is covered by this overlay. + locked ? readOnlyLine(import_react17.createElement, roster, actions, { key: "readonly", className: "bt-board-hint" }) : null, (0, import_react17.createElement)( "div", { className: "bt-board-columns" }, @@ -10030,6 +10031,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { ), (0, import_react17.createElement)(BoardView, { roster, + actions, tasks: boardTasks, rail, scope, @@ -12129,7 +12131,7 @@ function ProjectBoardPane({ project, rows, done, canManage, tasks, decisions, me (0, import_react21.createElement)(ProjectBoardRecord, { record: view.record, projectId: view.projectId, call: call4 }) ); } -function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { +function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { const save = request ?? call; const [kind, setKind] = (0, import_react21.useState)("bot"); const [memberId, setMemberId] = (0, import_react21.useState)(""); @@ -12145,7 +12147,7 @@ function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, "div", { className: "bt-project-panel", "data-panel": "project-settings" }, (0, import_react21.createElement)("h3", null, t("Project settings")), - (0, import_react21.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(readOnly)), + readOnlyLine(import_react21.createElement, { readOnly }, actions, { key: "readonly" }), (0, import_react21.createElement)( "div", { className: "bt-project-row-actions" }, @@ -12432,7 +12434,7 @@ function ProjectBoardOverlay({ actions, call: request, useRoster } = {}) { close(); actions?.openSession?.(id); } - }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, { + }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, @@ -16293,7 +16295,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } else if (event.key === "Backspace" && query === "") unpick(); } }), - group && admin !== null ? locked ? (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { + group && admin !== null ? locked ? readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-admin" }) : (0, import_react34.createElement)("button", { type: "button", className: "bt-send bt-to-create", disabled: busy || picked.length === 0, @@ -16360,7 +16362,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), - locked ? group && admin !== null ? null : (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon)) + locked ? group && admin !== null ? null : readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-compose" }) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon)) ) ) ) diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 2902a926a..ba1c4edeb 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -7,7 +7,8 @@ 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 { hostText, t } from './i18n.js' +import { t } from './i18n.js' +import { readOnlyLine } from './read-only.js' import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -334,7 +335,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { +export function BoardView({ roster, actions, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -367,8 +368,9 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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, + // so the lock's own sentence is drawn here too, from the host, not in a second wording — + // with the shared Reload beside it, since the sidebar's entry is covered by this overlay. + locked ? readOnlyLine(h, roster, actions, { key: 'readonly', className: 'bt-board-hint' }) : 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, @@ -468,7 +470,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, archiveFull, locked, + roster, actions, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index ba9957785..3ee0ac28c 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -5,7 +5,8 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' import { CreateBot } from './create-bot.js' import { afterCreate } from './bot-draft.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' -import { hostText, t } from './i18n.js' +import { t } from './i18n.js' +import { readOnlyLine } from './read-only.js' import { lockedWindow } from './schedules.js' import { newChatActionRows } from './flybot-registry.js' import { SlotIcon } from './flybot-slots.js' @@ -251,7 +252,7 @@ export function NewChat({ mode, roster, actions }) { }, }), group && admin !== null ? (locked - ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) + ? readOnlyLine(h, roster, actions, { key: 'readonly-admin' }) : h('button', { type: 'button', className: 'bt-send bt-to-create', disabled: busy || picked.length === 0, title: picked.length === 0 ? t('Add at least one more Bot') : undefined, onClick: createGroup, @@ -299,6 +300,6 @@ export function NewChat({ mode, roster, actions }) { locked ? (group && admin !== null ? null - : h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))) + : readOnlyLine(h, roster, actions, { key: 'readonly-compose' })) : h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon)))))) } diff --git a/core/src/client/project-board.js b/core/src/client/project-board.js index 05ac3dae5..7e8eba0a0 100644 --- a/core/src/client/project-board.js +++ b/core/src/client/project-board.js @@ -4,6 +4,7 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 'react' import { call } from './api.js' import { dateLocale, hostText, t } from './i18n.js' +import { readOnlyLine } from './read-only.js' import { roleText } from './role-labels.js' import { lockedWindow } from './schedules.js' @@ -843,7 +844,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi // unbind a member (`project-bind`), the assistant's per-owner controls // (`project-assistant-owner`) and the assistant's memory (`project-memory-write`) — and adds no // fourth. Fields the host has no write path for are not drawn, so every control here can save. -export function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) { +export function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) { const save = request ?? call const [kind, setKind] = useState('bot') const [memberId, setMemberId] = useState('') @@ -867,7 +868,7 @@ export function ProjectSettingsPanel({ project, assistantMine, call: request, on if (locked) { return h('div', { className: 'bt-project-panel', 'data-panel': 'project-settings' }, h('h3', null, t('Project settings')), - h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(readOnly)), + readOnlyLine(h, { readOnly }, actions, { key: 'readonly' }), h('div', { className: 'bt-project-row-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) } @@ -1141,7 +1142,7 @@ export function ProjectBoardOverlay({ actions, call: request, useRoster } = {}) onClose: () => setPanel(null), onOpenBot: (id) => { close(); actions?.openSession?.(id) }, }) - : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) + : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : h(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks, diff --git a/core/test/readonly-reload-lines.test.js b/core/test/readonly-reload-lines.test.js index c5248a2ce..549fe20fb 100644 --- a/core/test/readonly-reload-lines.test.js +++ b/core/test/readonly-reload-lines.test.js @@ -18,6 +18,9 @@ const { outputFiles } = await build({ "export { DetailsDrawer } from './src/client/details-drawer.js'", "export { InboxDialog } from './src/client/inbox.js'", "export { MemoryDialog } from './src/client/memory-panel.js'", + "export { KanbanDialog } from './src/client/kanban.js'", + "export { NewChat } from './src/client/new-chat.js'", + "export { ProjectSettingsPanel } from './src/client/project-board.js'", ].join('\n'), resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, @@ -61,7 +64,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog } = module.exports +const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog, KanbanDialog, NewChat, ProjectSettingsPanel } = module.exports const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' @@ -140,6 +143,38 @@ const mountMemory = (roster, acts = actions()) => { index = 0 return draw(MemoryDialog({ bot: chief, roster, actions: acts })) } +// The three overlay surfaces the host sentence reaches without a way to answer it: the Board, the +// New chat overlay (which draws its sentence in the To: row once the admin is chosen and in the +// composer otherwise) and the Project settings panel. +const mountBoard = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(KanbanDialog({ roster, actions: acts })) +} +const mountNewChatComposer = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(NewChat({ mode: 'group', roster, actions: acts })) +} +// A locked group chat without an admin draws the sentence in the composer; with the admin chosen +// it draws it in the To: row instead. Seed the state that pick leaves behind (group on, admin +// chosen) so the second draw is the one under test. +const mountNewChatAdmin = (roster, acts = actions()) => { + slots = [] + index = 0 + slots[2] = true + slots[4] = 'chief' + return draw(NewChat({ mode: 'group', roster, actions: acts })) +} +const mountProjectSettings = (roster, acts = actions()) => { + slots = [] + index = 0 + const readOnly = typeof roster?.readOnly === 'string' ? roster.readOnly : '' + return draw(ProjectSettingsPanel({ + project: { id: 'p1', name: 'Ops' }, call: () => Promise.resolve({}), + locked: readOnly.trim() !== '', readOnly, actions: acts, onClose() {}, + })) +} const PAGES = ['general', 'bots', 'groups', 'projects', 'connectors', 'capabilities', 'schedules', 'workflows', 'usage', 'secrets', 'feedback', 'about'] @@ -152,6 +187,10 @@ const SURFACES = [ { name: 'drawer: group settings', mount: roster => mountDrawer(roster, 'settings', 'r1') }, { name: 'Inbox dialog', mount: roster => mountInbox(roster) }, { name: 'Memory panel', mount: roster => mountMemory(roster) }, + { name: 'Board dialog', mount: (roster, acts) => mountBoard(roster, acts) }, + { name: 'New chat: composer', mount: (roster, acts) => mountNewChatComposer(roster, acts) }, + { name: 'New chat: admin chosen', mount: (roster, acts) => mountNewChatAdmin(roster, acts) }, + { name: 'Project settings panel', mount: (roster, acts) => mountProjectSettings(roster, acts) }, ] test('the Settings header offers one Reload beside the host sentence on every page', () => { @@ -174,7 +213,7 @@ test('each drawer surface offers exactly one Reload, never two', () => { } }) -test('the Inbox and the Memory panel each offer one Reload — their own way out of the modal', () => { +test('every surface that is not the header or a drawer offers one Reload — its own way out of the modal', () => { for (const surface of SURFACES.filter(entry => !entry.name.startsWith('drawer') && entry.name !== 'Settings header')) { const nodes = surface.mount(locked) assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: the sentence stays once`) @@ -219,6 +258,10 @@ const mountFor = (surface, roster, acts) => { if (surface.name === 'Settings header') return mountSettings(roster, 'general', null, acts) if (surface.name === 'Inbox dialog') return mountInbox(roster, acts) if (surface.name === 'Memory panel') return mountMemory(roster, acts) + if (surface.name === 'Board dialog') return mountBoard(roster, acts) + if (surface.name === 'New chat: composer') return mountNewChatComposer(roster, acts) + if (surface.name === 'New chat: admin chosen') return mountNewChatAdmin(roster, acts) + if (surface.name === 'Project settings panel') return mountProjectSettings(roster, acts) return mountDrawer(roster, surface.name.includes('settings') ? 'settings' : 'details', surface.name.includes('group') ? 'r1' : 'chief', acts) } diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 38f13e5d7..e4151d118 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -15,7 +15,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl - The chat's `ask_user` card (`questions.js`) answers where it is drawn: one option answers a single-select card at once, a multi-select card toggles its rows and **Submit** sends the set, and the × dismisses it; the same card waits in the dock above the composer when its call is not on screen. In a window that holds the team read-only the card keeps the question, its rows and the host's sentence (`roster.readOnly`, read through `lockedWindow` from `schedules.js`) and draws none of those controls — no option button, **Submit**, **Skip**, × or own-answer field, and no form to submit. Each of them reached a write the host refuses (`answer-question`, `dismiss-question`), and the refusal was invisible: a pressed answer marked the card answered and the next poll said nothing, while the × hid the card until that poll brought it back. Tests: `core/test/questions-readonly.test.js`. - A shared room's overview (`details-drawer.js` `SharedRoomApprovals`) draws its **Waiting for your approval** card in a read-only window (`lockedWindow`, `schedules.js`) with no **Approve**/**Reject**: a decision is `social/decide-approval`, a write the host refuses there, so the two buttons could only earn `bot/read-only` after the click. The card keeps its headline, its count and each request's own text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`), the shape the drawer's own Settings view already used. Since #2409 each row also carries the wait's own age beside its text (` · 12 min`, `relativeShort()`), parsed from the plugin view's `createdAt` and left off when that field is absent, so two requests of different ages read apart. A decision the host still refuses — a writable window, or a room with several members answering `bot/blocked-shared` — writes that reason on the card in one `role="alert"` line (`bt-error`) instead of vanishing, which is what the component's `try`/`finally` with no `catch` did (#2381). Tests: `core/test/room-approvals-readonly.test.js`, `core/test/approval-age.test.js`. -- Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`. Since #2502 that sentence and entry are one shared piece: `client/read-only.js` exports `readOnlyLine(h, roster, actions, { key, className, style, reload })` and `readOnlySaid`, where the first argument is the renderer `h` itself, passed by the caller so the new module never imports react and the generated bundle keeps its bindings, and the Settings header, the drawer's three hint sites (`details-drawer.js` Bot overview, Bot settings view and group overview), the Inbox dialog and the Memory panel draw them instead of their own copies. The sentence stays `bt-hint bt-read-only` under `role="status"`, the button sits outside that node, and a press runs the same `actions.reloadTeam` read with `reportError(error, 'reload')` on failure; one view draws at most one entry, and `reload: false` keeps a sentence without the control so a second hint can never grow a second button. A writable window, and a sentence of only whitespace, draw neither. Tests: `core/test/readonly-reload-lines.test.js`. +- Read-only banner: when another dsh holds the team (`roster.readOnly` a non-empty string), the sidebar draws the host's own sentence and, beside it, one **Reload** control in both shapes (wide and rail; the control stays outside the `role="status"` line so a live region remains text). It calls `actions.reloadTeam`, which re-reads the whole team (`state` without `since`, see `stateRequest` / `stateAnswerIsStale` in `sources.js`) so the banner and every refused control clear once the other holder let go, without a page reload that would drop what is in the composer. Tests: `core/test/sidebar-readonly-reload.test.js`, `core/test/client-reload-state.test.js`. Since #2502 that sentence and entry are one shared piece: `client/read-only.js` exports `readOnlyLine(h, roster, actions, { key, className, style, reload })` and `readOnlySaid`, where the first argument is the renderer `h` itself, passed by the caller so the new module never imports react and the generated bundle keeps its bindings, and the Settings header, the drawer's three hint sites (`details-drawer.js` Bot overview, Bot settings view and group overview), the Inbox dialog and the Memory panel draw them instead of their own copies. The Board, both New chat sites (the To: row once the admin is chosen, the composer otherwise) and the Project settings panel draw the same piece since then: each is an overlay that covers the sidebar's own entry, so the sentence they repeat needs its own way to answer it. The sentence stays `bt-hint bt-read-only` under `role="status"`, the button sits outside that node, and a press runs the same `actions.reloadTeam` read with `reportError(error, 'reload')` on failure; one view draws at most one entry, and `reload: false` keeps a sentence without the control so a second hint can never grow a second button. A writable window, and a sentence of only whitespace, draw neither. Tests: `core/test/readonly-reload-lines.test.js`. - Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration. - A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270). - Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266). @@ -54,7 +54,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. 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/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. That line carries the shared Reload (`readOnlyLine`, `client/read-only.js`) since then, because this overlay covers the sidebar's own entry. Tests: `core/test/kanban.test.js`, `core/test/readonly-reload-lines.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`. | @@ -224,7 +224,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility - One name per destination in the account menu and command palette (#392, #393, #394): **Settings** opens the Settings dialog (General), since **Bot settings** / **Edit Bot** open one Bot; **Scheduled tasks** opens Settings → Scheduled tasks, the name Settings and a Bot's details use (DSH's own bundle stays "Automation tasks" in the Plugins hints); and **Switch to classic Agent mode** is the only way to classic Agent mode, so Settings → General is Appearance only. - Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected. - A read-only window draws no Create control in the new-Bot form (#2307): `CreateBot` (`create-bot.js`) reads `lockedWindow(roster)`, the one predicate the other surfaces use, and when it is locked the footer shows the host's own sentence (`.bt-hint.bt-read-only`, `hostText(roster.readOnly)`) where `Create` was, and `create()` returns before `create-bot`; the rest of the form stays as a preview. Both entries end in the same component — the sidebar **+** pane (`new-chat.js`) and Settings → Bots (`settings.js` inline) — so both are covered by the one gate. A writable window is unchanged. Test: `core/test/create-bot-readonly.test.js`. -- Group creation follows the same rule (#2496): `NewGroupForm` (`group-settings.js`) and the group mode of `NewChat` (`new-chat.js`) each read `lockedWindow(roster)`, keep their pickers as a preview, and put `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` where `Create` was; `create()` and `makeGroup()` return before `create-room`. The entries and the action layer are unchanged. Test: `core/test/group-create-readonly.test.js`. +- Group creation follows the same rule (#2496): `NewGroupForm` (`group-settings.js`) and the group mode of `NewChat` (`new-chat.js`) each read `lockedWindow(roster)`, keep their pickers as a preview, and draw the host's sentence where `Create` was — through the shared `readOnlyLine` (`client/read-only.js`), so it carries the Reload entry too: the New chat pane is the overlay that covers the sidebar's banner. `create()` and `makeGroup()` return before `create-room`. The entries and the action layer are unchanged. Test: `core/test/group-create-readonly.test.js`. - The composer's Send follows it (#2499): in a read-only window `sendMessage` returns before `send` and `create-room`, and `openBot` keeps the session open (a read) while dropping the unhide and the typed line; the composer draws no Send button at all: where it was, it shows `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` (`role="status"`); in a group chat that already picked its admin the sentence stands on the To: line, where Create was, and the composer draws nothing, so it appears once. A writable window sends exactly as before. The New… menu follows the same lock (#2506): the short action menu in `NewChat` reads the one `lockedWindow` predicate and, in a read-only window, draws no **Add coding agent** row at all — `addAgent` is a write the host refuses with `bot/read-only` — while the other rows stay drawn as a preview and a writable window keeps the row, where one press asks `add-agent` exactly once. Test: `core/test/composer-send-readonly.test.js`. - A missing model credential has its fix under the error (#405): `TurnErrorCell` (`model-menu.js`) shadows DSH's `turn-error` chat node (`locale: 'chat'`, so title and the AUTH / QUOTA / account copy are DSH's own strings). When the failure says the route has no API key (`missingModelKey`, e.g. `no API key for provider route "deepseek-official"`), the row says the Bot's model has no API key yet (the raw provider text is not drawn, not even on hover, since a provider may echo part of a key) and, for a `deepseek*` route, offers the same chips as a Bot with no model (`setupChoices`: Sign in to DeepSeek where the shell can, Enter a DeepSeek API key, Open settings to set up a custom model); any other or unnamed route gets Manage models. Under the DeepSeek chips (here and in the no-model dock) a **How to set a DeepSeek API key** link (`KeyHelpLink`, new tab) opens [docs/deepseek-api-key.md](../deepseek-api-key.md) on GitHub: DSH's own key dialog keeps only a typed or pasted key (#451), so the guide also gives the credentials file and `DEEPSEEK_API_KEY` ways. Every other failure reads as DSH writes it, with no buttons. - **Models and providers…**, **Manage models** and **Open settings to set up a custom model** open DSH's Settings on its Models page (#407), not General: the `settings.launcher` seat's `openSettings()` takes no section, so `openHarnessModels()` (`sidebar.js`) calls the seat's `openOnboarding('flybot-models')`. That `settings.onboarding` step (`ModelsSettingsStep`, order 1000, after DSH's own steps) is handed `openSection`: opened on purpose (`explicit`) it calls `openSection('models')`, then `complete()`; in first-run onboarding it only completes. With no seat it falls back to `openHarnessSettings()`. FlyBot's own **Harness settings** entry still opens DSH settings as a whole. @@ -356,7 +356,7 @@ so the two host sentences stay for the race where the host refuses an entry the In a window that holds the team read-only the panel draws no write at all (#2306): `ProjectBoardOverlay` reads the roster it is injected with through `lockedWindow` (`schedules.js`) and hands the lock down, so -the panel opens with the host's own sentence in one `role="status"` line and **Back to board** instead of +the panel opens with the host's own sentence in one `role="status"` line with the shared Reload beside it (`readOnlyLine`, `client/read-only.js`; the overlay hands the panel its `actions`) and **Back to board** instead of **Bind**/**Unbind**, the digest box and its **Save**, and the memory box and **Save memory** — the four controls behind `project-bind`, `project-assistant-owner` and `project-memory-write`, which the host refuses there. The board around it, the **Members** panel and the record keep reading, and a writable diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 53d6b1650..e8153ff2c 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2191,7 +2191,7 @@ diff --git a/src/client/index.js b/src/client/index.js diff --git a/src/client/new-chat.js b/src/client/new-chat.js --- a/src/client/new-chat.js +++ b/src/client/new-chat.js -@@ -1,21 +1,33 @@ +@@ -1,21 +1,34 @@ import { createElement as h, useRef, useState } from 'react' -import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' -import { PlusIcon, PeopleIcon, ArrowUpIcon } from './icons.js' @@ -2201,8 +2201,8 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js import { CreateBot } from './create-bot.js' +import { afterCreate } from './bot-draft.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' --import { t } from './i18n.js' -+import { hostText, t } from './i18n.js' + import { t } from './i18n.js' ++import { readOnlyLine } from './read-only.js' +import { lockedWindow } from './schedules.js' +import { newChatActionRows } from './flybot-registry.js' +import { SlotIcon } from './flybot-slots.js' @@ -2443,7 +2443,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js - title: picked.length === 0 ? t('Add at least one more Bot') : undefined, onClick: createGroup, - }, t('Create group chat')) : null, + group && admin !== null ? (locked -+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) ++ ? readOnlyLine(h, roster, actions, { key: 'readonly-admin' }) + : h('button', { + type: 'button', className: 'bt-send bt-to-create', disabled: busy || picked.length === 0, + title: picked.length === 0 ? t('Add at least one more Bot') : undefined, onClick: createGroup, @@ -2493,7 +2493,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + locked + ? (group && admin !== null + ? null -+ : h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))) ++ : readOnlyLine(h, roster, actions, { key: 'readonly-compose' })) + : h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon)))))) } diff --git a/src/client/overlay-hooks.js b/src/client/overlay-hooks.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 26e8baa68..c77eb80d3 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -6359,7 +6359,7 @@ diff --git a/client.js b/client.js @keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} @keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} @keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -@@ -2676,2833 +2676,6534 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2833 +2676,6536 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-bt-enter="new"]{animation:none} [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} } @@ -8845,6 +8845,9 @@ diff --git a/client.js b/client.js - (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), - (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) - ); +-} +-function ArrowUpIcon() { +- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); +// src/client/awaiting.js +var AWAIT_REPLY_MS = 10 * 60 * 1e3; +function awaitingReply(roster, sessionId, now = Date.now()) { @@ -8852,9 +8855,6 @@ diff --git a/client.js b/client.js + if (typeof at !== "number" || roster?.byId?.[sessionId]?.remote === void 0) return false; + return now - at < AWAIT_REPLY_MS; } --function ArrowUpIcon() { -- return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); --} -function MicIcon({ size = 16 }) { - return (0, import_react3.createElement)( - "svg", @@ -9011,7 +9011,6 @@ diff --git a/client.js b/client.js - } else if (index === count && ownRef.current) { - event.preventDefault(); - ownRef.current.focus(); -- } +function useReplyAfterSend(remote, sending) { + const sendTurn = (0, import_react12.useRef)(-1); + const wasSending = (0, import_react12.useRef)(false); @@ -9042,130 +9041,17 @@ diff --git a/client.js b/client.js + return () => { + cancelAnimationFrame(frame); + observer.disconnect(); - }; -- document.addEventListener("keydown", onKey, true); -- return () => document.removeEventListener("keydown", onKey, true); -- }, [count, ownRef]); ++ }; + }, [remote, sending]); + return replied; - } --function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { -- const [own, setOwn] = (0, import_react4.useState)(""); -- const [picked, setPicked] = (0, import_react4.useState)([]); -- const ownRef = (0, import_react4.useRef)(null); -- const titleId = (0, import_react4.useId)(); -- const options = optionsOf(question); -- const multi = question.multiSelect === true; -- const custom = own.trim(); -- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; -- const choose = (label) => { -- if (multi) { -- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); -- return; -- } -- onAnswer({ selected: [label] }); -- }; -- const submit = () => { -- if (!canSubmit) return; -- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); -- }; -- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); -- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; -- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { -- ref: ownRef, -- rows: 1, -- value: own, -- autoComplete: "off", -- spellCheck: false, -- className: "bt-q-input", -- "aria-label": t("Your own answer"), -- placeholder: t("Type your own answer"), -- "aria-keyshortcuts": ownLetter?.toLowerCase(), -- onChange: (event) => setOwn(event.target.value), -- onKeyDown: (event) => { -- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; -- event.preventDefault(); -- submit(); ++} +function useRunningHost(active, where = RUNNING_HOST) { + const [host, setHost] = (0, import_react12.useState)(null); + (0, import_react12.useEffect)(() => { + if (!active) { + setHost(null); + return void 0; - } -- }) : null; -- const rows = options.map((option, optionIndex) => { -- const selected = multi && picked.includes(option.label); -- return (0, import_react4.createElement)( -- "button", -- { -- key: option.label, -- type: "button", -- className: "bt-q-opt", -- "aria-pressed": multi ? selected : void 0, -- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), -- onClick: () => choose(option.label) -- }, -- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), -- (0, import_react4.createElement)( -- "span", -- { className: "bt-q-body" }, -- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), -- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null -- ), -- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null -- ); -- }); -- if (raised && field) { -- rows.push((0, import_react4.createElement)( -- "label", -- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, -- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) -- )); -- } -- let foot = null; -- if (raised && multi) { -- foot = (0, import_react4.createElement)( -- "div", -- { className: "bt-q-foot" }, -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), -- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) -- ); -- } else if ((raised || multi) && canSubmit) { -- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); -- } -- return (0, import_react4.createElement)( -- "form", -- { -- className: raised ? "bt-q bt-q-raised" : "bt-q", -- "aria-labelledby": titleId, -- onSubmit: (event) => { -- event.preventDefault(); -- submit(); -- } -- }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-text" }, -- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), -- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, -- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null -- ), -- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) -- ), -- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, -- field && !raised ? (0, import_react4.createElement)( -- "div", -- { className: "bt-q-custom" }, -- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), -- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null -- ) : null, -- foot -- ); ++ } + let frame = 0; + const find = () => { + frame = 0; @@ -9183,38 +9069,7 @@ diff --git a/client.js b/client.js + }; + }, [active, where]); + return host; - } --function SettledQuestion({ question, answer }) { -- const options = optionsOf(question); -- const prompt = question?.question ?? ""; -- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; -- if (chosen.length === 0) { -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, -- (0, import_react4.createElement)( -- "div", -- { className: "bt-q-head" }, -- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), -- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) -- ) -- ); -- } -- return (0, import_react4.createElement)( -- "div", -- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, -- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, -- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { -- const optionIndex = options.findIndex((option) => option.label === label); -- return (0, import_react4.createElement)( -- "div", -- { key: `${label}-${index}`, className: "bt-q-opt" }, -- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, -- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), -- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) -- ); -- })) -- ); ++} +function useRowEntrances(replacing) { + const check = (0, import_react12.useRef)(replacing); + check.current = replacing; @@ -9250,8 +9105,11 @@ diff --git a/client.js b/client.js + const reply = [void 0, "assistant-step", "tool-call"].includes(item.dataset.chatFlowKind); + item.dataset.btEnter = reply && check.current() ? "after-collapse" : "new"; + item.addEventListener("animationend", settle); -+ } -+ }; + } + }; +- document.addEventListener("keydown", onKey, true); +- return () => document.removeEventListener("keydown", onKey, true); +- }, [count, ownRef]); + const observer = new MutationObserver(() => { + if (!frame) frame = requestAnimationFrame(scan); + }); @@ -9262,18 +9120,18 @@ diff --git a/client.js b/client.js + }; + }, []); } --function QuestionCard({ matched }) { -- const pending = matched; -- const questions = pending.questions ?? []; -- const [index, setIndex] = (0, import_react4.useState)(0); -- const [answers, setAnswers] = (0, import_react4.useState)([]); -- const question = questions[index]; -- if (question === void 0) return null; -- const onAnswer = (answer) => { -- const next = [...answers, { id: question.id, ...answer }]; -- if (index + 1 < questions.length) { -- setAnswers(next); -- setIndex(index + 1); +-function QuestionView({ question, onAnswer, onDismiss, ownAnswer = false, raised = false }) { +- const [own, setOwn] = (0, import_react4.useState)(""); +- const [picked, setPicked] = (0, import_react4.useState)([]); +- const ownRef = (0, import_react4.useRef)(null); +- const titleId = (0, import_react4.useId)(); +- const options = optionsOf(question); +- const multi = question.multiSelect === true; +- const custom = own.trim(); +- const canSubmit = multi ? picked.length > 0 || custom !== "" : custom !== ""; +- const choose = (label) => { +- if (multi) { +- setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]); - return; +function ActivityIndicator({ sessionId, useRoster, useSessionStatus, useLive, useSession, actions }) { + const roster = useRoster((value) => value); @@ -9314,22 +9172,29 @@ diff --git a/client.js b/client.js + make(); + return void 0; } -- pending.answer({ answers: next }); -- }; -- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); --} --function PendingQuestion({ sessionId, question, actions, inline }) { -- const [answered, setAnswered] = (0, import_react4.useState)(null); -- if (answered === question.id) return null; -- const onAnswer = (answer) => { -- setAnswered(question.id); -- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- onAnswer({ selected: [label] }); - }; -- const onDismiss = () => { -- setAnswered(question.id); -- void actions.dismissQuestion(sessionId); +- const submit = () => { +- if (!canSubmit) return; +- onAnswer({ selected: multi ? picked : [], ...custom ? { custom } : {} }); - }; -- return (0, import_react4.createElement)( +- useOptionKeys(options.length, (index) => choose(options[index].label), ownRef); +- const ownLetter = options.length > 0 ? keyLetter(options.length) : void 0; +- const field = ownAnswer ? (0, import_react4.createElement)("textarea", { +- ref: ownRef, +- rows: 1, +- value: own, +- autoComplete: "off", +- spellCheck: false, +- className: "bt-q-input", +- "aria-label": t("Your own answer"), +- placeholder: t("Type your own answer"), +- "aria-keyshortcuts": ownLetter?.toLowerCase(), +- onChange: (event) => setOwn(event.target.value), +- onKeyDown: (event) => { +- if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return; +- event.preventDefault(); +- submit(); + const timer = setTimeout(make, wait); + return () => clearTimeout(timer); + }, [wantKey, shown]); @@ -9342,7 +9207,22 @@ diff --git a/client.js b/client.js + if (visible) { + setPhase("in"); + return void 0; -+ } + } +- }) : null; +- const rows = options.map((option, optionIndex) => { +- const selected = multi && picked.includes(option.label); +- return (0, import_react4.createElement)( +- "button", +- { +- key: option.label, +- type: "button", +- className: "bt-q-opt", +- "aria-pressed": multi ? selected : void 0, +- "aria-keyshortcuts": keyLetter(optionIndex).toLowerCase(), +- onClick: () => choose(option.label) +- }, +- (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)), +- (0, import_react4.createElement)( + let timer; + const leave = () => { + setPhase((current) => current === "in" ? "out" : current); @@ -9386,30 +9266,177 @@ diff --git a/client.js b/client.js + if (!host || phase === null || !shown) return null; + const elapsed = now - shown.at >= 6e4 ? elapsedLabel(now - shown.at) : null; + return (0, import_react_dom2.createPortal)((0, import_react12.createElement)( - "div", ++ "div", ++ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, ++ bot ? (0, import_react12.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), ++ (0, import_react12.createElement)( ++ "span", ++ { className: "bt-typing-text" }, ++ (0, import_react12.createElement)( + "span", +- { className: "bt-q-body" }, +- (0, import_react4.createElement)("span", { className: "bt-q-label" }, option.label), +- option.description ? (0, import_react4.createElement)("span", { className: "bt-q-desc" }, option.description) : null +- ), +- selected && !raised ? (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) : null +- ); +- }); +- if (raised && field) { +- rows.push((0, import_react4.createElement)( +- "label", +- { key: "own", className: "bt-q-opt bt-q-ownrow", "data-filled": custom !== "" ? "" : void 0 }, +- ownLetter ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, ownLetter) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, field) +- )); +- } +- let foot = null; +- if (raised && multi) { +- foot = (0, import_react4.createElement)( +- "div", +- { className: "bt-q-foot" }, +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-btn", onClick: onDismiss }, t("Skip")), +- (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit", disabled: !canSubmit }, t("Submit")) +- ); +- } else if ((raised || multi) && canSubmit) { +- foot = (0, import_react4.createElement)("div", { className: "bt-q-foot" }, (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit"))); +- } +- return (0, import_react4.createElement)( +- "form", +- { +- className: raised ? "bt-q bt-q-raised" : "bt-q", +- "aria-labelledby": titleId, +- onSubmit: (event) => { +- event.preventDefault(); +- submit(); +- } +- }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-text" }, +- (0, import_react4.createElement)("p", { className: "bt-q-title", id: titleId }, question.header ? `${question.header} · ` : "", question.question), +- question.detail ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, question.detail) : null, +- raised && multi ? (0, import_react4.createElement)("div", { className: "bt-q-detail" }, t("Choose all that apply")) : null +- ), +- (0, import_react4.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Dismiss question"), title: t("Dismiss"), onClick: onDismiss }, (0, import_react4.createElement)(CloseIcon)) +- ), +- rows.length > 0 ? (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group" }, rows) : null, +- field && !raised ? (0, import_react4.createElement)( +- "div", +- { className: "bt-q-custom" }, +- (0, import_react4.createElement)("label", { className: "bt-q-field" }, field), +- !multi && canSubmit ? (0, import_react4.createElement)("button", { type: "submit", className: "bt-q-submit" }, t("Submit")) : null +- ) : null, +- foot +- ); +-} +-function SettledQuestion({ question, answer }) { +- const options = optionsOf(question); +- const prompt = question?.question ?? ""; +- const chosen = [...answer.selected ?? [], ...answer.custom ? [answer.custom] : []]; +- if (chosen.length === 0) { +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled bt-q-dismissed", role: "group", "aria-label": prompt }, +- (0, import_react4.createElement)( +- "div", +- { className: "bt-q-head" }, +- (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt)), +- (0, import_react4.createElement)("span", { className: "bt-q-badge" }, t("Dismissed")) ++ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, ++ (0, import_react12.createElement)(Shimmer, null, `${shown.text}…`), ++ elapsed ? (0, import_react12.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null + ) +- ); +- } +- return (0, import_react4.createElement)( +- "div", +- { className: "bt-q bt-q-settled", role: "group", "aria-label": prompt }, +- prompt ? (0, import_react4.createElement)("div", { className: "bt-q-head" }, (0, import_react4.createElement)("div", { className: "bt-q-text" }, (0, import_react4.createElement)("p", { className: "bt-q-title" }, prompt))) : null, +- (0, import_react4.createElement)("div", { className: "bt-q-list", role: "group", "aria-label": t("Your answer") }, chosen.map((label, index) => { +- const optionIndex = options.findIndex((option) => option.label === label); +- return (0, import_react4.createElement)( +- "div", +- { key: `${label}-${index}`, className: "bt-q-opt" }, +- optionIndex >= 0 ? (0, import_react4.createElement)("span", { className: "bt-key", "aria-hidden": true }, keyLetter(optionIndex)) : null, +- (0, import_react4.createElement)("span", { className: "bt-q-body" }, (0, import_react4.createElement)("span", { className: "bt-q-label" }, label)), +- (0, import_react4.createElement)("span", { className: "bt-q-check", title: t("Selected") }, (0, import_react4.createElement)(CheckIcon)) +- ); +- })) +- ); +-} +-function QuestionCard({ matched }) { +- const pending = matched; +- const questions = pending.questions ?? []; +- const [index, setIndex] = (0, import_react4.useState)(0); +- const [answers, setAnswers] = (0, import_react4.useState)([]); +- const question = questions[index]; +- if (question === void 0) return null; +- const onAnswer = (answer) => { +- const next = [...answers, { id: question.id, ...answer }]; +- if (index + 1 < questions.length) { +- setAnswers(next); +- setIndex(index + 1); +- return; +- } +- pending.answer({ answers: next }); +- }; +- return (0, import_react4.createElement)(QuestionView, { key: index, question, onAnswer, onDismiss: () => pending.dismiss(), ownAnswer: true, raised: true }); ++ ) ++ ), host); + } +-function PendingQuestion({ sessionId, question, actions, inline }) { +- const [answered, setAnswered] = (0, import_react4.useState)(null); +- if (answered === question.id) return null; +- const onAnswer = (answer) => { +- setAnswered(question.id); +- void actions.answerQuestion(sessionId, question.id, { selected: answer.selected ?? [], custom: answer.custom ?? "" }).catch(() => setAnswered(null)); +- }; +- const onDismiss = () => { +- setAnswered(question.id); +- void actions.dismissQuestion(sessionId); +- }; +- return (0, import_react4.createElement)( +- "div", - { className: inline ? "bt-q-inline" : "bt-q-dock" }, - (0, import_react4.createElement)(QuestionView, { key: question.id, question, onAnswer, onDismiss, ownAnswer: question.allowCustom === true, raised: !inline }) - ); --} ++ ++// src/client/dsh-offer.js ++var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); ++var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; ++var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); ++function showsDshRow(dsh, app) { ++ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; ++ if (dsh.kind === "npm") return true; ++ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); + } -function QuestionDock({ sessionId, useRoster, useCards, useSession, actions }) { - const question = useRoster((value) => value.questions?.[sessionId]); - const drawn = useCards?.((state) => state[sessionId]?.[question?.callId] > 0) === true; - const loading = useSession?.(historyLoading) === true; - if (question === void 0 || question.callId && (drawn || loading)) return null; - return (0, import_react4.createElement)(PendingQuestion, { sessionId, question, actions, inline: false }); -+ { className: "bt-typing", "data-exiting": phase === "out" ? "" : void 0, "data-carry": carry && phase !== "out" ? "" : void 0, "data-remote": remote ? "" : void 0, "data-flybot": shown.key === "remote-approval" ? "remote-approval" : void 0, "aria-hidden": true }, -+ bot ? (0, import_react12.createElement)(BotMark, { bot, size: 28, state: markState.current, live: true, pokeable: true }) : (0, import_react12.createElement)(RoomAvatar, { room: room2, roster, size: 28 }), -+ (0, import_react12.createElement)( -+ "span", -+ { className: "bt-typing-text" }, -+ (0, import_react12.createElement)( -+ "span", -+ { key: `${shown.key}:${shown.at}`, className: "bt-typing-label" }, -+ (0, import_react12.createElement)(Shimmer, null, `${shown.text}…`), -+ elapsed ? (0, import_react12.createElement)("span", { className: "bt-typing-time" }, `· ${elapsed}`) : null -+ ) -+ ) -+ ), host); ++ ++// src/client/overlay-hooks.js ++var import_react13 = require("react"); ++var escapeLayers = []; ++var onEscapeKey = (event) => { ++ if (event.key === "Escape") escapeLayers.at(-1)?.current(); ++}; ++function useEscape(onClose) { ++ const ref = (0, import_react13.useRef)(onClose); ++ ref.current = onClose; ++ (0, import_react13.useEffect)(() => { ++ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); ++ escapeLayers.push(ref); ++ return () => { ++ escapeLayers.splice(escapeLayers.indexOf(ref), 1); ++ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); ++ }; ++ }, []); } -function AnsweredCell({ node }) { - const data = node.data ?? {}; @@ -9423,15 +9450,25 @@ diff --git a/client.js b/client.js - question, - answer: answers.find((answer) => answer.id === question.id) ?? { selected: [] } - }))); -+ -+// src/client/dsh-offer.js -+var APP_OFFER = /* @__PURE__ */ new Set(["available", "downloading", "verifying", "installing", "ready"]); -+var bare = (value) => String(value ?? "").trim().replace(/^v/i, "").split("+")[0]; -+var sameVersion = (a, b) => bare(a) !== "" && bare(a) === bare(b); -+function showsDshRow(dsh, app) { -+ if (!dsh?.verified || !dsh.current || sameVersion(dsh.verified, dsh.current)) return false; -+ if (dsh.kind === "npm") return true; -+ return dsh.kind === "app" && APP_OFFER.has(app?.phase) && sameVersion(app.version, dsh.verified); ++function useDialogFocus(ref) { ++ (0, import_react13.useEffect)(() => { ++ const before = document.activeElement; ++ ref.current?.focus?.({ preventScroll: true }); ++ return () => { ++ const active = document.activeElement; ++ const stillInDialog = active === ref.current || ref.current?.contains?.(active) === true; ++ const noFocusHeld = !active || active === document.body || active.isConnected === false; ++ if (before && typeof before.focus === "function" && before.isConnected && (stillInDialog || noFocusHeld || active === before)) before.focus({ preventScroll: true }); ++ }; ++ }, []); ++} ++function usePaneLeft() { ++ const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280); ++ (0, import_react13.useLayoutEffect)(() => { ++ const column = document.querySelector('[class*="_footArea"]')?.parentElement; ++ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); ++ }, []); ++ return paneLeft; } -// src/client/secret-card.js @@ -9462,11 +9499,13 @@ diff --git a/client.js b/client.js - if (scope === "all") return t("All Bots"); - const names = (scope ?? []).map((id) => roster.byId[id]?.name).filter(Boolean); - return names.length === 0 ? t("No Bot") : names.join(t(" and ")); -+// src/client/overlay-hooks.js -+var import_react13 = require("react"); -+var escapeLayers = []; -+var onEscapeKey = (event) => { -+ if (event.key === "Escape") escapeLayers.at(-1)?.current(); ++// src/client/member-status.js ++var import_react14 = require("react"); ++var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; ++var ROWS_MAX = 4; ++var stepIn = (work, botId, roomId, tool) => { ++ const own = work.bots?.[botId]; ++ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; }; -var when = (time) => Number.isFinite(time) ? new Date(time).toLocaleString(dateLocale(), { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : ""; -function MaskedInput({ value, onChange, onEnter, label, autoFocus = false }) { @@ -9492,18 +9531,19 @@ diff --git a/client.js b/client.js - event.preventDefault(); - onEnter?.(); - } -- }); -+function useEscape(onClose) { -+ const ref = (0, import_react13.useRef)(onClose); -+ ref.current = onClose; -+ (0, import_react13.useEffect)(() => { -+ if (escapeLayers.length === 0) window.addEventListener("keydown", onEscapeKey); -+ escapeLayers.push(ref); -+ return () => { -+ escapeLayers.splice(escapeLayers.indexOf(ref), 1); -+ if (escapeLayers.length === 0) window.removeEventListener("keydown", onEscapeKey); -+ }; -+ }, []); ++var workIn = (room2, work, id) => { ++ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); ++ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; ++ const own = work.bots?.[id]; ++ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; ++}; ++var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); ++function memberWork(room2, work = NO_WORK) { ++ return room2.members.map((id) => { ++ const doing = workIn(room2, work, id); ++ const approvals = askedIn(room2, work, id, doing); ++ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; + }); } -function ScopeSwitch({ value, onChange, bot }) { - const options = [["all", t("All Bots")], ["bot", t("Only {name}", { name: bot.name })]]; @@ -9520,18 +9560,7 @@ diff --git a/client.js b/client.js - onClick: () => onChange(id) - }, text)) - ); -+function useDialogFocus(ref) { -+ (0, import_react13.useEffect)(() => { -+ const before = document.activeElement; -+ ref.current?.focus?.({ preventScroll: true }); -+ return () => { -+ const active = document.activeElement; -+ const stillInDialog = active === ref.current || ref.current?.contains?.(active) === true; -+ const noFocusHeld = !active || active === document.body || active.isConnected === false; -+ if (before && typeof before.focus === "function" && before.isConnected && (stillInDialog || noFocusHeld || active === before)) before.focus({ preventScroll: true }); -+ }; -+ }, []); - } +-} -function SecretHead({ bot, title, detail, name, onClose }) { - return (0, import_react5.createElement)( - "div", @@ -9545,14 +9574,7 @@ diff --git a/client.js b/client.js - ), - onClose ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-x", "aria-label": t("Cancel the request"), title: t("Cancel"), onClick: onClose }, (0, import_react5.createElement)(CloseIcon)) : null - ); -+function usePaneLeft() { -+ const [paneLeft, setPaneLeft] = (0, import_react13.useState)(280); -+ (0, import_react13.useLayoutEffect)(() => { -+ const column = document.querySelector('[class*="_footArea"]')?.parentElement; -+ if (column) setPaneLeft(Math.round(column.getBoundingClientRect().right)); -+ }, []); -+ return paneLeft; - } +-} -function SecretCard({ bot, request, roster, actions }) { - const [value, setValue] = (0, import_react5.useState)(""); - const [scope, setScope] = (0, import_react5.useState)("all"); @@ -9611,28 +9633,9 @@ diff --git a/client.js b/client.js - filling ? (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: !long || busy, onClick: save }, busy ? t("Saving…") : t("Save")) : (0, import_react5.createElement)("button", { type: "button", className: "bt-q-submit", disabled: busy, onClick: allow }, busy ? t("Allowing…") : t("Allow")) - ) - ); -+ -+// src/client/member-status.js -+var import_react14 = require("react"); -+var NO_WORK = { bots: {}, rooms: {}, approvals: [] }; -+var ROWS_MAX = 4; -+var stepIn = (work, botId, roomId, tool) => { -+ const own = work.bots?.[botId]; -+ return own?.roomId === roomId && own.tool === tool ? own.step : void 0; -+}; -+var workIn = (room2, work, id) => { -+ const local = (work.rooms?.[room2.id] ?? []).find((entry) => entry.botId === id); -+ if (local) return { ...local, roomId: room2.id, step: stepIn(work, id, room2.id, local.tool) }; -+ const own = work.bots?.[id]; -+ return own !== void 0 && own.roomId === void 0 && own.fromRoomId === room2.id ? { ...own, roomId: room2.id, handed: true } : void 0; -+}; -+var askedIn = (room2, work, id, doing) => (work.approvals ?? []).filter((entry) => entry.botId === id && (entry.roomId === room2.id || entry.roomId === void 0 && doing?.handed === true)); -+function memberWork(room2, work = NO_WORK) { -+ return room2.members.map((id) => { -+ const doing = workIn(room2, work, id); -+ const approvals = askedIn(room2, work, id, doing); -+ return { id, approvals, doing, here: doing !== void 0, state: approvals.length > 0 ? "ask" : doing ? "work" : "idle" }; -+ }); ++function busyMembers(room2, work, roster) { ++ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); ++ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); } -function SettledSecret({ bot, request, roster }) { - const canceled = request.status === "canceled"; @@ -9653,17 +9656,20 @@ diff --git a/client.js b/client.js - canceled ? (0, import_react5.createElement)("span", { className: "bt-q-badge" }, t("Canceled")) : (0, import_react5.createElement)("span", { className: "bt-q-check bt-secret-check", title: t("Done") }, (0, import_react5.createElement)(CheckIcon)) - ) - ); -+function busyMembers(room2, work, roster) { -+ const busy = memberWork(room2, work).filter((entry) => entry.state !== "idle" && roster.byId[entry.id]); -+ return [...busy.filter((entry) => entry.state === "ask"), ...busy.filter((entry) => entry.state === "work")].map((entry) => ({ ...entry, bot: roster.byId[entry.id] })); - } --function RequestView({ bot, request, roster, actions }) { -- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); +function splitBusy(busy) { + const here = busy.filter((entry) => entry.state === "work" && entry.here); + const rows = [...here.filter((entry) => entry.doing.speaking === true), ...here.filter((entry) => entry.doing.speaking !== true)].slice(0, ROWS_MAX); + return { rows, rest: busy.filter((entry) => !rows.includes(entry)) }; } +-function RequestView({ bot, request, roster, actions }) { +- return request.status === "pending" ? (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }) : (0, import_react5.createElement)(SettledSecret, { bot, request, roster }); ++function botGroupWork(botId, work, roster) { ++ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { ++ const entry = entries2.find((item) => item.botId === botId); ++ const room2 = roster.roomsById[roomId]; ++ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; ++ }); + } -function SecretToolCell({ sessionId, callId, name, done, content, roster, actions }) { - const bot = roster.byId[sessionId]; - if (!bot) return null; @@ -9673,12 +9679,15 @@ diff --git a/client.js b/client.js - return (0, import_react5.createElement)("div", { className: "bt-event" }, (0, import_react5.createElement)("span", { className: "bt-secret-event" }, (0, import_react5.createElement)(KeyIcon), t("{bot} is using {name}", { bot: bot.name, name: name ?? t("a saved key") }))); - } - return null; -+function botGroupWork(botId, work, roster) { -+ return Object.entries(work.rooms ?? {}).flatMap(([roomId, entries2]) => { -+ const entry = entries2.find((item) => item.botId === botId); -+ const room2 = roster.roomsById[roomId]; -+ return entry && room2 ? [{ room: room2, tool: entry.tool, step: stepIn(work, botId, roomId, entry.tool), since: entry.since }] : []; -+ }); ++var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); ++function summaryText(busy) { ++ const asks = busy.filter((entry) => entry.state === "ask"); ++ const workers = busy.filter((entry) => entry.state === "work"); ++ const names = asks.slice(0, 2).map((entry) => entry.bot.name); ++ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); ++ const first = workers[0]; ++ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; ++ return { waiting, doing }; } -function SecretDock({ sessionId, useRoster, useCards, useSession, actions }) { - const roster = useRoster((value) => value); @@ -9690,15 +9699,12 @@ diff --git a/client.js b/client.js - const waiting = (roster.secretRequests?.[bot.id] ?? []).filter((entry) => entry.status === "pending" && !drawn(entry)); - if (waiting.length === 0) return null; - return (0, import_react5.createElement)("div", { className: "bt-q-dock bt-secret-dock" }, waiting.map((request) => (0, import_react5.createElement)(SecretCard, { key: `${request.id}:${request.kind}`, bot, request, roster, actions }))); -+var moreRooms = (count) => count === 1 ? t("1 more room") : t("{count} more rooms", { count }); -+function summaryText(busy) { -+ const asks = busy.filter((entry) => entry.state === "ask"); -+ const workers = busy.filter((entry) => entry.state === "work"); -+ const names = asks.slice(0, 2).map((entry) => entry.bot.name); -+ const waiting = asks.length === 0 ? "" : asks.length === 1 ? t("{name} is waiting for your approval", { name: names[0] }) : asks.length === 2 ? t("{first} and {second} are waiting for your approval", { first: names[0], second: names[1] }) : t("{first}, {second} and {count} more are waiting for your approval", { first: names[0], second: names[1], count: asks.length - 2, total: asks.length }); -+ const first = workers[0]; -+ const doing = first ? `${first.bot.name} · ${workText(first.doing.tool, first.doing.step)}${workers.length > 1 ? ` +${workers.length - 1}` : ""}` : ""; -+ return { waiting, doing }; ++function Face({ entry, size }) { ++ return (0, import_react14.createElement)( ++ "span", ++ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, ++ (0, import_react14.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ++ ); } -function ScopeEditor({ secret, roster, onSave }) { - const [mode, setMode] = (0, import_react5.useState)(secret.scope === "all" ? "all" : "some"); @@ -9731,11 +9737,16 @@ diff --git a/client.js b/client.js - { className: "bt-secret-row-actions" }, - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: same, onClick: () => onSave(mode === "all" ? "all" : picked) }, t("Save access")) - ) -+function Face({ entry, size }) { ++var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; ++function WorkRow({ bot, tool, step, label, at, now, onOpen }) { ++ const text2 = `${label} · ${workText(tool, step)}`; ++ const elapsed = elapsedSince(now, at); + return (0, import_react14.createElement)( -+ "span", -+ { className: "bt-mw-face", "data-ask": entry.state === "ask" || void 0 }, -+ (0, import_react14.createElement)(BotMark, { bot: entry.bot, size, state: entry.state === "ask" ? "alert" : toolState(entry.doing?.tool ?? "") }) ++ "button", ++ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, ++ (0, import_react14.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), ++ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react14.createElement)(Shimmer, null, text2)), ++ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null ); } -function ValueEditor({ secret, onSave }) { @@ -9764,18 +9775,6 @@ diff --git a/client.js b/client.js - { className: "bt-secret-inline" }, - (0, import_react5.createElement)("label", { className: "bt-q-field bt-secret-field" }, (0, import_react5.createElement)(MaskedInput, { value, onChange: edit, onEnter: save, label: t("New value of {name}", { name: secret.name }) })), - (0, import_react5.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: !long, onClick: save }, t("Replace")) -+var elapsedSince = (now, at) => typeof at === "number" && now - at >= 6e4 ? elapsedLabel(now - at) : ""; -+function WorkRow({ bot, tool, step, label, at, now, onOpen }) { -+ const text2 = `${label} · ${workText(tool, step)}`; -+ const elapsed = elapsedSince(now, at); -+ return (0, import_react14.createElement)( -+ "button", -+ { type: "button", className: "bt-mw-row", "aria-label": [text2, elapsed].filter(Boolean).join(" · "), onClick: onOpen }, -+ (0, import_react14.createElement)(BotMark, { bot, size: 22, state: toolState(tool ?? ""), live: true }), -+ (0, import_react14.createElement)("span", { className: "bt-mw-row-text" }, (0, import_react14.createElement)(Shimmer, null, text2)), -+ elapsed ? (0, import_react14.createElement)("span", { className: "bt-mw-row-time" }, `· ${elapsed}`) : null -+ ); -+} +function SummaryLine({ busy, now, onOpen }) { + const { waiting, doing } = summaryText(busy); + const elapsed = doing ? elapsedSince(now, busy.find((entry) => entry.state === "work")?.doing.since) : ""; @@ -11493,6 +11492,10 @@ diff --git a/client.js b/client.js - onOpen: (bot) => actions.openExchange(selfId, bot.id), - whole: true - }); +- } +- if (source.kind === "schedule") { +- const prompts = reminderPrompts(contentText(data.content)); +- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); +function dropLabel(column) { + if (column === "in-progress") return t("Start task"); + if (column === "done") return t("Complete task"); @@ -11515,10 +11518,6 @@ diff --git a/client.js b/client.js + map.set(id, []); + order.push(id); } -- if (source.kind === "schedule") { -- const prompts = reminderPrompts(contentText(data.content)); -- return (0, import_react6.createElement)("div", { className: "bt-event" }, prompts.length > 0 ? t("Scheduled task: {text}", { text: prompts.join(" · ").slice(0, 80) }) : t("Routine started")); -- } - if (source.kind === "user-question-reply") return null; - return (0, import_react6.createElement)("div", { className: "bt-event" }, contentText(data.content).slice(0, 80) || t("Update")); + map.get(id).push(task); @@ -11737,7 +11736,7 @@ diff --git a/client.js b/client.js - const listed = firstTurns.split(",").map(Number); - const earlier = listed.filter((entry) => entry < turn); - return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { ++function BoardView({ roster, actions, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -11779,8 +11778,9 @@ diff --git a/client.js b/client.js + (0, import_react17.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_react17.createElement)("p", { className: "bt-board-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, ++ // so the lock's own sentence is drawn here too, from the host, not in a second wording — ++ // with the shared Reload beside it, since the sidebar's entry is covered by this overlay. ++ locked ? readOnlyLine(import_react17.createElement, roster, actions, { key: "readonly", className: "bt-board-hint" }) : null, + (0, import_react17.createElement)( + "div", + { className: "bt-board-columns" }, @@ -11975,6 +11975,7 @@ diff --git a/client.js b/client.js + ), + (0, import_react17.createElement)(BoardView, { + roster, ++ actions, + tasks: boardTasks, + rail, + scope, @@ -14965,7 +14966,7 @@ diff --git a/client.js b/client.js - shiftKey: held.has("shift"), - metaKey: held.has("meta") - })); -+function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { ++function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = "" } = {}) { + const save = request ?? call; + const [kind, setKind] = (0, import_react21.useState)("bot"); + const [memberId, setMemberId] = (0, import_react21.useState)(""); @@ -14981,7 +14982,7 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-project-panel", "data-panel": "project-settings" }, + (0, import_react21.createElement)("h3", null, t("Project settings")), -+ (0, import_react21.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(readOnly)), ++ readOnlyLine(import_react21.createElement, { readOnly }, actions, { key: "readonly" }), + (0, import_react21.createElement)( + "div", + { className: "bt-project-row-actions" }, @@ -15387,7 +15388,7 @@ diff --git a/client.js b/client.js + close(); + actions?.openSession?.(id); + } -+ }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, { ++ }) : panel === "settings" ? (0, import_react21.createElement)(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === "members" ? (0, import_react21.createElement)(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : (0, import_react21.createElement)(ProjectBoardPane, { + project: state.project, + rows: state.rows, + done: state.done, @@ -20737,7 +20738,7 @@ diff --git a/client.js b/client.js } }), - group && admin !== null ? (0, import_react20.createElement)("button", { -+ group && admin !== null ? locked ? (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { ++ group && admin !== null ? locked ? readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-admin" }) : (0, import_react34.createElement)("button", { type: "button", className: "bt-send bt-to-create", disabled: busy || picked.length === 0, @@ -20809,7 +20810,7 @@ diff --git a/client.js b/client.js } }), - (0, import_react20.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react20.createElement)(ArrowUpIcon)) -+ locked ? group && admin !== null ? null : (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon)) ++ locked ? group && admin !== null ? null : readOnlyLine(import_react34.createElement, roster, actions, { key: "readonly-compose" }) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon)) ) ) ) diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 2902a926a..ba1c4edeb 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -7,7 +7,8 @@ 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 { hostText, t } from './i18n.js' +import { t } from './i18n.js' +import { readOnlyLine } from './read-only.js' import { relativeShort } from './text.js' const POLL_MS = 15_000 @@ -334,7 +335,7 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { +export function BoardView({ roster, actions, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -367,8 +368,9 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, 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, + // so the lock's own sentence is drawn here too, from the host, not in a second wording — + // with the shared Reload beside it, since the sidebar's entry is covered by this overlay. + locked ? readOnlyLine(h, roster, actions, { key: 'readonly', className: 'bt-board-hint' }) : 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, @@ -468,7 +470,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, archiveFull, locked, + roster, actions, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/patches/ds-bot/files/src/client/project-board.js b/patches/ds-bot/files/src/client/project-board.js index 05ac3dae5..7e8eba0a0 100644 --- a/patches/ds-bot/files/src/client/project-board.js +++ b/patches/ds-bot/files/src/client/project-board.js @@ -4,6 +4,7 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 'react' import { call } from './api.js' import { dateLocale, hostText, t } from './i18n.js' +import { readOnlyLine } from './read-only.js' import { roleText } from './role-labels.js' import { lockedWindow } from './schedules.js' @@ -843,7 +844,7 @@ export function ProjectBoardPane({ project, rows, done, canManage, tasks, decisi // unbind a member (`project-bind`), the assistant's per-owner controls // (`project-assistant-owner`) and the assistant's memory (`project-memory-write`) — and adds no // fourth. Fields the host has no write path for are not drawn, so every control here can save. -export function ProjectSettingsPanel({ project, assistantMine, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) { +export function ProjectSettingsPanel({ project, assistantMine, actions, call: request, onClose, onSaved, locked = false, readOnly = '' } = {}) { const save = request ?? call const [kind, setKind] = useState('bot') const [memberId, setMemberId] = useState('') @@ -867,7 +868,7 @@ export function ProjectSettingsPanel({ project, assistantMine, call: request, on if (locked) { return h('div', { className: 'bt-project-panel', 'data-panel': 'project-settings' }, h('h3', null, t('Project settings')), - h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(readOnly)), + readOnlyLine(h, { readOnly }, actions, { key: 'readonly' }), h('div', { className: 'bt-project-row-actions' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => onClose?.() }, t('Back to board')))) } @@ -1141,7 +1142,7 @@ export function ProjectBoardOverlay({ actions, call: request, useRoster } = {}) onClose: () => setPanel(null), onOpenBot: (id) => { close(); actions?.openSession?.(id) }, }) - : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) + : panel === 'settings' ? h(ProjectSettingsPanel, { project: state.project, assistantMine: state.assistantMine, actions, call: read, locked, readOnly: roster?.readOnly, onClose: () => setPanel(null) }) : panel === 'members' ? h(ProjectMembersPanel, { project: state.project, rows: state.rows, onClose: () => setPanel(null) }) : h(ProjectBoardPane, { project: state.project, rows: state.rows, done: state.done, canManage: state.canManage, tasks: state.tasks, diff --git a/patches/ds-bot/files/test/readonly-reload-lines.test.js b/patches/ds-bot/files/test/readonly-reload-lines.test.js index c5248a2ce..549fe20fb 100644 --- a/patches/ds-bot/files/test/readonly-reload-lines.test.js +++ b/patches/ds-bot/files/test/readonly-reload-lines.test.js @@ -18,6 +18,9 @@ const { outputFiles } = await build({ "export { DetailsDrawer } from './src/client/details-drawer.js'", "export { InboxDialog } from './src/client/inbox.js'", "export { MemoryDialog } from './src/client/memory-panel.js'", + "export { KanbanDialog } from './src/client/kanban.js'", + "export { NewChat } from './src/client/new-chat.js'", + "export { ProjectSettingsPanel } from './src/client/project-board.js'", ].join('\n'), resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, @@ -61,7 +64,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog } = module.exports +const { SettingsDialog, DetailsDrawer, InboxDialog, MemoryDialog, KanbanDialog, NewChat, ProjectSettingsPanel } = module.exports const SENTENCE = 'The Bot team in /home/xuke/.flybot is open in another dsh (Web or Desktop, pid 41 on host). Close the other one and reload.' @@ -140,6 +143,38 @@ const mountMemory = (roster, acts = actions()) => { index = 0 return draw(MemoryDialog({ bot: chief, roster, actions: acts })) } +// The three overlay surfaces the host sentence reaches without a way to answer it: the Board, the +// New chat overlay (which draws its sentence in the To: row once the admin is chosen and in the +// composer otherwise) and the Project settings panel. +const mountBoard = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(KanbanDialog({ roster, actions: acts })) +} +const mountNewChatComposer = (roster, acts = actions()) => { + slots = [] + index = 0 + return draw(NewChat({ mode: 'group', roster, actions: acts })) +} +// A locked group chat without an admin draws the sentence in the composer; with the admin chosen +// it draws it in the To: row instead. Seed the state that pick leaves behind (group on, admin +// chosen) so the second draw is the one under test. +const mountNewChatAdmin = (roster, acts = actions()) => { + slots = [] + index = 0 + slots[2] = true + slots[4] = 'chief' + return draw(NewChat({ mode: 'group', roster, actions: acts })) +} +const mountProjectSettings = (roster, acts = actions()) => { + slots = [] + index = 0 + const readOnly = typeof roster?.readOnly === 'string' ? roster.readOnly : '' + return draw(ProjectSettingsPanel({ + project: { id: 'p1', name: 'Ops' }, call: () => Promise.resolve({}), + locked: readOnly.trim() !== '', readOnly, actions: acts, onClose() {}, + })) +} const PAGES = ['general', 'bots', 'groups', 'projects', 'connectors', 'capabilities', 'schedules', 'workflows', 'usage', 'secrets', 'feedback', 'about'] @@ -152,6 +187,10 @@ const SURFACES = [ { name: 'drawer: group settings', mount: roster => mountDrawer(roster, 'settings', 'r1') }, { name: 'Inbox dialog', mount: roster => mountInbox(roster) }, { name: 'Memory panel', mount: roster => mountMemory(roster) }, + { name: 'Board dialog', mount: (roster, acts) => mountBoard(roster, acts) }, + { name: 'New chat: composer', mount: (roster, acts) => mountNewChatComposer(roster, acts) }, + { name: 'New chat: admin chosen', mount: (roster, acts) => mountNewChatAdmin(roster, acts) }, + { name: 'Project settings panel', mount: (roster, acts) => mountProjectSettings(roster, acts) }, ] test('the Settings header offers one Reload beside the host sentence on every page', () => { @@ -174,7 +213,7 @@ test('each drawer surface offers exactly one Reload, never two', () => { } }) -test('the Inbox and the Memory panel each offer one Reload — their own way out of the modal', () => { +test('every surface that is not the header or a drawer offers one Reload — its own way out of the modal', () => { for (const surface of SURFACES.filter(entry => !entry.name.startsWith('drawer') && entry.name !== 'Settings header')) { const nodes = surface.mount(locked) assert.equal(sentenceNodes(nodes).length, 1, `${surface.name}: the sentence stays once`) @@ -219,6 +258,10 @@ const mountFor = (surface, roster, acts) => { if (surface.name === 'Settings header') return mountSettings(roster, 'general', null, acts) if (surface.name === 'Inbox dialog') return mountInbox(roster, acts) if (surface.name === 'Memory panel') return mountMemory(roster, acts) + if (surface.name === 'Board dialog') return mountBoard(roster, acts) + if (surface.name === 'New chat: composer') return mountNewChatComposer(roster, acts) + if (surface.name === 'New chat: admin chosen') return mountNewChatAdmin(roster, acts) + if (surface.name === 'Project settings panel') return mountProjectSettings(roster, acts) return mountDrawer(roster, surface.name.includes('settings') ? 'settings' : 'details', surface.name.includes('group') ? 'r1' : 'chief', acts) }