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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2512-face-lead-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 3 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down Expand Up @@ -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
);
}
Expand Down
10 changes: 8 additions & 2 deletions core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand All @@ -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)
}

Expand Down
187 changes: 187 additions & 0 deletions core/test/details-face-lead-readonly.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}),
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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
);
}
Expand Down
Loading
Loading