diff --git a/changelog.d/2271-agent-menu-readonly.fixed.md b/changelog.d/2271-agent-menu-readonly.fixed.md new file mode 100644 index 00000000..f17e738d --- /dev/null +++ b/changelog.d/2271-agent-menu-readonly.fixed.md @@ -0,0 +1 @@ +A read-only FlyBot window no longer offers the DSH Agent menu's Pin/Unpin and Remove DSH Agent, or a Session menu's Rename and Archive: the host refuses every one of those writes, so the row only produced a failure toast (#2271). diff --git a/core/client.js b/core/client.js index 852a1d35..2faa1cf2 100644 --- a/core/client.js +++ b/core/client.js @@ -19515,25 +19515,28 @@ function AgentMenu({ menu, roster, actions, leaving }) { }, [menu.x, menu.y]); const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId]; if (agent === void 0) return null; + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; const groups = menu.kind === "agent" ? [ [ - [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })] + locked ? null : [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })] ], [ - [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true] + locked ? null : [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true] ] ] : [ [ - [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)] + locked ? null : [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)] ], [ - [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] + locked ? null : [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; + const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); + if (shown.length === 0) return null; return (0, import_react43.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 }, - groups.map((group, index) => (0, import_react43.createElement)( + shown.map((group, index) => (0, import_react43.createElement)( import_react43.Fragment, { key: index }, index > 0 ? (0, import_react43.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, diff --git a/core/src/client/context-menu.js b/core/src/client/context-menu.js index 9f1c8438..0633a08a 100644 --- a/core/src/client/context-menu.js +++ b/core/src/client/context-menu.js @@ -87,25 +87,30 @@ function AgentMenu({ menu, roster, actions, leaving }) { }, [menu.x, menu.y]) const agent = roster.agentsById[menu.kind === 'agent' ? menu.id : menu.agentId] if (agent === undefined) return null + // A window that holds the team read-only draws none of the writes the host refuses (#2232, #2253). + const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' const groups = menu.kind === 'agent' ? [ [ - [agent.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })], + locked ? null : [agent.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })], ], [ - [t('Remove DSH Agent'), TrashIcon, () => actions.removeAgent(agent.id), true], + locked ? null : [t('Remove DSH Agent'), TrashIcon, () => actions.removeAgent(agent.id), true], ], ] : [ [ - [t('Rename'), PencilIcon, () => actions.beginAgentRename(menu.id)], + locked ? null : [t('Rename'), PencilIcon, () => actions.beginAgentRename(menu.id)], ], [ - [t('Archive'), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true], + locked ? null : [t('Archive'), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true], ], ] + // Every row here writes, so a read-only window has no menu to draw at all. + const shown = groups.map(group => group.filter(Boolean)).filter(group => group.length > 0) + if (shown.length === 0) return null return h('div', { ref, className: 'bt-menu', role: 'menu', 'aria-label': menu.kind === 'agent' ? 'DSH Agent' : t('Session actions'), style: place, 'data-leaving': leaving || undefined }, - groups.map((group, index) => h(Fragment, { key: index }, + shown.map((group, index) => h(Fragment, { key: index }, index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, group.map(([label, Icon, run, danger]) => h('button', { key: label, type: 'button', role: 'menuitem', className: danger ? 'bt-danger' : undefined, diff --git a/core/test/context-menu-agent-readonly.test.js b/core/test/context-menu-agent-readonly.test.js new file mode 100644 index 00000000..18c9cfa0 --- /dev/null +++ b/core/test/context-menu-agent-readonly.test.js @@ -0,0 +1,133 @@ +// A window that holds the team read-only draws none of the DSH Agent menu's writes. +// The host refuses each of them with `bot/read-only` (`core/src/host/channel.js`: none of +// set-flags, remove-agent, agent-rename, agent-archive is in READS), so an offered control +// can only produce a refusal toast. The menu reads `roster.readOnly` exactly as the Bot menu +// does: a string that trims to nothing is a writable window (#2253). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { indexRoster } from '../src/client/sources.js' + +const STUB = ` +export const call = async () => ({}) +` +const stubApi = { + name: 'stub-api', + setup(context) { + context.onResolve({ filter: /(^|\/)api\.js$/ }, () => ({ path: 'flybot-api-stub', namespace: 'stub' })) + context.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: STUB, loader: 'js' })) + }, +} + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/context-menu.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], + plugins: [stubApi], +}) + +// One render pass over the hook shim: the menu reads its inputs and draws, so hooks only have +// to answer what the component reads during that one call. +let pass = null +const sandbox = { + module: { exports: {} }, exports: null, Date, Promise, console, + window: { addEventListener: () => {}, removeEventListener: () => {}, innerWidth: 1400, innerHeight: 900 }, + document: { body: {} }, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: (initial) => { + const index = pass === null ? 0 : pass.index++ + if (pass === null) return [typeof initial === 'function' ? initial() : initial, () => {}] + if (!(index in pass.values)) pass.values[index] = typeof initial === 'function' ? initial() : initial + return [pass.values[index], (next) => { pass.values[index] = typeof next === 'function' ? next(pass.values[index]) : next }] + }, + useEffect: () => {}, useLayoutEffect: () => {}, + useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + Component: class { constructor(props) { this.props = props } setState() {} render() { return null } }, + }, + 'react-dom': { createPortal: element => element }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +} +sandbox.exports = sandbox.module.exports +runInNewContext(bundle.outputFiles[0].text, sandbox) +const { ContextMenu } = sandbox.module.exports + +const renderNode = (node) => { + if (Array.isArray(node)) return node.map(renderNode) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') return renderNode(node.type({ ...node.props, children: node.children })) + return { ...node, children: (node.children ?? []).map(renderNode) } +} +const draw = (element) => { + pass = { index: 0, values: [] } + const tree = renderNode(element()) + pass = null + return tree +} + +const textOf = node => (node?.children ?? []).flat(Infinity).filter(child => typeof child === 'string').join('') +// Every row the menu draws, by its visible label. +const menuItems = (node, found = []) => { + if (node === null || typeof node !== 'object') return found + if (node.props?.role === 'menuitem') found.push(textOf(node)) + for (const child of node.children ?? []) menuItems(child, found) + return found +} + +const agents = [ + { id: 'dsh1', name: 'Desk', pinned: false }, + { id: 'dsh2', name: 'Bench', pinned: true }, +] +const rosterOf = (extra = {}) => indexRoster({ bots: [], rooms: [], agents, ...extra }) + +// Every label whose click writes through the host, by the menu that draws it. +const AGENT_WRITES = ['Pin', 'Unpin', 'Remove DSH Agent'] +const SESSION_WRITES = ['Rename', 'Archive'] + +const actions = () => ({ + closeMenu: () => {}, markUnread: () => {}, openSettings: () => {}, setFlags: () => {}, renameBot: () => {}, + duplicateBot: async () => ({ id: 'copy' }), openSession: () => {}, setMain: () => {}, deleteBot: () => {}, + deleteRoom: () => {}, reportError: () => {}, removeAgent: () => {}, beginAgentRename: () => {}, + archiveAgentSession: () => {}, +}) +const drawMenu = (roster, menu) => draw(() => ContextMenu({ menu, roster, actions: actions(), leaving: false })) +const menusOf = roster => ({ + agent: drawMenu(roster, { kind: 'agent', id: 'dsh1', x: 10, y: 10 }), + pinned: drawMenu(roster, { kind: 'agent', id: 'dsh2', x: 10, y: 10 }), + session: drawMenu(roster, { kind: 'agent-session', id: 's1', agentId: 'dsh1', x: 10, y: 10 }), +}) +const labelsOf = menus => Object.fromEntries(Object.entries(menus).map(([name, tree]) => [name, menuItems(tree)])) + +test('a read-only window offers no write in the DSH Agent menus', () => { + const labels = labelsOf(menusOf(rosterOf({ readOnly: 'This window holds the team read-only.' }))) + assert.deepEqual(labels.agent.filter(label => AGENT_WRITES.includes(label)), [], 'the agent menu still offers a write') + assert.deepEqual(labels.pinned.filter(label => AGENT_WRITES.includes(label)), [], 'the pinned agent menu still offers a write') + assert.deepEqual(labels.session.filter(label => SESSION_WRITES.includes(label)), [], 'the session menu still offers a write') +}) + +test('a read-only window draws no empty menu box', () => { + const menus = menusOf(rosterOf({ readOnly: 'read only' })) + for (const [menu, tree] of Object.entries(menus)) assert.equal(tree, null, `the read-only ${menu} menu drew a box`) +}) + +test('a writable window keeps every Agent write exactly as it is', () => { + const labels = labelsOf(menusOf(rosterOf())) + assert.deepEqual(labels.agent, ['Pin', 'Remove DSH Agent']) + assert.deepEqual(labels.pinned, ['Unpin', 'Remove DSH Agent']) + assert.deepEqual(labels.session, ['Rename', 'Archive']) +}) + +test('a host sentence that trims to nothing is a writable window', () => { + for (const readOnly of [undefined, '', ' ']) { + const labels = labelsOf(menusOf(rosterOf({ readOnly }))) + assert.deepEqual(labels.agent, ['Pin', 'Remove DSH Agent'], `readOnly ${JSON.stringify(readOnly)} hid the agent writes`) + assert.deepEqual(labels.session, ['Rename', 'Archive'], `readOnly ${JSON.stringify(readOnly)} hid the session writes`) + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 6783f459..9294c855 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -224,7 +224,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility - Slot `flybot.settings.page` (`0003-flybot-api-client`, types in `flybot.d.ts`, #337) adds a page to the Settings list right after Connectors: `{ id, order, label, icon?, component }`. `SettingsDialog` (`settings.js`) lists `useSettingsPages()` entries under the key `plugin:` and draws the component through `SettingsPageSlot` (a failing component draws nothing, it does not close the dialog); a page whose plugin is gone opens General. `label` is an English key for the host `t()`; the component gets `{ roster, actions, call, run, t, Section, Row }` (the host translator, the Settings section card and row, so the page matches the others and reads zh from `i18n.js`). Social registers **Bot Social** (the server, see [social](social.md)). Test: `core/test/settings-page-slot.test.js`. - Slot `flybot.account.header` (`0003-flybot-api-client`) is a compact block at the top of the account menu (`FlyBotAccountHeader`); social uses it for the owner's own handle (#181). - Slot `flybot.new-chat.action` (`0003-flybot-api-client`, types in `flybot.d.ts`) is a row on the sidebar **+** new-chat menu, after New group chat. Plugins register `{ id, order, label, hint?, visible?, run }`; the picker looks `label` / `hint` up with `t()` like the neighbouring rows. A string returned from `run` is shown as a toast (`actions.notify`). Social registers Face-to-face room and Add friend; see [plugin-runtime](plugin-runtime.md) and [social](social.md). -- Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **New group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add coding agent**; every row has its own mark (`ChatIcon`, `BotIcon`, `PeopleIcon`, a plugin row's `icon`, drawn through `SlotIcon` behind its own error boundary with the people mark as fallback; a function or memo/forwardRef component, `TerminalIcon`), one verb style (New … / Add …) and one short hint with no closing period (#385: *Message one of your Bots*, *Set up a Bot with its own role*, *Chat with several of your Bots*, *Meet people nearby with a 6-digit number*, *Paste an invite link or code*, *A coding session without a Bot*). A Bot's right-click menu has **Edit Bot** and no separate **Rename Bot** (the name is edited there); a `flybot.sidebar.structure` entry's `menuItems` rows follow in their own group, and the menu hands each one the window's read-only flag (`readOnly: true` only when `roster.readOnly` trims to a non-empty string), so an entry hides a write the host would refuse instead of painting its failure (#2238); a read-only window (`roster.readOnly` a non-empty string, read the same way) also draws none of the menu's own core writes — no Pin/Unpin, Duplicate, Make Main Bot, Remove Main Bot role, Hide from sidebar, Rename group, Delete or Delete group, because the host refuses every one of them with `bot/read-only` — while Edit Bot, Group settings, Mark as Unread and Copy conversation ID stay; the Agent row's menu is not a Bot menu and keeps its own rows. Tests: `core/test/context-menu-readonly.test.js`; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126, #181). +- Sidebar **+** (`mode === 'new'`) opens a short action menu (`data-menu`, no To:/composer) until **New chat** or **New group chat**. That menu does not list individual Bots (Chief and the rest stay behind **New chat**). The Agent row in that FlyBot menu is **Add coding agent**; every row has its own mark (`ChatIcon`, `BotIcon`, `PeopleIcon`, a plugin row's `icon`, drawn through `SlotIcon` behind its own error boundary with the people mark as fallback; a function or memo/forwardRef component, `TerminalIcon`), one verb style (New … / Add …) and one short hint with no closing period (#385: *Message one of your Bots*, *Set up a Bot with its own role*, *Chat with several of your Bots*, *Meet people nearby with a 6-digit number*, *Paste an invite link or code*, *A coding session without a Bot*). A Bot's right-click menu has **Edit Bot** and no separate **Rename Bot** (the name is edited there); a `flybot.sidebar.structure` entry's `menuItems` rows follow in their own group, and the menu hands each one the window's read-only flag (`readOnly: true` only when `roster.readOnly` trims to a non-empty string), so an entry hides a write the host would refuse instead of painting its failure (#2238); a read-only window (`roster.readOnly` a non-empty string, read the same way) also draws none of the menu's own core writes — no Pin/Unpin, Duplicate, Make Main Bot, Remove Main Bot role, Hide from sidebar, Rename group, Delete or Delete group, because the host refuses every one of them with `bot/read-only` — while Edit Bot, Group settings, Mark as Unread and Copy conversation ID stay; the DSH Agent row's menu and a Session's menu read the same flag, so a read-only window draws no Pin/Unpin, Remove DSH Agent, Rename or Archive there either, because the host refuses `set-flags`, `remove-agent`, `agent-rename` and `agent-archive` for such a window, and with every row hidden the menu draws nothing at all rather than an empty box. Tests: `core/test/context-menu-readonly.test.js`, `core/test/context-menu-agent-readonly.test.js`; **Add DSH Agent** stays the DSH product string on Agent rows elsewhere (#126, #181). - Group bubbles, notices, and failed-reply lines never show a raw `` wrap or a developer error (`llm-deepseek: no API key`). `remoteMessageText` unwraps stored fences; `failureLabel` maps missing-key / unknown model failures to a short friendly line. Details stay in Settings → Feedback. - A closed face room's status line already says who left (#1103). `GroupReplies` (`leaveNoticeCoveredByClose` in `cells.js`) does not also draw a short leave notice for that person (`SelfB1025 left the room`, or `Coder (SelfB1025) left`). A leave while the room is still open, a leave the status line does not name, and every other notice stay. The social plugin uses the same rule when it decides whether to post the line. Test: `core/test/tool-cells.test.js`. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index d0beffcb..f808f620 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1310,7 +1310,42 @@ diff --git a/src/client/context-menu.js b/src/client/context-menu.js index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, group.map(([label, Icon, run, danger]) => h('button', { key: label, type: 'button', role: 'menuitem', className: danger ? 'bt-danger' : undefined, -@@ -92,3 +92,4 @@ function AgentMenu({ menu, roster, actions, leaving }) { +@@ -67,28 +67,34 @@ function AgentMenu({ menu, roster, actions, leaving }) { + }, [menu.x, menu.y]) + const agent = roster.agentsById[menu.kind === 'agent' ? menu.id : menu.agentId] + if (agent === undefined) return null ++ // A window that holds the team read-only draws none of the writes the host refuses (#2232, #2253). ++ const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' + const groups = menu.kind === 'agent' + ? [ + [ +- [agent.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })], ++ locked ? null : [agent.pinned ? t('Unpin') : t('Pin'), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })], + ], + [ +- [t('Remove DSH Agent'), TrashIcon, () => actions.removeAgent(agent.id), true], ++ locked ? null : [t('Remove DSH Agent'), TrashIcon, () => actions.removeAgent(agent.id), true], + ], + ] + : [ + [ +- [t('Rename'), PencilIcon, () => actions.beginAgentRename(menu.id)], ++ locked ? null : [t('Rename'), PencilIcon, () => actions.beginAgentRename(menu.id)], + ], + [ +- [t('Archive'), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true], ++ locked ? null : [t('Archive'), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true], + ], + ] ++ // Every row here writes, so a read-only window has no menu to draw at all. ++ const shown = groups.map(group => group.filter(Boolean)).filter(group => group.length > 0) ++ if (shown.length === 0) return null + return h('div', { ref, className: 'bt-menu', role: 'menu', 'aria-label': menu.kind === 'agent' ? 'DSH Agent' : t('Session actions'), style: place, 'data-leaving': leaving || undefined }, +- groups.map((group, index) => h(Fragment, { key: index }, ++ shown.map((group, index) => h(Fragment, { key: index }, + index > 0 ? h('div', { className: 'bt-menu-sep', role: 'separator' }) : null, + group.map(([label, Icon, run, danger]) => h('button', { + key: label, type: 'button', role: 'menuitem', className: danger ? 'bt-danger' : undefined, onClick: () => { actions.closeMenu(); void Promise.resolve(run()).catch(error => console.warn('[ds-bot]', error)) }, }, h(Icon), label))))) } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 7b9eb9b6..81cb9b23 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -24183,7 +24183,7 @@ diff --git a/client.js b/client.js key: label, type: "button", role: "menuitem", -@@ -10923,14 +10923,14 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { +@@ -10923,43 +10923,46 @@ function ContextMenu({ menu, roster, actions, leaving = false }) { actions.closeMenu(); void Promise.resolve(run()).catch((error) => actions.reportError(error, "menu")); } @@ -24202,17 +24202,38 @@ diff --git a/client.js b/client.js const box = ref.current?.getBoundingClientRect(); if (!box) return; setPlace({ left: Math.max(8, Math.min(menu.x, window.innerWidth - box.width - 8)), top: Math.max(8, Math.min(menu.y, window.innerHeight - box.height - 8)) }); -@@ -10952,14 +10952,14 @@ function AgentMenu({ menu, roster, actions, leaving }) { - [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] + }, [menu.x, menu.y]); + const agent = roster.agentsById[menu.kind === "agent" ? menu.id : menu.agentId]; + if (agent === void 0) return null; ++ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const groups = menu.kind === "agent" ? [ + [ +- [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })] ++ locked ? null : [agent.pinned ? t("Unpin") : t("Pin"), PinIcon, () => actions.setFlags(agent.id, { pinned: !agent.pinned })] + ], + [ +- [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true] ++ locked ? null : [t("Remove DSH Agent"), TrashIcon, () => actions.removeAgent(agent.id), true] + ] + ] : [ + [ +- [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)] ++ locked ? null : [t("Rename"), PencilIcon, () => actions.beginAgentRename(menu.id)] + ], + [ +- [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ++ locked ? null : [t("Archive"), TrashIcon, () => actions.archiveAgentSession(agent.id, menu.id), true] ] ]; - return (0, import_react29.createElement)( ++ const shown = groups.map((group) => group.filter(Boolean)).filter((group) => group.length > 0); ++ if (shown.length === 0) return null; + return (0, import_react43.createElement)( "div", { ref, className: "bt-menu", role: "menu", "aria-label": menu.kind === "agent" ? "DSH Agent" : t("Session actions"), style: place, "data-leaving": leaving || void 0 }, - groups.map((group, index) => (0, import_react29.createElement)( - import_react29.Fragment, -+ groups.map((group, index) => (0, import_react43.createElement)( ++ shown.map((group, index) => (0, import_react43.createElement)( + import_react43.Fragment, { key: index }, - index > 0 ? (0, import_react29.createElement)("div", { className: "bt-menu-sep", role: "separator" }) : null, diff --git a/patches/ds-bot/files/test/context-menu-agent-readonly.test.js b/patches/ds-bot/files/test/context-menu-agent-readonly.test.js new file mode 100644 index 00000000..18c9cfa0 --- /dev/null +++ b/patches/ds-bot/files/test/context-menu-agent-readonly.test.js @@ -0,0 +1,133 @@ +// A window that holds the team read-only draws none of the DSH Agent menu's writes. +// The host refuses each of them with `bot/read-only` (`core/src/host/channel.js`: none of +// set-flags, remove-agent, agent-rename, agent-archive is in READS), so an offered control +// can only produce a refusal toast. The menu reads `roster.readOnly` exactly as the Bot menu +// does: a string that trims to nothing is a writable window (#2253). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' +import { indexRoster } from '../src/client/sources.js' + +const STUB = ` +export const call = async () => ({}) +` +const stubApi = { + name: 'stub-api', + setup(context) { + context.onResolve({ filter: /(^|\/)api\.js$/ }, () => ({ path: 'flybot-api-stub', namespace: 'stub' })) + context.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: STUB, loader: 'js' })) + }, +} + +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/context-menu.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom'], + plugins: [stubApi], +}) + +// One render pass over the hook shim: the menu reads its inputs and draws, so hooks only have +// to answer what the component reads during that one call. +let pass = null +const sandbox = { + module: { exports: {} }, exports: null, Date, Promise, console, + window: { addEventListener: () => {}, removeEventListener: () => {}, innerWidth: 1400, innerHeight: 900 }, + document: { body: {} }, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: (initial) => { + const index = pass === null ? 0 : pass.index++ + if (pass === null) return [typeof initial === 'function' ? initial() : initial, () => {}] + if (!(index in pass.values)) pass.values[index] = typeof initial === 'function' ? initial() : initial + return [pass.values[index], (next) => { pass.values[index] = typeof next === 'function' ? next(pass.values[index]) : next }] + }, + useEffect: () => {}, useLayoutEffect: () => {}, + useRef: current => ({ current }), useCallback: fn => fn, useMemo: fn => fn(), Fragment: 'fragment', + Component: class { constructor(props) { this.props = props } setState() {} render() { return null } }, + }, + 'react-dom': { createPortal: element => element }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, +} +sandbox.exports = sandbox.module.exports +runInNewContext(bundle.outputFiles[0].text, sandbox) +const { ContextMenu } = sandbox.module.exports + +const renderNode = (node) => { + if (Array.isArray(node)) return node.map(renderNode) + if (node === null || typeof node !== 'object') return node + if (typeof node.type === 'function') return renderNode(node.type({ ...node.props, children: node.children })) + return { ...node, children: (node.children ?? []).map(renderNode) } +} +const draw = (element) => { + pass = { index: 0, values: [] } + const tree = renderNode(element()) + pass = null + return tree +} + +const textOf = node => (node?.children ?? []).flat(Infinity).filter(child => typeof child === 'string').join('') +// Every row the menu draws, by its visible label. +const menuItems = (node, found = []) => { + if (node === null || typeof node !== 'object') return found + if (node.props?.role === 'menuitem') found.push(textOf(node)) + for (const child of node.children ?? []) menuItems(child, found) + return found +} + +const agents = [ + { id: 'dsh1', name: 'Desk', pinned: false }, + { id: 'dsh2', name: 'Bench', pinned: true }, +] +const rosterOf = (extra = {}) => indexRoster({ bots: [], rooms: [], agents, ...extra }) + +// Every label whose click writes through the host, by the menu that draws it. +const AGENT_WRITES = ['Pin', 'Unpin', 'Remove DSH Agent'] +const SESSION_WRITES = ['Rename', 'Archive'] + +const actions = () => ({ + closeMenu: () => {}, markUnread: () => {}, openSettings: () => {}, setFlags: () => {}, renameBot: () => {}, + duplicateBot: async () => ({ id: 'copy' }), openSession: () => {}, setMain: () => {}, deleteBot: () => {}, + deleteRoom: () => {}, reportError: () => {}, removeAgent: () => {}, beginAgentRename: () => {}, + archiveAgentSession: () => {}, +}) +const drawMenu = (roster, menu) => draw(() => ContextMenu({ menu, roster, actions: actions(), leaving: false })) +const menusOf = roster => ({ + agent: drawMenu(roster, { kind: 'agent', id: 'dsh1', x: 10, y: 10 }), + pinned: drawMenu(roster, { kind: 'agent', id: 'dsh2', x: 10, y: 10 }), + session: drawMenu(roster, { kind: 'agent-session', id: 's1', agentId: 'dsh1', x: 10, y: 10 }), +}) +const labelsOf = menus => Object.fromEntries(Object.entries(menus).map(([name, tree]) => [name, menuItems(tree)])) + +test('a read-only window offers no write in the DSH Agent menus', () => { + const labels = labelsOf(menusOf(rosterOf({ readOnly: 'This window holds the team read-only.' }))) + assert.deepEqual(labels.agent.filter(label => AGENT_WRITES.includes(label)), [], 'the agent menu still offers a write') + assert.deepEqual(labels.pinned.filter(label => AGENT_WRITES.includes(label)), [], 'the pinned agent menu still offers a write') + assert.deepEqual(labels.session.filter(label => SESSION_WRITES.includes(label)), [], 'the session menu still offers a write') +}) + +test('a read-only window draws no empty menu box', () => { + const menus = menusOf(rosterOf({ readOnly: 'read only' })) + for (const [menu, tree] of Object.entries(menus)) assert.equal(tree, null, `the read-only ${menu} menu drew a box`) +}) + +test('a writable window keeps every Agent write exactly as it is', () => { + const labels = labelsOf(menusOf(rosterOf())) + assert.deepEqual(labels.agent, ['Pin', 'Remove DSH Agent']) + assert.deepEqual(labels.pinned, ['Unpin', 'Remove DSH Agent']) + assert.deepEqual(labels.session, ['Rename', 'Archive']) +}) + +test('a host sentence that trims to nothing is a writable window', () => { + for (const readOnly of [undefined, '', ' ']) { + const labels = labelsOf(menusOf(rosterOf({ readOnly }))) + assert.deepEqual(labels.agent, ['Pin', 'Remove DSH Agent'], `readOnly ${JSON.stringify(readOnly)} hid the agent writes`) + assert.deepEqual(labels.session, ['Rename', 'Archive'], `readOnly ${JSON.stringify(readOnly)} hid the session writes`) + } +})