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/2492-face-room-readonly-facts.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window a face-to-face group's Settings page names each person's Bots under them, and states who answers and the room's working folder, instead of drawing every person as **Human** while its own header counted the Bots.
5 changes: 4 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -15278,6 +15278,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
const admin = roster.byId[room2.admin];
const mode = shownMode(room2, roster);
const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? "";
const modeLabel = ROOM_MODES.find(([id]) => id === mode)?.[1] ?? "Everyone";
if (lockedWindow(roster)) {
return (0, import_react31.createElement)(
"div",
Expand All @@ -15296,7 +15297,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
(0, import_react31.createElement)(
Card,
{ title: t("Members · {count}", { count: face ? people2.length : members.length + humans.length + remotes.length }) },
face ? people2.map((person) => (0, import_react31.createElement)(Row, { key: person.key, label: facePersonLabel(person), hint: t("Human") })) : [
face ? people2.map((person) => (0, import_react31.createElement)(FacePersonRow, { key: person.key, person, roster, lead: room2.face?.lead?.handle })) : [
// The stored name, cleaned by the same reader the friend's-Bot rows use (#2474).
humans.map((person) => (0, import_react31.createElement)(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t("Human") })),
members.map((member) => (0, import_react31.createElement)(Row, {
Expand All @@ -15307,6 +15308,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) {
remotes.map((person) => (0, import_react31.createElement)(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") }))
]
),
face ? (0, import_react31.createElement)(Card, { title: t("Who answers") }, (0, import_react31.createElement)(Row, { label: t(modeLabel), hint: t(modeHint) })) : null,
face ? (0, import_react31.createElement)(Card, { title: t("Working folder") }, (0, import_react31.createElement)(Row, { label: t("Folder"), hint: room2.workDir || t("Not set") })) : null,
room2.notice ? (0, import_react31.createElement)(Card, { title: t("Notice") }, (0, import_react31.createElement)(Row, { label: t("Group notice"), hint: room2.notice, stack: true })) : null
);
}
Expand Down
7 changes: 6 additions & 1 deletion core/src/client/group-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
const admin = roster.byId[room.admin]
const mode = shownMode(room, roster)
const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? ''
const modeLabel = ROOM_MODES.find(([id]) => id === mode)?.[1] ?? 'Everyone'
// Every control here writes `update-room` or `delete-room`, which the host refuses in a
// read-only window; the form then keeps the group's facts as text (#2474).
if (lockedWindow(roster)) {
Expand All @@ -193,7 +194,9 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
h('span', { className: 'bt-bs-sub' }, roomSubtitle(room, roster)))),
h(Card, { title: t('Members · {count}', { count: face ? people.length : members.length + humans.length + remotes.length }) },
face
? people.map(person => h(Row, { key: person.key, label: facePersonLabel(person), hint: t('Human') }))
// A face-to-face room names each person's Bots under them, the way the writable
// window does; without `onOpenBot` the row is text and offers no control (#2492).
? people.map(person => h(FacePersonRow, { key: person.key, person, roster, lead: room.face?.lead?.handle }))
: [
// The stored name, cleaned by the same reader the friend's-Bot rows use (#2474).
humans.map(person => h(Row, { key: person.id, label: remoteBotName(person, person.id), hint: t('Human') })),
Expand All @@ -204,6 +207,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) {
})),
remotes.map(person => h(Row, { key: person.handle || person.id, label: person.label || remoteBotTitle(person), hint: t("Friend's Bot") })),
]),
face ? h(Card, { title: t('Who answers') }, h(Row, { label: t(modeLabel), hint: t(modeHint) })) : null,
face ? h(Card, { title: t('Working folder') }, h(Row, { label: t('Folder'), hint: room.workDir || t('Not set') })) : null,
room.notice ? h(Card, { title: t('Notice') }, h(Row, { label: t('Group notice'), hint: room.notice, stack: true })) : null)
}
const update = patch => run(actions.updateRoom(room.id, patch))
Expand Down
50 changes: 50 additions & 0 deletions core/test/settings-readonly-pages.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ const baseRoster = {
}
const lockedRoster = { ...baseRoster, readOnly: SENTENCE }

// #2492: the same two windows, but a face-to-face room. Two people, one Bot each: my own Chief
// (Ann) and a friend's Writer (Bob). The page's own header already counts both Bots, so the
// members card has to name them too, and the room's two facts are the writable page's.
const faceRoom = {
id: 'face', name: 'Face-to-face · Ann, Bob · 482913', origin: 'face-to-face', named: false,
members: [], mode: 'everyone', workDir: '/work/face', notice: '',
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' },
],
}
const faceRoster = { ...baseRoster, rooms: [faceRoom], roomsById: { face: faceRoom } }
const faceLocked = { ...faceRoster, readOnly: SENTENCE }

// Every action that asks the host to change something. A locked page may draw an entry that
// navigates (New Bot, a Bot's row, Open board); it may not draw one that lands here.
const MUTATING = ['updateBot', 'deleteBot', 'createBot', 'duplicateBot', 'setMain', 'setPrefs', 'setFlags', 'openMemory', 'refreshModels',
Expand Down Expand Up @@ -177,6 +191,12 @@ const isControl = (node) => ['select', 'input', 'textarea'].includes(nameOf(node
|| node.props?.role === 'switch'
|| isWidget(node)

// A face-room Bot name is a `button` only when its row got an `onOpenBot`; a locked page must
// draw none of those, and nothing else a reader could press either — a link, a field, or a
// span carrying a handler (#2492).
const INTERACTIVE = new Set(['button', 'a', 'input', 'select', 'textarea'])
const isInteractive = (node) => INTERACTIVE.has(nameOf(node)) || typeof node.props?.onClick === 'function'

const PAGES = [
{ key: 'general', id: null, name: 'GeneralPage', facts: ['DeepSeek', 'On'] },
{ key: 'bots', id: null, name: 'BotsPage', facts: ['Main Bot', 'Chief', 'Writer', 'Bot animation', 'Quiet'] },
Expand Down Expand Up @@ -271,3 +291,33 @@ test('every Settings page re-renders when the window unlocks under it (#2474)',
assert.ok(nodes.filter(isControl).length > 0, `${page.name} still draws its live controls after the flip`)
}
})

// #2492: a read-only face-to-face room drew every person with the hint `Human` while its own
// header counted `2 Bots`, and the names the writable page draws were thrown away with each
// person's seats. The next change to that branch must not silently lose them again.
test('a read-only face-to-face room names each person\'s Bot (#2492)', () => {
const { nodes, calls } = mount(faceLocked, 'groups', 'face', 'GroupSettingsForm')
const facts = text(nodes)
assert.match(facts, /Ann \(you\)Chief/, 'my Bot is named under me')
assert.match(facts, /BobWriter/, "the friend's Bot is named under them")
assert.ok(!facts.includes('Human'), 'a face-to-face room does not call a person Human')
for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point, not its answer */ } }
assert.deepEqual(calls, [], 'the members card offers no write')
assert.deepEqual(nodes.filter(isControl).map(nameOf), [], 'the members card draws text rows only')
assert.deepEqual(nodes.filter(isInteractive).map(nameOf), [], 'no button, link, field or span with a handler, counted deep')
// A zero is only worth reading if the counter can see the buttons when they are there: the
// writable page draws these same rows with `onOpenBot`, so it must find `bt-face-bot` buttons.
const writable = mount(faceRoster, 'groups', 'face', 'GroupSettingsForm').nodes
assert.ok(writable.some(node => nameOf(node) === 'button' && node.props?.className === 'bt-face-bot'),
'the same counter finds the writable page\'s Bot opener')
})

test('a read-only face-to-face room states who answers and its working folder (#2492)', () => {
const { nodes } = mount(faceLocked, 'groups', 'face', 'GroupSettingsForm')
const facts = text(nodes)
assert.ok(facts.includes('Who answers'), 'the page says who answers')
assert.ok(facts.includes('Everyone'), 'with the mode the writable page shows')
assert.ok(facts.includes('Working folder'), 'the page names the working folder')
assert.ok(facts.includes('/work/face'), 'with the folder the room works in')
assert.deepEqual(nodes.filter(isControl).map(nameOf), [], 'both facts are text, not controls')
})
Loading
Loading