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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2271-agent-menu-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 8 additions & 5 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions core/src/client/context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
133 changes: 133 additions & 0 deletions core/test/context-menu-agent-readonly.test.js
Original file line number Diff line number Diff line change
@@ -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`)
}
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>` 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 `<untrusted-message>` 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:<name>`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182).
Expand Down
Loading
Loading