diff --git a/changelog.d/2512-face-lead-readonly.fixed.md b/changelog.d/2512-face-lead-readonly.fixed.md new file mode 100644 index 000000000..c721a99cd --- /dev/null +++ b/changelog.d/2512-face-lead-readonly.fixed.md @@ -0,0 +1 @@ +A read-only window's face-to-face room lead card no longer offers **Change**: `social/face-lead` is a write the host answers `bot/read-only`, so the card draws the lead's name and its own sentence only — no picker, no **Use the default lead**, and not the "Only the person who created this room can change it." hint — while a writable window keeps all of them. diff --git a/core/client.js b/core/client.js index bc30c8c0b..eb69f5064 100644 --- a/core/client.js +++ b/core/client.js @@ -15228,7 +15228,8 @@ function FaceLead({ room: room2, roster, pick: pick2 }) { const seats = faceSeats(room2, roster); const current = seats.find((seat) => isLeadSeat(seat, lead?.handle)); if (!current) return null; - const canPick = lead.canPick === true && typeof pick2 === "function"; + const locked = lockedWindow(roster); + const canPick = !locked && lead.canPick === true && typeof pick2 === "function"; const choose = async (handle) => { setBusy(true); setError(""); @@ -15263,7 +15264,7 @@ function FaceLead({ room: room2, roster, pick: pick2 }) { seat === current ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) )) : null, open && canPick && lead.picked ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null, - canPick || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + canPick || locked || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), error ? (0, import_react31.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null ); } diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 551c33d48..dca0a97cf 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -109,7 +109,13 @@ export function FaceLead({ room, roster, pick }) { const seats = faceSeats(room, roster) const current = seats.find(seat => isLeadSeat(seat, lead?.handle)) if (!current) return null - const canPick = lead.canPick === true && typeof pick === 'function' + // `social/face-lead` is not registered `{ read: true }` (`plugins/social/src/social.js`), so a + // read-only window refuses it — and the room's creator still arrives with `canPick: true`, because + // the host knows who created the room, not that this window cannot write. The card reads the window + // too: the lead's name and its sentence stay, the button and its picker go, and so does the + // "only the person who created this room" hint, since nobody in this window can change the lead. + const locked = lockedWindow(roster) + const canPick = !locked && lead.canPick === true && typeof pick === 'function' const choose = async (handle) => { setBusy(true) setError('') @@ -133,7 +139,7 @@ export function FaceLead({ room, roster, pick }) { ? h('span', { className: 'bt-tag' }, t('Lead')) : h('button', { type: 'button', className: 'bt-mini', disabled: busy, onClick: () => void choose(seat.record?.handle) }, t('Make lead')))) : null, open && canPick && lead.picked ? h('button', { type: 'button', className: 'bt-more', disabled: busy, onClick: () => void choose(null) }, t('Use the default lead')) : null, - canPick || !lead.supported ? null : h('div', { className: 'bt-set-hint' }, t('Only the person who created this room can change it.')), + canPick || locked || !lead.supported ? null : h('div', { className: 'bt-set-hint' }, t('Only the person who created this room can change it.')), error ? h('div', { className: 'bt-set-hint', role: 'alert' }, error) : null) } diff --git a/core/test/details-face-lead-readonly.test.js b/core/test/details-face-lead-readonly.test.js new file mode 100644 index 000000000..fa23603ed --- /dev/null +++ b/core/test/details-face-lead-readonly.test.js @@ -0,0 +1,187 @@ +// A read-only window's face-to-face Drawer still drew the Room lead card's `Change` control +// (`group-settings.js` `FaceLead`). The card reaches such a window on purpose — the lead, its +// name and the sentence about it are reads — but `Change` opens the seat picker and `Make lead` +// calls `social/face-lead`, a write the host answers with `bot/read-only` (`plugins/social` +// `social/face-lead` is not `{ read: true }`), and the room's own creator keeps `canPick: true` in +// that window because the host still knows who created the room. So the card could only offer a +// control that earns a refusal. +// A locked window now draws the lead's name and its one sentence and nothing else: no `Change`, +// no picker, and not the "Only the person who created this room can change it." hint either, since +// nobody in that window can change it. A writable window keeps all three. +// The harness is `room-approvals-readonly.test.js`'s: React and the host call replaced by stubs, +// hook slots that survive a re-render, and no jsdom. +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +reactStub.useRef = (value) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } + return inst.hooks[i] +} +reactStub.useMemo = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn() } + return inst.hooks[i].value +} +reactStub.useCallback = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn } + return inst.hooks[i].value +} +for (const name of ['useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +// Every host call the page makes, in order: `[endpoint, payload]`. +const hostCalls = [] +let hostAnswer = () => Promise.resolve([]) +globalThis.__hostCall = (endpoint, payload) => { hostCalls.push([endpoint, JSON.parse(JSON.stringify(payload ?? null))]); return hostAnswer(endpoint, payload) } +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + if (specifier === 'react-dom') return { url: 'data:text/javascript,export const createPortal = node => node', shortCircuit: true } + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return { url: 'data:text/javascript,export const MarkdownText = node => node;export const FishLogo = node => node', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +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 WHY = 'Only the person who created this room can change it.' +const host = { id: 'chief', name: 'Chief', ownerId: 'm1', sessionId: 's1' } +// Two people, one Bot each: my own Chief (Ann) and a friend's Writer (Bob), which is the room's +// shape in `settings-readonly-pages.test.js`. +const faceRoom = { + id: 'face', name: 'Face-to-face · Ann, Bob · 482913', origin: 'face-to-face', named: false, + members: [], mode: 'everyone', workDir: '/work/face', notice: '', admin: '', + participants: [ + { kind: 'bot', id: 'chief', handle: '@chief.ann.home', ownerName: 'Ann', ownerHome: 'ann.home' }, + { kind: 'bot', id: 'writer-bob', remote: true, handle: '@writer.bob.home', ownerName: 'Bob', ownerHome: 'bob.home', displayName: 'Writer' }, + ], + face: { lead: { handle: '@chief.ann.home', picked: true, canPick: true, supported: true } }, +} +const base = { + self: { memberId: 'm1', displayName: 'Ann' }, bots: [host], byId: { chief: host }, rooms: [faceRoom], roomsById: { face: faceRoom }, + readOnly: '', exchangeTurns: {}, prefs: {}, mainBotIds: [], connectors: [], memory: false, questions: {}, +} +const writable = base +const locked = { ...base, readOnly: SENTENCE } +const notCreator = (roster) => ({ ...roster, roomsById: { face: { ...faceRoom, face: { lead: { ...faceRoom.face.lead, canPick: false } } } }, rooms: [] }) + +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 map = () => new Map() +const fakeList = { byId: {}, projectionsBySession: {} } +const drawer = (roster) => ({ + hooks: {}, effects: [], dirty: false, + props: { + sessionId: 'face', startTab: 'details', hasStrip: false, roster, actions, + useSessions: (pick) => pick(fakeList), useSessionStatus: (pick) => pick(map()), + useActivity: (pick) => pick(map()), useWork: (pick) => pick(map()), + useBrowserTabs: (pick) => pick(map()), useBrowserPages: (pick) => pick(map()), + }, +}) +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' && !node.type.prototype?.render) return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +let DetailsDrawer +const render = (inst) => { + live.inst = inst + live.idx = 0 + inst.effects = [] + return expand(DetailsDrawer(inst.props)) +} +const nodes = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) nodes(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + nodes(node.props?.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.props?.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const button = (tree, label) => nodes(tree).find(node => node.type === 'button' && text(node.props?.children).trim() === label) +const buttonLabels = tree => nodes(tree).filter(node => node.type === 'button').map(node => text(node.props?.children).trim()) +const leadCard = tree => nodes(tree).find(node => hasClass(node, 'bt-face-lead')) + +test.before(async () => { + ;({ DetailsDrawer } = await import('../src/client/details-drawer.js')) +}) + +test('a read-only window draws the room lead as text: no Change, no picker, no refusal hint', () => { + hostCalls.length = 0 + const drawn = render(drawer(locked)) + const card = leadCard(drawn) + assert.ok(card, 'the Room lead card is still drawn: the lead is a read') + const shown = text(card) + assert.match(shown, /Room lead/, 'the card keeps its title') + assert.match(shown, /Chief \(Ann\)/, "the card names the lead, as the room labels it") + assert.match(shown, /Writes the plan and gives each Bot its part\./, 'and keeps the sentence about what the lead does') + assert.deepEqual(buttonLabels(card), [], 'the card itself offers no control') + assert.equal(button(card, 'Change'), undefined, 'Change is a write the host would refuse') + assert.equal(shown.includes(WHY), false, 'a locked window does not say who *can* change a lead nobody there can change') + assert.deepEqual(hostCalls, [], 'drawing the card reached the host with nothing') +}) + +test('a read-only window draws no Change for the creator and no refusal hint for anyone else', () => { + const mine = render(drawer(locked)) + assert.equal(button(mine, 'Change'), undefined, 'the creator cannot change the lead in a read-only window either') + const theirs = render(drawer(notCreator(locked))) + assert.equal(button(theirs, 'Change'), undefined, 'nor is Change drawn for someone who never could') + assert.equal(text(theirs).includes(WHY), false, 'the "only the person who created this room" hint is not the locked sentence') + assert.match(text(theirs), /Chief \(Ann\)/, 'the lead is still named') +}) + +test('a writable window keeps Change, the picker and exactly one social/face-lead write', async () => { + hostCalls.length = 0 + const inst = drawer(writable) + const open = render(inst) + const change = button(open, 'Change') + assert.ok(change, 'a writable window keeps Change') + await change.props.onClick() + const picked = render(inst) + const make = button(picked, 'Make lead') + assert.ok(make, 'pressing Change lists the other seat with Make lead') + await make.props.onClick() + assert.deepEqual(hostCalls, [['social/face-lead', { roomId: 'face', bot: '@writer.bob.home' }]], + 'one press is exactly one social/face-lead for the seat that was chosen') + const others = render(drawer(notCreator(writable))) + assert.equal(button(others, 'Change'), undefined, 'someone who is not the creator never sees Change') + assert.match(text(others), /Only the person who created this room can change it\./, 'and is told why, as before') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b7ae613b7..29ee0973a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -100,7 +100,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Face-to-face room lead - A local group's **Members · N** card (`group-settings.js`) offers **Make task lead** / **Use the admin** on each of this home's Bots (#1182). The picked Bot is the room's task lead (`room.taskLead`); the tag reads **Task lead**, and the note says the admin divides the board when none is picked. Face rooms and shared rooms do not show it. The card ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` / `room-invite-seat` (#876, #1064, the social plugin's **Invite by link**; a claim the host could not finish is named there, and **Seat them** is in the same dictionary). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog, including **Enter 6 digits** under a partial room number, #1052), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the **Shared** tag's title (#892). In a room projected from another home the Members rows carry what that side could confirm (#904): a Bot learned from the host's roster reads **Unconfirmed** until this home holds its own fact about it, one with no Bot Social identity reads **Not connected**, and the remove **×** is not offered there — the host's membership is changed on the host's side. -- The face room's details (`FaceMembers` in `details-drawer.js`) open with `FaceLead` (`group-settings.js`, #476): the current lead as `Bot (person)`, *Default* until the creator picked one. Only when `room.face.lead.canPick` (this home made the room and Bot Social stores a lead) does it show **Change**, a **Make lead** button per Bot of every home and **Use the default lead**; each calls `social/face-lead`. Everyone else sees it read-only. `FacePersonRow` marks the lead's Bot with a *Lead* tag (`isLeadSeat`, by handle). Never a chat command. +- The face room's details (`FaceMembers` in `details-drawer.js`) open with `FaceLead` (`group-settings.js`, #476): the current lead as `Bot (person)`, *Default* until the creator picked one. Only when `room.face.lead.canPick` (this home made the room and Bot Social stores a lead) does it show **Change**, a **Make lead** button per Bot of every home and **Use the default lead**; each calls `social/face-lead`. Everyone else sees it read-only. `FacePersonRow` marks the lead's Bot with a *Lead* tag (`isLeadSeat`, by handle). Never a chat command. A window that holds the team read-only draws the lead's name and its own sentence and nothing else: `lockedWindow(roster)` turns `canPick` off even for the room's creator, because the host still reports `canPick: true` for them while refusing `social/face-lead`, so neither **Change** nor its picker nor **Use the default lead** is drawn, and the "Only the person who created this room can change it." hint goes too, since nobody in that window can change the lead; a writable window is unchanged. Tests: `core/test/details-face-lead-readonly.test.js`. ### Room avatars in face-to-face chats diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 5b00bbb85..e42ee7aa5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -17057,7 +17057,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,1401 +6759,1907 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,1401 +6759,1908 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -19334,7 +19334,8 @@ diff --git a/client.js b/client.js + const seats = faceSeats(room2, roster); + const current = seats.find((seat) => isLeadSeat(seat, lead?.handle)); + if (!current) return null; -+ const canPick = lead.canPick === true && typeof pick2 === "function"; ++ const locked = lockedWindow(roster); ++ const canPick = !locked && lead.canPick === true && typeof pick2 === "function"; + const choose = async (handle) => { + setBusy(true); + setError(""); @@ -19372,7 +19373,7 @@ diff --git a/client.js b/client.js + seat === current ? (0, import_react31.createElement)("span", { className: "bt-tag" }, t("Lead")) : (0, import_react31.createElement)("button", { type: "button", className: "bt-mini", disabled: busy, onClick: () => void choose(seat.record?.handle) }, t("Make lead")) + )) : null, + open && canPick && lead.picked ? (0, import_react31.createElement)("button", { type: "button", className: "bt-more", disabled: busy, onClick: () => void choose(null) }, t("Use the default lead")) : null, -+ canPick || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), ++ canPick || locked || !lead.supported ? null : (0, import_react31.createElement)("div", { className: "bt-set-hint" }, t("Only the person who created this room can change it.")), + error ? (0, import_react31.createElement)("div", { className: "bt-set-hint", role: "alert" }, error) : null ); } diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 36779d80a..659e15809 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -509,7 +509,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js export const ROOM_MODES = [ ['admin', 'Admin leads', 'Plain messages go to the admin, who calls others with @Name.'], -@@ -15,9 +15,136 @@ export const ROOM_MODES = [ +@@ -15,9 +15,142 @@ export const ROOM_MODES = [ // Without an admin the host answers as everyone, whatever the stored mode says. export const shownMode = (room, roster) => (roster.byId[room.admin] ? room.mode : 'everyone') @@ -602,7 +602,13 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + const seats = faceSeats(room, roster) + const current = seats.find(seat => isLeadSeat(seat, lead?.handle)) + if (!current) return null -+ const canPick = lead.canPick === true && typeof pick === 'function' ++ // `social/face-lead` is not registered `{ read: true }` (`plugins/social/src/social.js`), so a ++ // read-only window refuses it — and the room's creator still arrives with `canPick: true`, because ++ // the host knows who created the room, not that this window cannot write. The card reads the window ++ // too: the lead's name and its sentence stay, the button and its picker go, and so does the ++ // "only the person who created this room" hint, since nobody in this window can change the lead. ++ const locked = lockedWindow(roster) ++ const canPick = !locked && lead.canPick === true && typeof pick === 'function' + const choose = async (handle) => { + setBusy(true) + setError('') @@ -626,7 +632,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + ? h('span', { className: 'bt-tag' }, t('Lead')) + : h('button', { type: 'button', className: 'bt-mini', disabled: busy, onClick: () => void choose(seat.record?.handle) }, t('Make lead')))) : null, + open && canPick && lead.picked ? h('button', { type: 'button', className: 'bt-more', disabled: busy, onClick: () => void choose(null) }, t('Use the default lead')) : null, -+ canPick || !lead.supported ? null : h('div', { className: 'bt-set-hint' }, t('Only the person who created this room can change it.')), ++ canPick || locked || !lead.supported ? null : h('div', { className: 'bt-set-hint' }, t('Only the person who created this room can change it.')), + error ? h('div', { className: 'bt-set-hint', role: 'alert' }, error) : null) +} + diff --git a/patches/ds-bot/files/test/details-face-lead-readonly.test.js b/patches/ds-bot/files/test/details-face-lead-readonly.test.js new file mode 100644 index 000000000..fa23603ed --- /dev/null +++ b/patches/ds-bot/files/test/details-face-lead-readonly.test.js @@ -0,0 +1,187 @@ +// A read-only window's face-to-face Drawer still drew the Room lead card's `Change` control +// (`group-settings.js` `FaceLead`). The card reaches such a window on purpose — the lead, its +// name and the sentence about it are reads — but `Change` opens the seat picker and `Make lead` +// calls `social/face-lead`, a write the host answers with `bot/read-only` (`plugins/social` +// `social/face-lead` is not `{ read: true }`), and the room's own creator keeps `canPick: true` in +// that window because the host still knows who created the room. So the card could only offer a +// control that earns a refusal. +// A locked window now draws the lead's name and its one sentence and nothing else: no `Change`, +// no picker, and not the "Only the person who created this room can change it." hint either, since +// nobody in that window can change it. A writable window keeps all three. +// The harness is `room-approvals-readonly.test.js`'s: React and the host call replaced by stubs, +// hook slots that survive a re-render, and no jsdom. +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: { ...(props ?? {}), children: children.flat(Infinity) } }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +reactStub.useRef = (value) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } + return inst.hooks[i] +} +reactStub.useMemo = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn() } + return inst.hooks[i].value +} +reactStub.useCallback = (fn) => { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: fn } + return inst.hooks[i].value +} +for (const name of ['useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +// Every host call the page makes, in order: `[endpoint, payload]`. +const hostCalls = [] +let hostAnswer = () => Promise.resolve([]) +globalThis.__hostCall = (endpoint, payload) => { hostCalls.push([endpoint, JSON.parse(JSON.stringify(payload ?? null))]); return hostAnswer(endpoint, payload) } +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + if (specifier === 'react-dom') return { url: 'data:text/javascript,export const createPortal = node => node', shortCircuit: true } + if (specifier === '@deepseek-ai/dsh-client-ui-primitives') return { url: 'data:text/javascript,export const MarkdownText = node => node;export const FishLogo = node => node', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +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 WHY = 'Only the person who created this room can change it.' +const host = { id: 'chief', name: 'Chief', ownerId: 'm1', sessionId: 's1' } +// Two people, one Bot each: my own Chief (Ann) and a friend's Writer (Bob), which is the room's +// shape in `settings-readonly-pages.test.js`. +const faceRoom = { + id: 'face', name: 'Face-to-face · Ann, Bob · 482913', origin: 'face-to-face', named: false, + members: [], mode: 'everyone', workDir: '/work/face', notice: '', admin: '', + participants: [ + { kind: 'bot', id: 'chief', handle: '@chief.ann.home', ownerName: 'Ann', ownerHome: 'ann.home' }, + { kind: 'bot', id: 'writer-bob', remote: true, handle: '@writer.bob.home', ownerName: 'Bob', ownerHome: 'bob.home', displayName: 'Writer' }, + ], + face: { lead: { handle: '@chief.ann.home', picked: true, canPick: true, supported: true } }, +} +const base = { + self: { memberId: 'm1', displayName: 'Ann' }, bots: [host], byId: { chief: host }, rooms: [faceRoom], roomsById: { face: faceRoom }, + readOnly: '', exchangeTurns: {}, prefs: {}, mainBotIds: [], connectors: [], memory: false, questions: {}, +} +const writable = base +const locked = { ...base, readOnly: SENTENCE } +const notCreator = (roster) => ({ ...roster, roomsById: { face: { ...faceRoom, face: { lead: { ...faceRoom.face.lead, canPick: false } } } }, rooms: [] }) + +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 map = () => new Map() +const fakeList = { byId: {}, projectionsBySession: {} } +const drawer = (roster) => ({ + hooks: {}, effects: [], dirty: false, + props: { + sessionId: 'face', startTab: 'details', hasStrip: false, roster, actions, + useSessions: (pick) => pick(fakeList), useSessionStatus: (pick) => pick(map()), + useActivity: (pick) => pick(map()), useWork: (pick) => pick(map()), + useBrowserTabs: (pick) => pick(map()), useBrowserPages: (pick) => pick(map()), + }, +}) +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' && !node.type.prototype?.render) return expand(node.type(props)) + return { ...node, props: { ...props, children: expand(props.children) } } +} +let DetailsDrawer +const render = (inst) => { + live.inst = inst + live.idx = 0 + inst.effects = [] + return expand(DetailsDrawer(inst.props)) +} +const nodes = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) nodes(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + nodes(node.props?.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.props?.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const button = (tree, label) => nodes(tree).find(node => node.type === 'button' && text(node.props?.children).trim() === label) +const buttonLabels = tree => nodes(tree).filter(node => node.type === 'button').map(node => text(node.props?.children).trim()) +const leadCard = tree => nodes(tree).find(node => hasClass(node, 'bt-face-lead')) + +test.before(async () => { + ;({ DetailsDrawer } = await import('../src/client/details-drawer.js')) +}) + +test('a read-only window draws the room lead as text: no Change, no picker, no refusal hint', () => { + hostCalls.length = 0 + const drawn = render(drawer(locked)) + const card = leadCard(drawn) + assert.ok(card, 'the Room lead card is still drawn: the lead is a read') + const shown = text(card) + assert.match(shown, /Room lead/, 'the card keeps its title') + assert.match(shown, /Chief \(Ann\)/, "the card names the lead, as the room labels it") + assert.match(shown, /Writes the plan and gives each Bot its part\./, 'and keeps the sentence about what the lead does') + assert.deepEqual(buttonLabels(card), [], 'the card itself offers no control') + assert.equal(button(card, 'Change'), undefined, 'Change is a write the host would refuse') + assert.equal(shown.includes(WHY), false, 'a locked window does not say who *can* change a lead nobody there can change') + assert.deepEqual(hostCalls, [], 'drawing the card reached the host with nothing') +}) + +test('a read-only window draws no Change for the creator and no refusal hint for anyone else', () => { + const mine = render(drawer(locked)) + assert.equal(button(mine, 'Change'), undefined, 'the creator cannot change the lead in a read-only window either') + const theirs = render(drawer(notCreator(locked))) + assert.equal(button(theirs, 'Change'), undefined, 'nor is Change drawn for someone who never could') + assert.equal(text(theirs).includes(WHY), false, 'the "only the person who created this room" hint is not the locked sentence') + assert.match(text(theirs), /Chief \(Ann\)/, 'the lead is still named') +}) + +test('a writable window keeps Change, the picker and exactly one social/face-lead write', async () => { + hostCalls.length = 0 + const inst = drawer(writable) + const open = render(inst) + const change = button(open, 'Change') + assert.ok(change, 'a writable window keeps Change') + await change.props.onClick() + const picked = render(inst) + const make = button(picked, 'Make lead') + assert.ok(make, 'pressing Change lists the other seat with Make lead') + await make.props.onClick() + assert.deepEqual(hostCalls, [['social/face-lead', { roomId: 'face', bot: '@writer.bob.home' }]], + 'one press is exactly one social/face-lead for the seat that was chosen') + const others = render(drawer(notCreator(writable))) + assert.equal(button(others, 'Change'), undefined, 'someone who is not the creator never sees Change') + assert.match(text(others), /Only the person who created this room can change it\./, 'and is told why, as before') +})