diff --git a/changelog.d/2400-group-overview-readonly.fixed.md b/changelog.d/2400-group-overview-readonly.fixed.md new file mode 100644 index 00000000..07bcd8ac --- /dev/null +++ b/changelog.d/2400-group-overview-readonly.fixed.md @@ -0,0 +1 @@ +A read-only window's group overview in the details drawer now draws no write control: the group's name is plain text rather than a rename button, an invited share no longer offers **Remove my Bot**, and a shared room's "Waiting for your approval" card keeps its headline and rows but drops **Approve** and **Reject**, with the host's own sentence in the single `role="status"` line at the top of the page ([#2400](https://github.com/ThinkFlowLab/FlyBot/issues/2400)). A writable window keeps every control unchanged. diff --git a/core/client.js b/core/client.js index 8beb0af8..70bec4aa 100644 --- a/core/client.js +++ b/core/client.js @@ -19893,7 +19893,6 @@ function SharedRoomApprovals({ room: room2, roster }) { locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) )), - locked ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert" }, error) : null ); } @@ -19928,7 +19927,12 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }); }; const face = room2.origin === "face-to-face"; + const locked = lockedWindow(roster); return [ + // P-544: the group overview's writes are gone in a locked window, so it says why once, above + // the reads it still draws. The approval card used to carry its own copy (#2381); one page, + // one sentence. + locked ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), invited && room2.sharedFrom && !face ? (0, import_react44.createElement)( "div", @@ -19936,7 +19940,7 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti (0, import_react44.createElement)("div", { className: "bt-section-title" }, title), (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), room2.sharedFrom.invitedAt ? (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, - (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) + locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) ) : face ? null : room2.notice ? (0, import_react44.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react44.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), face ? null : (0, import_react44.createElement)(SharedRoomApprovals, { key: "approvals", room: room2, roster }), face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react44.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react44.createElement)( @@ -20119,7 +20123,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a "div", { key: "id", className: "bt-drawer-id", "data-move": side }, bot ? (0, import_react44.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react44.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react44.createElement)("input", { + bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : room2 && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : editing ? (0, import_react44.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 2066abcb..9748daa0 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -125,8 +125,8 @@ function SharedRoomApprovals({ room, roster }) { if ((room.viewerRole !== 'invited-owner' && !face) || !items.length) return null // A decision is a write, so a read-only window cannot make one: the host would answer // `bot/read-only` (or `bot/blocked-shared` in a room with several members) after the click. - // The card keeps its headline, count and rows; the two buttons give way to one line carrying the - // host's own sentence, the same shape the drawer's Settings view uses (#2381). + // The card keeps its headline, count and rows; its two buttons are gone, and the overview's one + // read-only sentence above explains why (#2381, P-544). const locked = lockedWindow(roster) const decide = async (item, decision) => { setBusy(item.id) @@ -150,9 +150,6 @@ function SharedRoomApprovals({ room, roster }) { locked ? null : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))), - locked - ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) - : null, error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null) } @@ -184,14 +181,19 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) if (mine) void actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }) } const face = room.origin === 'face-to-face' + const locked = lockedWindow(roster) return [ + // P-544: the group overview's writes are gone in a locked window, so it says why once, above + // the reads it still draws. The approval card used to carry its own copy (#2381); one page, + // one sentence. + locked ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), invited && room.sharedFrom && !face ? h('div', { key: 'from', className: 'bt-shared-from' }, h('div', { className: 'bt-section-title' }, title), h('div', { className: 'bt-set-hint' }, t('Your Bot was invited to this room by {name}', { name: room.sharedFrom.inviterName })), room.sharedFrom.invitedAt ? h('div', { className: 'bt-set-hint' }, t('Invited {when}', { when: new Date(room.sharedFrom.invitedAt).toLocaleString() })) : null, - h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) + locked ? null : h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) : face ? null : room.notice @@ -338,13 +340,15 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro : h(RoomAvatar, { room, roster, size: 64 }), bot?.remote ? h('div', { className: 'bt-drawer-name' }, current) - // P-542/P-487: a locked window refuses a rename like any other write, so the name stays as - // plain text instead of a control that can only be turned down. Bot overview only: a group's - // overview still holds its member entries, so it is left exactly as it was (P-544). + // P-542/P-487/P-544: a locked window refuses a rename like any other write, so the name + // stays as plain text instead of a control that can only be turned down — for a group as + // well as for a Bot. : bot && lockedWindow(roster) ? h('div', { className: 'bt-drawer-name', title: current }, current) : room?.origin === 'face-to-face' ? h('div', { className: 'bt-drawer-name bt-drawer-name-face', title: current }, current) + : room && lockedWindow(roster) + ? h('div', { className: 'bt-drawer-name', title: current }, current) : editing ? h('input', { className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), diff --git a/core/test/details-group-overview-readonly.test.js b/core/test/details-group-overview-readonly.test.js new file mode 100644 index 00000000..a26bb2f0 --- /dev/null +++ b/core/test/details-group-overview-readonly.test.js @@ -0,0 +1,177 @@ +// P-544: a read-only window's *group* overview still drew the rename control, left the name a +// button, and (for an invited share) offered `Remove my Bot` — every one of them a write the host +// answers with `bot/read-only`. It also said nothing about why. #2391 fixed the Bot overview and +// deliberately left this one alone; this is the group half. +// +// The drawing harness is the one `details-overview-readonly.test.js` uses: the real bundle, React +// and the host replaced by stubs, hook slots that survive a re-render, and no jsdom. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { DetailsDrawer } from './src/client/details-drawer.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { DetailsDrawer } = 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.' +const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' } +const room = { + id: 'r1', name: 'Team room', origin: 'group', named: true, members: ['a'], admin: 'a', + participants: [], presentedParticipants: [], pinned: false, hidden: false, notice: '', + workDir: '', mode: 'everyone', viewerRole: 'owner', +} +const invited = { ...room, viewerRole: 'invited-owner', pendingCount: 0, sharedFrom: { inviterName: 'Chris', invitedAt: 0 } } +const pending = { ...invited, pendingCount: 1, pendingApprovals: [{ id: 'a1', text: 'Which file?' }] } +const base = { + bots: [bot], byId: { a: bot }, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, + exchangeTurns: {}, questions: {}, self: { memberId: 'm1' }, +} +const rosterFor = (target, readOnly) => ({ + ...base, rooms: [target], roomsById: { [target.id]: target }, + ...(readOnly === undefined ? {} : { readOnly }), +}) +const actions = { + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, + notify() {}, reportError() {}, setFlags: () => Promise.resolve(), + updateBot: () => Promise.resolve(), updateRoom: () => Promise.resolve(), + schedules: () => Promise.resolve({ available: true, tasks: [] }), +} +const render = (roster, acts = actions) => { + slots = [] + index = 0 + effects = [] + const map = () => new Map() + return DetailsDrawer({ + sessionId: 'r1', startTab: 'details', hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: selector => selector(map()), + useActivity: selector => selector(map()), useWork: selector => selector(map()), + useBrowserTabs: selector => selector(map()), useBrowserPages: selector => selector(map()), + }) +} +// Real React draws a function component when it meets one; this expands the same way, with the hook +// slots above assigned in tree order. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function') return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const flatten = (node, ancestors = [], out = []) => { + if (Array.isArray(node)) { for (const child of node) flatten(child, ancestors, out); return out } + if (!node || typeof node !== 'object') return out + out.push({ node, ancestors }) + flatten(node.props?.children, [...ancestors, node], out) + return out +} +const tree = (roster) => flatten(expand(render(roster))) +const ownText = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const bodyOf = entries => entries.find(({ node }) => node.props?.className === 'bt-drawer-body') +const renameButton = entries => entries.find(({ node }) => node.type === 'button' && hasClass(node, 'bt-drawer-name')) +const plainName = entries => entries.find(({ node }) => node.type === 'div' && hasClass(node, 'bt-drawer-name') && ownText(node).trim() === 'Team room') +const removeButton = entries => entries.find(({ node }) => node.type === 'button' && ownText(node).trim() === 'Remove my Bot') +const hintEntries = entries => entries.filter(({ node }) => hasClass(node, 'bt-read-only')) +const statusLine = entries => entries.filter(({ node }) => node.props?.role === 'status' && ownText(node).trim() === SENTENCE) +const buttonLabels = entries => entries.filter(({ node }) => node.type === 'button').map(({ node }) => ownText(node).trim()) +const inBody = entries => { + const box = bodyOf(entries) + return box ? entries.filter(entry => entry.ancestors.includes(box.node)) : [] +} + +test('P-544: a read-only group overview says why once and draws no rename control', () => { + const locked = tree(rosterFor(room, SENTENCE)) + const lines = statusLine(locked) + assert.equal(lines.length, 1, "the host's sentence is drawn once") + assert.ok(hasClass(lines[0].node, 'bt-read-only'), 'the sentence is the read-only hint') + const name = plainName(locked)?.node + assert.ok(name, 'the name is still drawn, as plain text') + assert.equal(name.props.title, 'Team room', 'the plain name keeps the full text as its title') + assert.deepEqual(renameButton(locked), undefined, 'pressing the name can no longer open the rename field') + const labels = buttonLabels(inBody(locked)) + for (const write of ['Pin', 'Hide from sidebar', 'Remove my Bot']) { + assert.equal(labels.includes(write), false, `${write} is not drawn in a read-only group overview`) + } +}) + +test('P-544: a read-only invited group draws no Remove my Bot and keeps the invitation text', () => { + const locked = tree(rosterFor(invited, SENTENCE)) + assert.deepEqual(removeButton(locked), undefined, 'Remove my Bot is a write the host would refuse') + assert.match(ownText(bodyOf(locked).node), /invited to this room by Chris/, 'the invitation itself is a read and stays') + assert.equal(statusLine(locked).length, 1, "the host's sentence is drawn once") +}) + +test('P-544: a writable group keeps the rename control, Remove my Bot and draws no sentence', () => { + const open = tree(rosterFor(invited, undefined)) + assert.ok(renameButton(open), 'a writable group keeps the rename control') + assert.ok(removeButton(open), 'a writable invited group keeps Remove my Bot') + assert.deepEqual(statusLine(open), [], 'no read-only sentence is drawn') + for (const roster of [rosterFor(invited, ''), rosterFor(invited, ' ')]) { + const blank = tree(roster) + assert.ok(renameButton(blank), 'a blank read-only string is writable, as before') + assert.ok(removeButton(blank), 'a blank read-only string keeps Remove my Bot') + assert.deepEqual(statusLine(blank), [], 'a blank read-only string draws no sentence') + } +}) + +test('P-544: a read-only group with a pending approval still says the host sentence exactly once', () => { + const locked = tree(rosterFor(pending, SENTENCE)) + const lines = hintEntries(locked) + assert.equal(lines.length, 1, 'one read-only line on the page, not one per card') + assert.equal(ownText(lines[0].node).trim(), SENTENCE, "the line is the host's own sentence") + assert.equal(lines[0].ancestors.some(node => hasClass(node, 'bt-shared-wait')), false, + 'the page sentence is drawn above the approval card, not repeated inside it') + assert.match(ownText(bodyOf(locked).node), /Which file\?/, 'the approval is still readable') + const decisions = buttonLabels(locked).filter(label => label === 'Approve' || label === 'Reject') + assert.deepEqual(decisions, [], 'a read-only window still offers neither decision') +}) diff --git a/core/test/details-overview-readonly.test.js b/core/test/details-overview-readonly.test.js index b1897277..422f3eb6 100644 --- a/core/test/details-overview-readonly.test.js +++ b/core/test/details-overview-readonly.test.js @@ -69,8 +69,8 @@ const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', i const permissions = { defaultPreset: 'workspace-write', options: [{ value: 'read-only', name: 'Read only' }, { value: 'workspace-write', name: 'Workspace' }, { value: 'danger-full-access', name: 'Full access' }] } const base = { bots: [bot], rooms: [], byId: { a: bot }, roomsById: {}, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, exchangeTurns: {}, questions: {}, permissions } const rosterWith = (readOnly) => (readOnly === undefined ? { ...base } : { ...base, readOnly }) -// The group overview this pull request deliberately does not touch (P-544 owns it); it is drawn -// here so the test can say its shape did not change. +// The group overview #2391 left alone and P-544 now owns; it is drawn here so the Bot overview's +// own shape can be told apart from the group's. const room = { id: 'r1', name: 'Team room', origin: 'group', named: true, members: ['a'], admin: 'a', participants: [], presentedParticipants: [], pinned: false, hidden: false, notice: '', @@ -187,14 +187,16 @@ test('#2391: a writable window keeps the overview\'s write controls and draws no } }) -// P-544 owns the group overview's read-only shape. This pull request claims the Bot overview only, so -// the group drawer is expected to keep exactly the shape it has on the base commit: the rename control -// still drawn, no read-only sentence added. Written down here so a later change to it is deliberate. -test('#2391: the group overview is left as it was — this pull request claims the Bot overview only', () => { +// P-544: the group overview took the same shape as the Bot overview — one sentence in the host's +// words, the name as plain text, no rename control. +test('P-544: a read-only group overview says why once and draws the name as plain text', () => { slots = [] const entries = tree(roomRosterWith(SENTENCE), actions, 'r1') - assert.ok(renameButton(entries), 'a group keeps the rename control the base commit draws') - assert.deepEqual(statusLine(entries), [], 'no read-only sentence is added to the group overview') + assert.equal(statusLine(entries).length, 1, "the host's sentence is drawn once") + assert.deepEqual(renameButton(entries), undefined, 'the name is not a rename control in a locked window') + const plain = nodes(entries).find(node => node.type === 'div' && hasClass(node, 'bt-drawer-name') && ownText(node).trim() === 'Team room') + assert.ok(plain, 'the name is still drawn, as plain text') + assert.equal(plain.props.title, 'Team room', 'the plain name keeps the full text as its title') assert.deepEqual(modelTriggers(entries), [], 'a group never had a model menu') assert.deepEqual(permissionRows(entries), [], 'a group never had permission presets') }) diff --git a/core/test/room-approvals-readonly.test.js b/core/test/room-approvals-readonly.test.js index 52076cef..baeace08 100644 --- a/core/test/room-approvals-readonly.test.js +++ b/core/test/room-approvals-readonly.test.js @@ -5,7 +5,9 @@ // `try/finally` with no `catch`, so the card said nothing at all. // The drawer's own Settings view already asks `lockedWindow(roster)` and prints the host's // sentence (`details-drawer.js:270`); this card never did. It now does, and a refused decision -// writes one `role="alert"` line instead of vanishing. +// writes one `role="alert"` line instead of vanishing. P-544 later moved the one read-only +// sentence to the top of the group overview, so this file asserts the page still carries it once +// while the card keeps its rows and drops its decisions. // React is replaced by the same tiny renderer `approvals-readonly.test.js` uses (no jsdom here); // the host call is replaced too, so "the click was refused and the card said why" is measurable. import assert from 'node:assert/strict' @@ -154,10 +156,10 @@ test('#2381: a read-only window draws neither Approve nor Reject on the room app assert.match(shown, /Which file\?/) }) -test('#2381: the read-only room card carries the host sentence in one status line', () => { +test('#2381/P-544: a read-only group overview carries the host sentence in one status line', () => { const drawn = render(drawer(locked)) const lines = hints(drawn) - assert.equal(lines.length, 1, 'one read-only line on the card') + assert.equal(lines.length, 1, 'one read-only line on the page') assert.equal(lines[0].props.role, 'status', 'the line is announced') assert.equal(text(lines[0].props?.children), LOCK, "the line is the host's own sentence") // A writable window draws no line and keeps both decisions: the zero above is the lock, not a diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 294d0145..83e6f48c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -130,7 +130,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged. - A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). -- An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (`lockedWindow`, `schedules.js`) and, in a read-only window, draws the host's sentence in the form's place instead of mounting `BotSettingsForm` or `GroupSettingsForm`, so no write control the host would refuse is drawn for a Bot or a group, and a writable window keeps the form unchanged (#2387). A refused write in that view follows the same rule as the dialog (#2385): the `bt-error` line is a sibling above `bt-drawer-body bt-set-body`, not its first child, so a press at the bottom of the group page — Pin, Hide, Make admin, Remove — writes the host's refusal where the press happened instead of at the top of the box it was scrolled past. The drawer's overview view holds the same two rules: its refusal line is a sibling above `bt-drawer-body`, and in a read-only window the Bot's name is plain text with no model, permission or working-folder entry beside it, above a `role="status"` line carrying the host's own sentence, so the overview offers only what the host will not refuse. Tests: `core/test/details-bot-settings-readonly.test.js`, `core/test/details-drawer-refusal-line.test.js`, `core/test/details-overview-readonly.test.js`. The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. +- An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (`lockedWindow`, `schedules.js`) and, in a read-only window, draws the host's sentence in the form's place instead of mounting `BotSettingsForm` or `GroupSettingsForm`, so no write control the host would refuse is drawn for a Bot or a group, and a writable window keeps the form unchanged (#2387). A refused write in that view follows the same rule as the dialog (#2385): the `bt-error` line is a sibling above `bt-drawer-body bt-set-body`, not its first child, so a press at the bottom of the group page — Pin, Hide, Make admin, Remove — writes the host's refusal where the press happened instead of at the top of the box it was scrolled past. The drawer's overview view holds the same two rules: its refusal line is a sibling above `bt-drawer-body`, and in a read-only window the Bot's name is plain text with no model, permission or working-folder entry beside it, above a `role="status"` line carrying the host's own sentence, so the overview offers only what the host will not refuse. A group's overview follows it (#2391 left it to P-544): the name is plain text with no rename control, `Remove my Bot` is gone, and the host's one sentence sits above the reads — a shared room's approval card keeps its headline and rows and drops its decisions — while a writable window keeps every control. Tests: `core/test/details-bot-settings-readonly.test.js`, `core/test/details-drawer-refusal-line.test.js`, `core/test/details-overview-readonly.test.js`, `core/test/details-group-overview-readonly.test.js`. The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). - The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone. - What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws `.utilities` — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]` and the face-room `@` filter. `installHeaderEffects` (`src/client/header-effects.js`) is installed once from `apply` and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while `beginVisit`/`endVisit` keyed on more than the Session would open a second visit for a chat that was only just opened. `test/header-effects.test.js` drives those transitions on jsdom; `test/header-slot.test.js` pins that no such effect runs in `HeaderPill` and that FlyBot claims no other header seat. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c860fbfd..8e14dc44 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -24132,7 +24132,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,87 +11367,203 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,87 +11367,207 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -24217,7 +24217,6 @@ diff --git a/client.js b/client.js + locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "approve") }, t("Approve")), + locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-mini", disabled: busy === item.id, onClick: () => decide(item, "reject") }, t("Reject")) + )), -+ locked ? (0, import_react44.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + error ? (0, import_react44.createElement)("div", { className: "bt-error", role: "alert" }, error) : null + ); +} @@ -24255,9 +24254,14 @@ diff --git a/client.js b/client.js + if (mine) void actions.updateRoom(room2.id, { removeParticipants: [{ kind: "bot", id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }); + }; + const face = room2.origin === "face-to-face"; ++ const locked = lockedWindow(roster); return [ - room2.notice ? (0, import_react31.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react31.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), - (0, import_react31.createElement)( ++ // P-544: the group overview's writes are gone in a locked window, so it says why once, above ++ // the reads it still draws. The approval card used to carry its own copy (#2381); one page, ++ // one sentence. ++ locked ? (0, import_react44.createElement)("div", { key: "read-only", className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : null, + (0, import_react44.createElement)(FlyBotRoomOverview, { key: "plugin-overview", room: room2, roster, actions }), + invited && room2.sharedFrom && !face ? (0, import_react44.createElement)( + "div", @@ -24265,7 +24269,7 @@ diff --git a/client.js b/client.js + (0, import_react44.createElement)("div", { className: "bt-section-title" }, title), + (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Your Bot was invited to this room by {name}", { name: room2.sharedFrom.inviterName })), + room2.sharedFrom.invitedAt ? (0, import_react44.createElement)("div", { className: "bt-set-hint" }, t("Invited {when}", { when: new Date(room2.sharedFrom.invitedAt).toLocaleString() })) : null, -+ (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) ++ locked ? null : (0, import_react44.createElement)("button", { type: "button", className: "bt-more", onClick: leaveMine }, t("Remove my Bot")) + ) : face ? null : room2.notice ? (0, import_react44.createElement)(About, { key: "notice", title: "Notice", text: room2.notice }) : (0, import_react44.createElement)("button", { key: "notice", type: "button", className: "bt-more", onClick: openSettings }, t("Add a notice in group settings")), + face ? null : (0, import_react44.createElement)(SharedRoomApprovals, { key: "approvals", room: room2, roster }), + face && (room2.face?.status === "candidate" || room2.face?.status === "joining") ? null : face ? (0, import_react44.createElement)(FaceMembers, { key: "members", room: room2, roster, actions }) : (0, import_react44.createElement)( @@ -24490,7 +24494,7 @@ diff --git a/client.js b/client.js - bot ? (0, import_react31.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react31.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), - editing ? (0, import_react31.createElement)("input", { + bot ? (0, import_react44.createElement)(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== void 0), main: isMainBot, badge: false, gaze: true, pokeable: true }) : (0, import_react44.createElement)(RoomAvatar, { room: room2, roster, size: 64 }), -+ bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : editing ? (0, import_react44.createElement)("input", { ++ bot?.remote ? (0, import_react44.createElement)("div", { className: "bt-drawer-name" }, current) : bot && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : room2?.origin === "face-to-face" ? (0, import_react44.createElement)("div", { className: "bt-drawer-name bt-drawer-name-face", title: current }, current) : room2 && lockedWindow(roster) ? (0, import_react44.createElement)("div", { className: "bt-drawer-name", title: current }, current) : editing ? (0, import_react44.createElement)("input", { className: "bt-drawer-name-input", autoFocus: true, value: draftName, diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 6dbdc269..b86e81c8 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -145,7 +145,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js const TAB_LABELS = { details: 'Details', browser: 'Browser' } -@@ -113,16 +113,119 @@ function About({ text, title = 'About' }) { +@@ -113,16 +113,121 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. @@ -170,8 +170,8 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + if ((room.viewerRole !== 'invited-owner' && !face) || !items.length) return null + // A decision is a write, so a read-only window cannot make one: the host would answer + // `bot/read-only` (or `bot/blocked-shared` in a room with several members) after the click. -+ // The card keeps its headline, count and rows; the two buttons give way to one line carrying the -+ // host's own sentence, the same shape the drawer's Settings view uses (#2381). ++ // The card keeps its headline, count and rows; its two buttons are gone, and the overview's one ++ // read-only sentence above explains why (#2381, P-544). + const locked = lockedWindow(roster) + const decide = async (item, decision) => { + setBusy(item.id) @@ -195,9 +195,6 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + locked + ? null + : h('button', { type: 'button', className: 'bt-mini', disabled: busy === item.id, onClick: () => decide(item, 'reject') }, t('Reject')))), -+ locked -+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) -+ : null, + error ? h('div', { className: 'bt-error', role: 'alert' }, error) : null) +} + @@ -229,6 +226,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + if (mine) void actions.updateRoom(room.id, { removeParticipants: [{ kind: 'bot', id: mine.handle || mine.id, remote: Boolean(mine.handle) }] }) + } + const face = room.origin === 'face-to-face' ++ const locked = lockedWindow(roster) return [ - room.notice - ? h(About, { key: 'notice', title: 'Notice', text: room.notice }) @@ -236,13 +234,17 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - h('div', { key: 'members' }, - h('div', { className: 'bt-section-title' }, t('Members · {count}', { count: members.length })), - h('div', { className: 'bt-members', role: 'list' }, members.map((member) => { ++ // P-544: the group overview's writes are gone in a locked window, so it says why once, above ++ // the reads it still draws. The approval card used to carry its own copy (#2381); one page, ++ // one sentence. ++ locked ? h('div', { key: 'read-only', className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)) : null, + h(FlyBotRoomOverview, { key: 'plugin-overview', room, roster, actions }), + invited && room.sharedFrom && !face + ? h('div', { key: 'from', className: 'bt-shared-from' }, + h('div', { className: 'bt-section-title' }, title), + h('div', { className: 'bt-set-hint' }, t('Your Bot was invited to this room by {name}', { name: room.sharedFrom.inviterName })), + room.sharedFrom.invitedAt ? h('div', { className: 'bt-set-hint' }, t('Invited {when}', { when: new Date(room.sharedFrom.invitedAt).toLocaleString() })) : null, -+ h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) ++ locked ? null : h('button', { type: 'button', className: 'bt-more', onClick: leaveMine }, t('Remove my Bot'))) + : face + ? null + : room.notice @@ -359,7 +361,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js hasStrip ? null : h('button', { type: 'button', className: 'bt-round', 'aria-label': t('Close details'), onClick: close }, h(CloseIcon))) if (!bot && !room) { return [h(Fragment, { key: 'top' }, top), -@@ -213,28 +213,48 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro +@@ -213,28 +213,50 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro h('div', { key: 'id', className: 'bt-drawer-id', 'data-move': side }, bot ? h(BotAvatar, { bot, size: 64, state: botState(running, doing, roster.questions?.[part] !== undefined), main: isMainBot, badge: false, gaze: true, pokeable: true }) : h(RoomAvatar, { room, roster, size: 64 }), @@ -375,13 +377,15 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js - h('div', { key: 'tabs', ref: glide.box, className: 'bt-tabs', role: 'tablist', 'data-move': side }, + bot?.remote + ? h('div', { className: 'bt-drawer-name' }, current) -+ // P-542/P-487: a locked window refuses a rename like any other write, so the name stays as -+ // plain text instead of a control that can only be turned down. Bot overview only: a group's -+ // overview still holds its member entries, so it is left exactly as it was (P-544). ++ // P-542/P-487/P-544: a locked window refuses a rename like any other write, so the name ++ // stays as plain text instead of a control that can only be turned down — for a group as ++ // well as for a Bot. + : bot && lockedWindow(roster) + ? h('div', { className: 'bt-drawer-name', title: current }, current) + : room?.origin === 'face-to-face' + ? h('div', { className: 'bt-drawer-name bt-drawer-name-face', title: current }, current) ++ : room && lockedWindow(roster) ++ ? h('div', { className: 'bt-drawer-name', title: current }, current) + : editing + ? h('input', { + className: 'bt-drawer-name-input', autoFocus: true, value: draftName, 'aria-label': bot ? t('Bot name') : t('Group name'), diff --git a/patches/ds-bot/files/test/details-group-overview-readonly.test.js b/patches/ds-bot/files/test/details-group-overview-readonly.test.js new file mode 100644 index 00000000..a26bb2f0 --- /dev/null +++ b/patches/ds-bot/files/test/details-group-overview-readonly.test.js @@ -0,0 +1,177 @@ +// P-544: a read-only window's *group* overview still drew the rename control, left the name a +// button, and (for an invited share) offered `Remove my Bot` — every one of them a write the host +// answers with `bot/read-only`. It also said nothing about why. #2391 fixed the Bot overview and +// deliberately left this one alone; this is the group half. +// +// The drawing harness is the one `details-overview-readonly.test.js` uses: the real bundle, React +// and the host replaced by stubs, hook slots that survive a re-render, and no jsdom. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +const { outputFiles } = await build({ + stdin: { + contents: "export { DetailsDrawer } from './src/client/details-drawer.js'", + resolveDir: fileURLToPath(new URL('..', import.meta.url)), + }, + bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], +}) + +const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } }) +let slots = [] +let index = 0 +let effects = [] +const react = { + createElement: h, Component: class {}, Fragment: Symbol('F'), + useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] }, + useState: (initial) => { + const at = index++ + if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial + return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }] + }, + useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] }, + useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] }, + useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } }, + useLayoutEffect() {}, + useSyncExternalStore: (_subscribe, get) => get(), +} +const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) } +const module = { exports: {} } +runInNewContext(outputFiles[0].text, { + module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en' }, + localStorage: { getItem: () => null, setItem() {} }, + window: win, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return {} + throw new Error(`Unexpected platform module ${name}`) + }, +}) +const { DetailsDrawer } = 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.' +const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' } +const room = { + id: 'r1', name: 'Team room', origin: 'group', named: true, members: ['a'], admin: 'a', + participants: [], presentedParticipants: [], pinned: false, hidden: false, notice: '', + workDir: '', mode: 'everyone', viewerRole: 'owner', +} +const invited = { ...room, viewerRole: 'invited-owner', pendingCount: 0, sharedFrom: { inviterName: 'Chris', invitedAt: 0 } } +const pending = { ...invited, pendingCount: 1, pendingApprovals: [{ id: 'a1', text: 'Which file?' }] } +const base = { + bots: [bot], byId: { a: bot }, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, + exchangeTurns: {}, questions: {}, self: { memberId: 'm1' }, +} +const rosterFor = (target, readOnly) => ({ + ...base, rooms: [target], roomsById: { [target.id]: target }, + ...(readOnly === undefined ? {} : { readOnly }), +}) +const actions = { + openSettings() {}, closeSettings() {}, closeOverlay() {}, openSession() {}, openBrowser() {}, + notify() {}, reportError() {}, setFlags: () => Promise.resolve(), + updateBot: () => Promise.resolve(), updateRoom: () => Promise.resolve(), + schedules: () => Promise.resolve({ available: true, tasks: [] }), +} +const render = (roster, acts = actions) => { + slots = [] + index = 0 + effects = [] + const map = () => new Map() + return DetailsDrawer({ + sessionId: 'r1', startTab: 'details', hasStrip: false, roster, actions: acts, + useSessions: selector => selector({ byId: {}, projectionsBySession: {} }), useSessionStatus: selector => selector(map()), + useActivity: selector => selector(map()), useWork: selector => selector(map()), + useBrowserTabs: selector => selector(map()), useBrowserPages: selector => selector(map()), + }) +} +// Real React draws a function component when it meets one; this expands the same way, with the hook +// slots above assigned in tree order. +const expand = (node) => { + if (Array.isArray(node)) return node.map(expand) + if (!node || typeof node !== 'object') return node + const props = node.props ?? {} + if (typeof node.type === 'function') return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +const flatten = (node, ancestors = [], out = []) => { + if (Array.isArray(node)) { for (const child of node) flatten(child, ancestors, out); return out } + if (!node || typeof node !== 'object') return out + out.push({ node, ancestors }) + flatten(node.props?.children, [...ancestors, node], out) + return out +} +const tree = (roster) => flatten(expand(render(roster))) +const ownText = (node) => { + const walk = (value) => { + if (Array.isArray(value)) return value.map(walk).join('') + if (typeof value === 'string' || typeof value === 'number') return String(value) + if (value && typeof value === 'object') return walk(value.props?.children) + return '' + } + return walk(node?.props?.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const bodyOf = entries => entries.find(({ node }) => node.props?.className === 'bt-drawer-body') +const renameButton = entries => entries.find(({ node }) => node.type === 'button' && hasClass(node, 'bt-drawer-name')) +const plainName = entries => entries.find(({ node }) => node.type === 'div' && hasClass(node, 'bt-drawer-name') && ownText(node).trim() === 'Team room') +const removeButton = entries => entries.find(({ node }) => node.type === 'button' && ownText(node).trim() === 'Remove my Bot') +const hintEntries = entries => entries.filter(({ node }) => hasClass(node, 'bt-read-only')) +const statusLine = entries => entries.filter(({ node }) => node.props?.role === 'status' && ownText(node).trim() === SENTENCE) +const buttonLabels = entries => entries.filter(({ node }) => node.type === 'button').map(({ node }) => ownText(node).trim()) +const inBody = entries => { + const box = bodyOf(entries) + return box ? entries.filter(entry => entry.ancestors.includes(box.node)) : [] +} + +test('P-544: a read-only group overview says why once and draws no rename control', () => { + const locked = tree(rosterFor(room, SENTENCE)) + const lines = statusLine(locked) + assert.equal(lines.length, 1, "the host's sentence is drawn once") + assert.ok(hasClass(lines[0].node, 'bt-read-only'), 'the sentence is the read-only hint') + const name = plainName(locked)?.node + assert.ok(name, 'the name is still drawn, as plain text') + assert.equal(name.props.title, 'Team room', 'the plain name keeps the full text as its title') + assert.deepEqual(renameButton(locked), undefined, 'pressing the name can no longer open the rename field') + const labels = buttonLabels(inBody(locked)) + for (const write of ['Pin', 'Hide from sidebar', 'Remove my Bot']) { + assert.equal(labels.includes(write), false, `${write} is not drawn in a read-only group overview`) + } +}) + +test('P-544: a read-only invited group draws no Remove my Bot and keeps the invitation text', () => { + const locked = tree(rosterFor(invited, SENTENCE)) + assert.deepEqual(removeButton(locked), undefined, 'Remove my Bot is a write the host would refuse') + assert.match(ownText(bodyOf(locked).node), /invited to this room by Chris/, 'the invitation itself is a read and stays') + assert.equal(statusLine(locked).length, 1, "the host's sentence is drawn once") +}) + +test('P-544: a writable group keeps the rename control, Remove my Bot and draws no sentence', () => { + const open = tree(rosterFor(invited, undefined)) + assert.ok(renameButton(open), 'a writable group keeps the rename control') + assert.ok(removeButton(open), 'a writable invited group keeps Remove my Bot') + assert.deepEqual(statusLine(open), [], 'no read-only sentence is drawn') + for (const roster of [rosterFor(invited, ''), rosterFor(invited, ' ')]) { + const blank = tree(roster) + assert.ok(renameButton(blank), 'a blank read-only string is writable, as before') + assert.ok(removeButton(blank), 'a blank read-only string keeps Remove my Bot') + assert.deepEqual(statusLine(blank), [], 'a blank read-only string draws no sentence') + } +}) + +test('P-544: a read-only group with a pending approval still says the host sentence exactly once', () => { + const locked = tree(rosterFor(pending, SENTENCE)) + const lines = hintEntries(locked) + assert.equal(lines.length, 1, 'one read-only line on the page, not one per card') + assert.equal(ownText(lines[0].node).trim(), SENTENCE, "the line is the host's own sentence") + assert.equal(lines[0].ancestors.some(node => hasClass(node, 'bt-shared-wait')), false, + 'the page sentence is drawn above the approval card, not repeated inside it') + assert.match(ownText(bodyOf(locked).node), /Which file\?/, 'the approval is still readable') + const decisions = buttonLabels(locked).filter(label => label === 'Approve' || label === 'Reject') + assert.deepEqual(decisions, [], 'a read-only window still offers neither decision') +}) diff --git a/patches/ds-bot/files/test/details-overview-readonly.test.js b/patches/ds-bot/files/test/details-overview-readonly.test.js index b1897277..422f3eb6 100644 --- a/patches/ds-bot/files/test/details-overview-readonly.test.js +++ b/patches/ds-bot/files/test/details-overview-readonly.test.js @@ -69,8 +69,8 @@ const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', i const permissions = { defaultPreset: 'workspace-write', options: [{ value: 'read-only', name: 'Read only' }, { value: 'workspace-write', name: 'Workspace' }, { value: 'danger-full-access', name: 'Full access' }] } const base = { bots: [bot], rooms: [], byId: { a: bot }, roomsById: {}, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false, exchangeTurns: {}, questions: {}, permissions } const rosterWith = (readOnly) => (readOnly === undefined ? { ...base } : { ...base, readOnly }) -// The group overview this pull request deliberately does not touch (P-544 owns it); it is drawn -// here so the test can say its shape did not change. +// The group overview #2391 left alone and P-544 now owns; it is drawn here so the Bot overview's +// own shape can be told apart from the group's. const room = { id: 'r1', name: 'Team room', origin: 'group', named: true, members: ['a'], admin: 'a', participants: [], presentedParticipants: [], pinned: false, hidden: false, notice: '', @@ -187,14 +187,16 @@ test('#2391: a writable window keeps the overview\'s write controls and draws no } }) -// P-544 owns the group overview's read-only shape. This pull request claims the Bot overview only, so -// the group drawer is expected to keep exactly the shape it has on the base commit: the rename control -// still drawn, no read-only sentence added. Written down here so a later change to it is deliberate. -test('#2391: the group overview is left as it was — this pull request claims the Bot overview only', () => { +// P-544: the group overview took the same shape as the Bot overview — one sentence in the host's +// words, the name as plain text, no rename control. +test('P-544: a read-only group overview says why once and draws the name as plain text', () => { slots = [] const entries = tree(roomRosterWith(SENTENCE), actions, 'r1') - assert.ok(renameButton(entries), 'a group keeps the rename control the base commit draws') - assert.deepEqual(statusLine(entries), [], 'no read-only sentence is added to the group overview') + assert.equal(statusLine(entries).length, 1, "the host's sentence is drawn once") + assert.deepEqual(renameButton(entries), undefined, 'the name is not a rename control in a locked window') + const plain = nodes(entries).find(node => node.type === 'div' && hasClass(node, 'bt-drawer-name') && ownText(node).trim() === 'Team room') + assert.ok(plain, 'the name is still drawn, as plain text') + assert.equal(plain.props.title, 'Team room', 'the plain name keeps the full text as its title') assert.deepEqual(modelTriggers(entries), [], 'a group never had a model menu') assert.deepEqual(permissionRows(entries), [], 'a group never had permission presets') }) diff --git a/patches/ds-bot/files/test/room-approvals-readonly.test.js b/patches/ds-bot/files/test/room-approvals-readonly.test.js index 52076cef..baeace08 100644 --- a/patches/ds-bot/files/test/room-approvals-readonly.test.js +++ b/patches/ds-bot/files/test/room-approvals-readonly.test.js @@ -5,7 +5,9 @@ // `try/finally` with no `catch`, so the card said nothing at all. // The drawer's own Settings view already asks `lockedWindow(roster)` and prints the host's // sentence (`details-drawer.js:270`); this card never did. It now does, and a refused decision -// writes one `role="alert"` line instead of vanishing. +// writes one `role="alert"` line instead of vanishing. P-544 later moved the one read-only +// sentence to the top of the group overview, so this file asserts the page still carries it once +// while the card keeps its rows and drops its decisions. // React is replaced by the same tiny renderer `approvals-readonly.test.js` uses (no jsdom here); // the host call is replaced too, so "the click was refused and the card said why" is measurable. import assert from 'node:assert/strict' @@ -154,10 +156,10 @@ test('#2381: a read-only window draws neither Approve nor Reject on the room app assert.match(shown, /Which file\?/) }) -test('#2381: the read-only room card carries the host sentence in one status line', () => { +test('#2381/P-544: a read-only group overview carries the host sentence in one status line', () => { const drawn = render(drawer(locked)) const lines = hints(drawn) - assert.equal(lines.length, 1, 'one read-only line on the card') + assert.equal(lines.length, 1, 'one read-only line on the page') assert.equal(lines[0].props.role, 'status', 'the line is announced') assert.equal(text(lines[0].props?.children), LOCK, "the line is the host's own sentence") // A writable window draws no line and keeps both decisions: the zero above is the lock, not a