From 35a7c328a465ddbc873877793cb98719945cb349 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:19:05 +0800 Subject: [PATCH] Name a read-only face room's people instead of calling each one Human (#2492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a read-only window the read-only branch of `GroupSettingsForm` drew a face-to-face room's members with `hint: t('Human')`, so the page said `2 people · 2 Bots` above and named no Bot at all, and the same branch dropped the two facts the writable form states: who answers and the group's working folder. Reuse the writable window's `FacePersonRow` without `onOpenBot`, so each person's Bots are named under them and no seat draws a button, and add the `Who answers` and `Working folder` cards from the mode already computed above the branch. The writable path and non-face groups are unchanged; the new fixture fails on the old branch for both reasons. --- .../2492-face-room-readonly-facts.fixed.md | 1 + core/client.js | 5 +- core/src/client/group-settings.js | 7 ++- core/test/settings-readonly-pages.test.js | 50 +++++++++++++++++++ docs/subsystems/core-client.md | 4 +- patches/ds-bot/0008-client-bundle.patch | 7 ++- patches/ds-bot/0012-room-membership.patch | 9 +++- .../test/settings-readonly-pages.test.js | 50 +++++++++++++++++++ 8 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 changelog.d/2492-face-room-readonly-facts.fixed.md diff --git a/changelog.d/2492-face-room-readonly-facts.fixed.md b/changelog.d/2492-face-room-readonly-facts.fixed.md new file mode 100644 index 00000000..24a23213 --- /dev/null +++ b/changelog.d/2492-face-room-readonly-facts.fixed.md @@ -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. diff --git a/core/client.js b/core/client.js index 0fd179bb..9d076896 100644 --- a/core/client.js +++ b/core/client.js @@ -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", @@ -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, { @@ -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 ); } diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 08e030d1..551c33d4 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -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)) { @@ -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') })), @@ -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)) diff --git a/core/test/settings-readonly-pages.test.js b/core/test/settings-readonly-pages.test.js index 9dc274f2..4dac2535 100644 --- a/core/test/settings-readonly-pages.test.js +++ b/core/test/settings-readonly-pages.test.js @@ -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', @@ -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'] }, @@ -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') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index b0b26f49..2c73c16f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -132,7 +132,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - 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 control beside it, above a `role="status"` line carrying the host's own sentence, so the overview offers only what the host will not refuse. Since P-547 those three cards keep their titles and their saved values as plain text as well (`ReadOnlyOverview`: the model's name and provider, the permission preset's label, the working folder's last segment with the full path as its `title`), because the window refuses the writes, not the reading — a value that vanishes with its control leaves the page saying less than the host knows; a writable window keeps every control and draws none of those values twice. Tests: `core/test/details-overview-readonly.test.js`. 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. - Settings → **Projects** (`ProjectsPage` and `ProjectsList` in `settings.js`) draws the write half of the Project record: a name field, **New Project**, a **Bind to {name}** choice on every Project row and **Unbind** on every binding. In a window that holds the team read-only (`lockedWindow`, `schedules.js`) none of them is drawn, because the host refuses `project-create` and `project-bind`; **Open board**, the Project rows with their names and working folders, and the members already bound stay readable, and a writable window is unchanged. With no Project at all the read-only page says only that there is none (`No Project yet.`) instead of inviting a creation the page no longer offers, while a writable window keeps the shared `No Project yet — create one.` the Project board also uses (#2489). Tests: `core/test/project-settings-page.test.js`. -- The rest of Settings follows that lock (#2474): `GeneralPage`, `MainBotsSection` and `MotionSection` in `settings.js`, `BotSettingsForm` (`bot-settings.js`), `GroupSettingsForm` (`group-settings.js`), `ConnectorsPage` (`connectors.js`), `CapabilitiesPage` (`capability-settings.js`) and `SecretsPage` (`secret-card.js`) each read the same `lockedWindow(roster)` and, in a read-only window, draw no `select`, `input`, switch or component that would render one — every one of them asked for `set-prefs`, `set-main`, `update-bot`, `update-room`, a `connector-*` write, `save-capability`/`delete-capability` or a `secret-*` write, which the host refuses with `bot/read-only`. The facts stay as text: the theme and the accent in use, the notification state, the Main Bots and the animation setting, a Bot's role, model, working folder and instructions, a group's members, admin and notice, each connector's own state sentence, each capability's description and each stored key's name with who may use it. Entries that only navigate stay — **New Bot**, whose form explains the lock itself since #2465, and a row that opens a Bot's or a group's page — and the standing sentence is still the dialog header's, drawn once above the page and never again inside it. A writable window is unchanged. Tests: `core/test/settings-readonly-pages.test.js`. +- The rest of Settings follows that lock (#2474): `GeneralPage`, `MainBotsSection` and `MotionSection` in `settings.js`, `BotSettingsForm` (`bot-settings.js`), `GroupSettingsForm` (`group-settings.js`), `ConnectorsPage` (`connectors.js`), `CapabilitiesPage` (`capability-settings.js`) and `SecretsPage` (`secret-card.js`) each read the same `lockedWindow(roster)` and, in a read-only window, draw no `select`, `input`, switch or component that would render one — every one of them asked for `set-prefs`, `set-main`, `update-bot`, `update-room`, a `connector-*` write, `save-capability`/`delete-capability` or a `secret-*` write, which the host refuses with `bot/read-only`. The facts stay as text: the theme and the accent in use, the notification state, the Main Bots and the animation setting, a Bot's role, model, working folder and instructions, a group's members, admin and notice (in a face-to-face room each person's Bots are named under that person, and the page states who answers and the room's working folder, since #2492), each connector's own state sentence, each capability's description and each stored key's name with who may use it. Entries that only navigate stay — **New Bot**, whose form explains the lock itself since #2465, and a row that opens a Bot's or a group's page — and the standing sentence is still the dialog header's, drawn once above the page and never again inside it. A writable window is unchanged. Tests: `core/test/settings-readonly-pages.test.js`. - 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. @@ -237,7 +237,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility - A closed face room's status line already says who left (#1103). `GroupReplies` (`leaveNoticeCoveredByClose` in `cells.js`) does not also draw a short leave notice for that person (`SelfB1025 left the room`, or `Coder (SelfB1025) left`). A leave while the room is still open, a leave the status line does not name, and every other notice stay. The social plugin uses the same rule when it decides whether to post the line. Test: `core/test/tool-cells.test.js`. - `GroupReplies` draws a `user` reply with `other: true` (another member's face-room line, #180) as `PersonMessage` (`cells.js`): the person's name, the person mark (`PersonMark` in `mark.js`) and a received `--bt-bubble` bubble, not a centred `bt-event` notice and not the sent `--bt-user` style; own lines keep the right-hand bubble (#229). `other` comes only from the host's person-line post (`hooks.js` `postHuman`), never from text. Another home member's line in a shared group (`UserMessageCell`, author stamped by the host) gets the same person mark and the received surface (`.bt-umsg-other .bt-ubody`). `PersonMark({ name })` draws the person's initial on a color picked from the name (`personColor`, the core default-color hash of `person:`), so one person looks the same on every home and two people are told apart; without a name it is the plain silhouette (#182). - A person's line keeps its line breaks (#455): `.bt-person-msg .bt-bubble :is(p,li)` is `white-space: pre-line`, as the sender's own `pre-wrap` bubble, since Markdown would join single newlines. A trigger whose source has `shown: true` (a line a running round already drew, host #455) draws nothing. Covered by `core/test/face-people.test.js`. -- A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members · N** count people and draw one row per person (`FacePersonRow`: each Bot that person brought, drawn with the look the chat and the sidebar use — the local roster record, or the friend's seat/chat look, stepped off a same-named local Bot — then the person's name and the Bot names; my Bots open their chat). A row with no Bot still draws the person silhouette, not a blank disc (#1089). Owner names that share an initial (`SelfA1025`, `SelfB1025`) are not the mark (#1104). +- A face-to-face room is person-first (#229, #457, owner decision 2026-10-20). `face-seats.js` (re-exported by `group-settings.js`) builds it from participant fields and the roster, never message text: `faceSeats` lists the live Bot seats (`{ left: true }` adds the ones that left, marked `left`), `faceSeatLabel` names each Bot ` ()`, mine included (`Chief (SelfB)`, `Chief (SelfA)`; a Bot is never `(you)`), `faceBotLabel` gives a local Bot that name in a face room, and `facePeople` groups the seats by person (me once, first; a friend by home) with `facePersonLabel` putting `(you)` on me only. The overview and group settings **Members · N** count people and draw one row per person (`FacePersonRow`: each Bot that person brought, drawn with the look the chat and the sidebar use — the local roster record, or the friend's seat/chat look, stepped off a same-named local Bot — then the person's name and the Bot names; my Bots open their chat, and in a read-only window the row is the same text without that button, #2492). A row with no Bot still draws the person silhouette, not a blank disc (#1089). Owner names that share an initial (`SelfA1025`, `SelfB1025`) are not the mark (#1104). - In a face room a bubble names its speaker the same way (#457): my Bots' lines `Chief (SelfB)` (`cells.js` `GroupMessage` via `faceBotLabel`), a friend's Bot `Researcher (SelfA)` (`remoteSpeaker`), name only, with the handle as the tooltip instead of a globe and handle line. My own typed line is mine, not my Bot's: it carries my person mark and name (`data-face-mine`) and is never drawn held, so it does not change colour while a Bot's reply waits (`user-message.js`). A `@Chief (SelfA)` chip stays bound to that Bot after it leaves: `faceMentionables` also matches the left seats (after the live ones), so the name never falls through to my own Chief. - **One name for a friend's Bot (#384, #305, owner decision 2026-10-19).** `remoteBotTitle` (`shared/remote-label.js`) is the only format: the Bot's name, then its owner in parentheses (`ownerNameOf`: the human owner, `ownerName` / `owner`, before the home's label `homeName`), `Helper (Bob)`, and a friend's Chief the same, `Chief (Bob)` (no `'s Chief` special case). For a friend's chat in the roster, `friendBotTitle(bot)` is that one call (#498), on both homes: the sidebar row (one string, no muted owner part), chat header, tab title, composer placeholder, row tooltip and the `@` rows (a friend's Bot by that name with its handle as the description; the inserted text is unchanged; a friend's Bot is offered once: in a group by its seat, in a Bot's chat by its remote member row, which inserts the handle, not also by its chat row); in a face room `faceSeatLabel` / `at-menu.js` `faceLabel` give the same format (`Chief (Bob)`, my own Bot `Chief (SelfB)` with my name, #457). The social plugin's classic script reads the same functions from `globalThis.__flybot__.names` (`friendBotTitle`, `friendBotOwner`, set in `flybot-slots.js` `registry()`). Approval cards name the receiving person instead (`Chief → SelfB1233`, #418). `faceAliases` keeps the older labels (`Bob · Chief`, `Chief · Bob's Bot`, `Bob's Chief`) as aliases, so a line typed before the change still calls the same Bot. DSH's own Sessions rows in a face room's `@` menu are filtered in FlyBot (`face-at-filter.js`, below) until DSH has an API for it (#422). - **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 36b08507..f770b4b6 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -17028,7 +17028,7 @@ diff --git a/client.js b/client.js className: "bt-folder-input", autoFocus: true, value: draft, -@@ -6759,1401 +6759,1904 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,1401 +6759,1907 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -19425,6 +19425,7 @@ diff --git a/client.js b/client.js + 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", @@ -19443,7 +19444,7 @@ diff --git a/client.js b/client.js + (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, { @@ -19454,6 +19455,8 @@ diff --git a/client.js b/client.js + 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 + ); + } diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index f906e210..337dcb06 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -647,7 +647,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js } // A group chat's settings as grouped cards: its name and notice, who answers, its -@@ -31,11 +31,49 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,11 +31,54 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -671,6 +671,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js 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)) { @@ -682,7 +683,9 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + 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') })), @@ -693,6 +696,8 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + })), + 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)) diff --git a/patches/ds-bot/files/test/settings-readonly-pages.test.js b/patches/ds-bot/files/test/settings-readonly-pages.test.js index 9dc274f2..4dac2535 100644 --- a/patches/ds-bot/files/test/settings-readonly-pages.test.js +++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js @@ -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', @@ -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'] }, @@ -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') +})