From 4a3798c0ef4f2d2a8f18b23b4e69eb7892a97b92 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:53:01 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20regenerate=20the=20client=20bundle=20a?= =?UTF-8?q?nd=20ds-bot=20patches=20for=20the=20New=E2=80=A6=20menu=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2503-composer-group-admin-locked.fixed.md | 1 + .../2506-newchat-add-agent-locked.fixed.md | 1 + core/client.js | 4 +- core/src/client/new-chat.js | 11 ++-- core/test/composer-send-readonly.test.js | 50 ++++++++++++++++++- docs/subsystems/core-client.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 18 ++++--- patches/ds-bot/0008-client-bundle.patch | 7 +-- .../files/test/composer-send-readonly.test.js | 50 ++++++++++++++++++- 9 files changed, 123 insertions(+), 21 deletions(-) create mode 100644 changelog.d/2503-composer-group-admin-locked.fixed.md create mode 100644 changelog.d/2506-newchat-add-agent-locked.fixed.md diff --git a/changelog.d/2503-composer-group-admin-locked.fixed.md b/changelog.d/2503-composer-group-admin-locked.fixed.md new file mode 100644 index 000000000..c973c4fae --- /dev/null +++ b/changelog.d/2503-composer-group-admin-locked.fixed.md @@ -0,0 +1 @@ +In a read-only window a new group chat that already picked its admin no longer draws a **Send** arrow that does nothing: the composer draws no send control at all, and the host's own read-only sentence stays once, on the To: line where **Create group chat** would be. A writable window is unchanged ([#2503](https://github.com/ThinkFlowLab/FlyBot/issues/2503)). diff --git a/changelog.d/2506-newchat-add-agent-locked.fixed.md b/changelog.d/2506-newchat-add-agent-locked.fixed.md new file mode 100644 index 000000000..c222db9f7 --- /dev/null +++ b/changelog.d/2506-newchat-add-agent-locked.fixed.md @@ -0,0 +1 @@ +A read-only window's New… menu no longer offers **Add coding agent**: `addAgent` is a write the host answers `bot/read-only`, so the row is not drawn at all while the other rows stay as a preview, and a writable window keeps the row ([#2506](https://github.com/ThinkFlowLab/FlyBot/issues/2506)). diff --git a/core/client.js b/core/client.js index 9d0768968..9e0c82d3d 100644 --- a/core/client.js +++ b/core/client.js @@ -16158,7 +16158,7 @@ function NewChat({ mode, roster, actions }) { } }); } - if ((roster.agents ?? []).length === 0) { + if (!locked && (roster.agents ?? []).length === 0) { options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { await actions.addAgent(); close(); @@ -16327,7 +16327,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } } }), - locked && !(group && admin !== 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 : (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)) ) ) ) diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 6c1b1eb37..ba9957785 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -185,8 +185,9 @@ export function NewChat({ mode, roster, actions }) { }, }) } - // A team holds at most one Agent; the entry exists only while there is none. - if ((roster.agents ?? []).length === 0) { + // A team holds at most one Agent; the entry exists only while there is none, and a read-only + // window does not offer it at all: the host answers `add-agent` with `bot/read-only` (P-571b). + if (!locked && (roster.agents ?? []).length === 0) { options.push({ key: 'agent', icon: h(TerminalIcon), label: t('Add coding agent'), hint: t('A coding session without a Bot'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } @@ -295,7 +296,9 @@ export function NewChat({ mode, roster, actions }) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); sendMessage() } }, }), - locked && !(group && admin !== null) - ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) + locked + ? (group && admin !== null + ? null + : h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))) : 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/test/composer-send-readonly.test.js b/core/test/composer-send-readonly.test.js index 0e684af32..0b83e1900 100644 --- a/core/test/composer-send-readonly.test.js +++ b/core/test/composer-send-readonly.test.js @@ -2,7 +2,9 @@ // `create-room` + `send` for several, and `openBot` sent the typed line when a picker row was // pressed — every shape reached a host that can only answer `bot/read-only` after the fact. A // locked window now draws the host's own sentence beside the composer instead of a live Send, no -// press writes, and a writable window sends exactly as before. +// press writes, and a writable window sends exactly as before. The New… menu is the same shape one +// step earlier (P-571b): `Add coding agent` asked a host that answers `bot/read-only`, so a locked +// menu does not draw it and a writable one keeps it. import { test } from 'node:test' import assert from 'node:assert/strict' import { fileURLToPath } from 'node:url' @@ -73,7 +75,7 @@ const actionsOf = () => { send: async (id, text) => { calls.push(['send', id, text]) }, openSession: id => { calls.push(['open-session', id]) }, closeOverlay() {}, notify() {}, setFlags: async (id, flags) => { calls.push(['set-flags', id, flags]) }, - newChatActionRows() {}, addAgent: async () => {}, createBot: async () => ({ id: 'new' }), + newChatActionRows() {}, addAgent: async () => { calls.push(['add-agent']) }, createBot: async () => ({ id: 'new' }), openSettings() {}, openPalette() {}, }, } @@ -172,6 +174,12 @@ const groupSend = async (roster, picks, { enter = false } = {}) => { const sent = await view.send({ enter }) return { calls: view.calls, ...sent } } +// The New… menu itself: a short action list, no To: row and no composer. +const menuPane = (roster) => { + const { calls, actions } = actionsOf() + const pane = mount({ mode: 'new', roster, actions }) + return { calls, nodes: () => pane.draw() } +} // A picker row pressed with a line typed is the other send path (`openBot`, a search pane). const searchThenOpen = async (roster, name) => { const { calls, actions } = actionsOf() @@ -208,6 +216,26 @@ test('a read-only window sends nothing when a picker row is pressed with a line assert.ok(locked.calls.some(call => call[0] === 'open-session' && call[1] === 'chief'), 'opening the session may stay') }) +// A read-only group chat whose admin is still chosen keeps its Create slot as the host's sentence +// on the To: line; the composer below must not draw a second live Send for it. +test('a read-only window draws no Send for a group that still has its admin chosen', async () => { + const view = groupCompose(rosterWith(SENTENCE)) + await view.pick('Chief') + await view.pick('Scout') + let nodes = view.pane.draw() + typeLine(nodes, 'hello there') + await flush() + nodes = view.pane.draw() + assert.equal(sendButton(nodes), undefined, 'the composer draws no live Send control') + enterInComposer(nodes) + await flush() + nodes = view.pane.draw() + assert.deepEqual(view.calls, [], 'Enter writes nothing') + const line = readOnlyLine(nodes) + assert.equal(line.length, 1, "the host's own sentence is shown exactly once") + assert.equal(ownText(line[0]).trim(), SENTENCE) +}) + test('a writable window still sends once from the composer', async () => { for (const roster of [rosterWith(undefined), rosterWith(''), rosterWith(' ')]) { const open = await groupSend(roster, ['Scout']) @@ -221,3 +249,21 @@ test('a writable window still sends once from the composer', async () => { assert.deepEqual(many.calls[2], ['open-session', 'r-new'], 'then open the new room') } }) + +// P-571b: the Add coding agent row is an ask the host answers `bot/read-only`, so a locked window +// must not offer it at all; a writable one keeps the row and it still posts. +test('a read-only New… menu draws no Add coding agent row', async () => { + const menu = menuPane(rosterWith(SENTENCE)) + const nodes = menu.nodes() + assert.ok(nodes.some(optionRow('New group chat')), 'the menu itself is drawn') + assert.equal(nodes.some(optionRow('Add coding agent')), false, 'no Add coding agent row in a read-only window') + assert.deepEqual(menu.calls, [], 'drawing the menu writes nothing') +}) + +test('a writable New… menu keeps Add coding agent and asks once when it is pressed', async () => { + const menu = menuPane(rosterWith(undefined)) + const nodes = menu.nodes() + press(nodes, optionRow('Add coding agent')) + await flush() + assert.deepEqual(menu.calls, [['add-agent']], 'one press asks add-agent exactly once') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2c73c16f1..4c32878c7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -225,7 +225,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility - 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`. -- 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 puts `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` (`role="status"`) where the Send button was, drawn once (the group form's own line already stands when it picked an admin). A writable window sends exactly as before. Test: `core/test/composer-send-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. - Settings say each thing once (#230): the Model and Main Bot cards show no row label that repeats the card title (`SwitchRow` `showLabel: false` keeps the switch's accessible name), the Create Bot dialog has its Back / Close in the header only (the footer Back / Cancel is for the inline form), and the account menu is as wide as its longest item (`width:max-content`, capped to the viewport) instead of cutting `Switch to classic Agent mode`. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 32c642808..c78a6243f 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -2343,7 +2343,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js const text = message.trim() if (group && admin !== null) { await makeGroup(); return } if (text === '' || picked.length === 0) return -@@ -87,24 +87,51 @@ export function NewChat({ mode, roster, actions }) { +@@ -87,24 +87,52 @@ export function NewChat({ mode, roster, actions }) { close() return } @@ -2364,6 +2364,8 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js - options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot }) - options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } }) - // A team holds at most one DSH Agent; the entry exists only while there is none. +- if ((roster.agents ?? []).length === 0) { +- options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) + if (menuOnly) { + options.push({ key: 'chat', icon: h(ChatIcon), label: t('New chat'), hint: t('Message one of your Bots'), run: () => { setCompose(true); setCursor(0); setTimeout(() => inputRef.current?.focus()) } }) + } @@ -2383,9 +2385,9 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + }, + }) + } -+ // A team holds at most one Agent; the entry exists only while there is none. - if ((roster.agents ?? []).length === 0) { -- options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) }) ++ // A team holds at most one Agent; the entry exists only while there is none, and a read-only ++ // window does not offer it at all: the host answers `add-agent` with `bot/read-only` (P-571b). ++ if (!locked && (roster.agents ?? []).length === 0) { + options.push({ key: 'agent', icon: h(TerminalIcon), label: t('Add coding agent'), hint: t('A coding session without a Bot'), run: () => run(async () => { await actions.addAgent(); close() }) }) } } @@ -2483,13 +2485,15 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js h('div', { className: 'bt-nc-card' }, h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), h('textarea', { -@@ -176,5 +176,7 @@ export function NewChat({ mode, roster, actions }) { +@@ -176,5 +176,9 @@ export function NewChat({ mode, roster, actions }) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); sendMessage() } }, }), - h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon)))))) -+ locked && !(group && admin !== null) -+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) ++ locked ++ ? (group && admin !== null ++ ? null ++ : h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))) + : 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 f770b4b6c..97e443b10 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -20587,6 +20587,8 @@ diff --git a/client.js b/client.js setCursor(0); inputRef.current?.focus(); } }); +- if ((roster.agents ?? []).length === 0) { +- options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { + for (const action of newChatActionRows({ mode, group, picked, query, roster })) { + options.push({ + key: action.key, @@ -20601,8 +20603,7 @@ diff --git a/client.js b/client.js + } + }); + } - if ((roster.agents ?? []).length === 0) { -- options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => { ++ if (!locked && (roster.agents ?? []).length === 0) { + options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => { await actions.addAgent(); close(); @@ -20774,7 +20775,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) ? (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 : (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)) ) ) ) diff --git a/patches/ds-bot/files/test/composer-send-readonly.test.js b/patches/ds-bot/files/test/composer-send-readonly.test.js index 0e684af32..0b83e1900 100644 --- a/patches/ds-bot/files/test/composer-send-readonly.test.js +++ b/patches/ds-bot/files/test/composer-send-readonly.test.js @@ -2,7 +2,9 @@ // `create-room` + `send` for several, and `openBot` sent the typed line when a picker row was // pressed — every shape reached a host that can only answer `bot/read-only` after the fact. A // locked window now draws the host's own sentence beside the composer instead of a live Send, no -// press writes, and a writable window sends exactly as before. +// press writes, and a writable window sends exactly as before. The New… menu is the same shape one +// step earlier (P-571b): `Add coding agent` asked a host that answers `bot/read-only`, so a locked +// menu does not draw it and a writable one keeps it. import { test } from 'node:test' import assert from 'node:assert/strict' import { fileURLToPath } from 'node:url' @@ -73,7 +75,7 @@ const actionsOf = () => { send: async (id, text) => { calls.push(['send', id, text]) }, openSession: id => { calls.push(['open-session', id]) }, closeOverlay() {}, notify() {}, setFlags: async (id, flags) => { calls.push(['set-flags', id, flags]) }, - newChatActionRows() {}, addAgent: async () => {}, createBot: async () => ({ id: 'new' }), + newChatActionRows() {}, addAgent: async () => { calls.push(['add-agent']) }, createBot: async () => ({ id: 'new' }), openSettings() {}, openPalette() {}, }, } @@ -172,6 +174,12 @@ const groupSend = async (roster, picks, { enter = false } = {}) => { const sent = await view.send({ enter }) return { calls: view.calls, ...sent } } +// The New… menu itself: a short action list, no To: row and no composer. +const menuPane = (roster) => { + const { calls, actions } = actionsOf() + const pane = mount({ mode: 'new', roster, actions }) + return { calls, nodes: () => pane.draw() } +} // A picker row pressed with a line typed is the other send path (`openBot`, a search pane). const searchThenOpen = async (roster, name) => { const { calls, actions } = actionsOf() @@ -208,6 +216,26 @@ test('a read-only window sends nothing when a picker row is pressed with a line assert.ok(locked.calls.some(call => call[0] === 'open-session' && call[1] === 'chief'), 'opening the session may stay') }) +// A read-only group chat whose admin is still chosen keeps its Create slot as the host's sentence +// on the To: line; the composer below must not draw a second live Send for it. +test('a read-only window draws no Send for a group that still has its admin chosen', async () => { + const view = groupCompose(rosterWith(SENTENCE)) + await view.pick('Chief') + await view.pick('Scout') + let nodes = view.pane.draw() + typeLine(nodes, 'hello there') + await flush() + nodes = view.pane.draw() + assert.equal(sendButton(nodes), undefined, 'the composer draws no live Send control') + enterInComposer(nodes) + await flush() + nodes = view.pane.draw() + assert.deepEqual(view.calls, [], 'Enter writes nothing') + const line = readOnlyLine(nodes) + assert.equal(line.length, 1, "the host's own sentence is shown exactly once") + assert.equal(ownText(line[0]).trim(), SENTENCE) +}) + test('a writable window still sends once from the composer', async () => { for (const roster of [rosterWith(undefined), rosterWith(''), rosterWith(' ')]) { const open = await groupSend(roster, ['Scout']) @@ -221,3 +249,21 @@ test('a writable window still sends once from the composer', async () => { assert.deepEqual(many.calls[2], ['open-session', 'r-new'], 'then open the new room') } }) + +// P-571b: the Add coding agent row is an ask the host answers `bot/read-only`, so a locked window +// must not offer it at all; a writable one keeps the row and it still posts. +test('a read-only New… menu draws no Add coding agent row', async () => { + const menu = menuPane(rosterWith(SENTENCE)) + const nodes = menu.nodes() + assert.ok(nodes.some(optionRow('New group chat')), 'the menu itself is drawn') + assert.equal(nodes.some(optionRow('Add coding agent')), false, 'no Add coding agent row in a read-only window') + assert.deepEqual(menu.calls, [], 'drawing the menu writes nothing') +}) + +test('a writable New… menu keeps Add coding agent and asks once when it is pressed', async () => { + const menu = menuPane(rosterWith(undefined)) + const nodes = menu.nodes() + press(nodes, optionRow('Add coding agent')) + await flush() + assert.deepEqual(menu.calls, [['add-agent']], 'one press asks add-agent exactly once') +})