From 0ce742571300e2a684be09574b5434fa655d9c17 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:44:26 +0800 Subject: [PATCH] client: keep Who answers and Working folder on a read-only group's page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A window that holds the team read-only draws the group Settings page as text, and the two fact cards were gated on the room being face-to-face. Both facts are reads — `shownMode` and `room.workDir` — so a plain group's locked page drew neither while its writable page draws both as a control. Draw them for every group, as the face-to-face page already did (#2492). Fixes #2528 --- .../2528-plain-group-readonly-facts.fixed.md | 1 + core/client.js | 6 ++++-- core/src/client/group-settings.js | 6 ++++-- core/test/settings-readonly-pages.test.js | 19 +++++++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 8 +++++--- patches/ds-bot/0012-room-membership.patch | 8 +++++--- .../test/settings-readonly-pages.test.js | 19 +++++++++++++++++++ 8 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 changelog.d/2528-plain-group-readonly-facts.fixed.md diff --git a/changelog.d/2528-plain-group-readonly-facts.fixed.md b/changelog.d/2528-plain-group-readonly-facts.fixed.md new file mode 100644 index 000000000..eba6c938a --- /dev/null +++ b/changelog.d/2528-plain-group-readonly-facts.fixed.md @@ -0,0 +1 @@ +A read-only window's plain-group Settings page states who answers and the group's working folder as text, the way a face-to-face room's page already did ([#2528](https://github.com/ThinkFlowLab/FlyBot/issues/2528)). diff --git a/core/client.js b/core/client.js index 0302a97ec..a46a0ad70 100644 --- a/core/client.js +++ b/core/client.js @@ -15337,8 +15337,10 @@ 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, + // Both facts are reads (`shownMode` and `room.workDir`), so a locked page keeps them as + // text for every group, the way a face-to-face room already did (#2492). + (0, import_react31.createElement)(Card, { title: t("Who answers") }, (0, import_react31.createElement)(Row, { label: t(modeLabel), hint: t(modeHint) })), + (0, import_react31.createElement)(Card, { title: t("Working folder") }, (0, import_react31.createElement)(Row, { label: t("Folder"), hint: room2.workDir || t("Not set") })), 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 dca0a97cf..e33876493 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -213,8 +213,10 @@ 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, + // Both facts are reads (`shownMode` and `room.workDir`), so a locked page keeps them as + // text for every group, the way a face-to-face room already did (#2492). + h(Card, { title: t('Who answers') }, h(Row, { label: t(modeLabel), hint: t(modeHint) })), + h(Card, { title: t('Working folder') }, h(Row, { label: t('Folder'), hint: room.workDir || t('Not set') })), 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 4dac2535a..bedad0b72 100644 --- a/core/test/settings-readonly-pages.test.js +++ b/core/test/settings-readonly-pages.test.js @@ -321,3 +321,22 @@ test('a read-only face-to-face room states who answers and its 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') }) + +// The face-room case above is the same two facts on the other origin: a plain group's read-only +// page drew neither, while its writable page draws both as a control. They are reads (`room.mode` +// through `shownMode`, and `room.workDir`), so the locked page keeps them as text too. +test('a read-only plain group states who answers and its working folder', () => { + const { nodes, calls } = mount(lockedRoster, 'groups', 'r1', 'GroupSettingsForm') + const facts = text(nodes) + assert.ok(facts.includes('Who answers'), 'the page says who answers') + assert.ok(facts.includes('Admin leads'), 'with the mode the writable page shows') + assert.ok(facts.includes('Working folder'), 'the page names the working folder') + assert.ok(facts.includes('/work/ops'), 'with the folder the group works in') + assert.deepEqual(nodes.filter(isControl).map(nameOf), [], 'both facts are text, not controls') + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point, not its answer */ } } + assert.deepEqual(calls, [], 'and neither fact writes anything when drawn') + // A zero only counts if the same reader sees the controls on the writable page next door. + const writable = mount(baseRoster, 'groups', 'r1', 'GroupSettingsForm').nodes + assert.ok(writable.some(node => node.type === Segmented), 'the writable page draws the mode picker') + assert.ok(writable.some(node => node.type === FolderMenu), 'and the folder menu') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 2bb2c5798..19e11d222 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 (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`. +- 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; the page states who answers and the working folder for every group, face-to-face or plain, since #2492 and #2528), 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. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f379ff6bf..782f8690e 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,1908 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } +@@ -6759,1401 +6759,1910 @@ function FolderMenu({ value, group = false, actions, run, onPick, wide = false } if (event.key === "Enter" && !event.nativeEvent.isComposing) typed(); } }), @@ -19485,8 +19485,10 @@ 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, ++ // Both facts are reads (`shownMode` and `room.workDir`), so a locked page keeps them as ++ // text for every group, the way a face-to-face room already did (#2492). ++ (0, import_react31.createElement)(Card, { title: t("Who answers") }, (0, import_react31.createElement)(Row, { label: t(modeLabel), hint: t(modeHint) })), ++ (0, import_react31.createElement)(Card, { title: t("Working folder") }, (0, import_react31.createElement)(Row, { label: t("Folder"), hint: room2.workDir || t("Not set") })), + 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 659e15809..6e3e95313 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -653,7 +653,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,54 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -31,11 +31,56 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { const timer = setTimeout(() => setConfirming(false), 4000) return () => clearTimeout(timer) }, [confirming]) @@ -702,8 +702,10 @@ 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, ++ // Both facts are reads (`shownMode` and `room.workDir`), so a locked page keeps them as ++ // text for every group, the way a face-to-face room already did (#2492). ++ h(Card, { title: t('Who answers') }, h(Row, { label: t(modeLabel), hint: t(modeHint) })), ++ h(Card, { title: t('Working folder') }, h(Row, { label: t('Folder'), hint: room.workDir || t('Not set') })), + 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 4dac2535a..bedad0b72 100644 --- a/patches/ds-bot/files/test/settings-readonly-pages.test.js +++ b/patches/ds-bot/files/test/settings-readonly-pages.test.js @@ -321,3 +321,22 @@ test('a read-only face-to-face room states who answers and its 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') }) + +// The face-room case above is the same two facts on the other origin: a plain group's read-only +// page drew neither, while its writable page draws both as a control. They are reads (`room.mode` +// through `shownMode`, and `room.workDir`), so the locked page keeps them as text too. +test('a read-only plain group states who answers and its working folder', () => { + const { nodes, calls } = mount(lockedRoster, 'groups', 'r1', 'GroupSettingsForm') + const facts = text(nodes) + assert.ok(facts.includes('Who answers'), 'the page says who answers') + assert.ok(facts.includes('Admin leads'), 'with the mode the writable page shows') + assert.ok(facts.includes('Working folder'), 'the page names the working folder') + assert.ok(facts.includes('/work/ops'), 'with the folder the group works in') + assert.deepEqual(nodes.filter(isControl).map(nameOf), [], 'both facts are text, not controls') + for (const button of pressure(nodes)) { try { button.props.onClick?.() } catch { /* the press is the point, not its answer */ } } + assert.deepEqual(calls, [], 'and neither fact writes anything when drawn') + // A zero only counts if the same reader sees the controls on the writable page next door. + const writable = mount(baseRoster, 'groups', 'r1', 'GroupSettingsForm').nodes + assert.ok(writable.some(node => node.type === Segmented), 'the writable page draws the mode picker') + assert.ok(writable.some(node => node.type === FolderMenu), 'and the folder menu') +})