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/2199-settings-readonly-head.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A window that holds the Bot team read-only now says so in the Settings header, in the host's own sentence, above every page that still offers its write controls: General, Bots, Groups, Secrets, Connectors, Capabilities, Scheduled tasks and Projects. Pressing a control there is refused by the host, and until now nothing on the panel explained why nothing moved. The standing sentence sits above the line that reports one refused write, and a blank or missing sentence draws nothing ([#2199](https://github.com/ThinkFlowLab/FlyBot/issues/2199)).
4 changes: 4 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19175,6 +19175,10 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
back ? (0, import_react42.createElement)("button", { type: "button", className: "bt-icon-btn", "aria-label": back, title: back, onClick: () => actions.openSettings(shown) }, (0, import_react42.createElement)(ChevronLeftIcon)) : null,
(0, import_react42.createElement)("h2", { key: bot?.id ?? room2?.id ?? shown }, bot?.name ?? room2?.name ?? label)
),
// A window that holds the team read-only still opens every page here, so the header says
// why nothing can be changed, in the host's own sentence and above the refusal below it:
// the standing state first, the answer to one pressed control closest to that control.
typeof roster?.readOnly === "string" && roster.readOnly.trim() ? (0, import_react42.createElement)("div", { className: "bt-hint bt-read-only", role: "status", style: { padding: "0 20px 8px", textAlign: "left" } }, hostText(roster.readOnly)) : null,
shown === "usage" ? null : errorLine,
shown === "usage" ? null : (0, import_react42.createElement)(
"div",
Expand Down
8 changes: 7 additions & 1 deletion core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { SchedulesPage } from './schedules.js'
import { FeedbackIcon, FeedbackPage } from './feedback.js'
import { FolderMenu } from './folder-menu.js'
import { AboutPage } from './about.js'
import { t } from './i18n.js'
import { hostText, t } from './i18n.js'

const capitalize = word => word[0].toUpperCase() + word.slice(1)

Expand Down Expand Up @@ -226,6 +226,12 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving =
h('div', { className: 'bt-settings-head' },
back ? h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': back, title: back, onClick: () => actions.openSettings(shown) }, h(ChevronLeftIcon)) : null,
h('h2', { key: bot?.id ?? room?.id ?? shown }, bot?.name ?? room?.name ?? label)),
// A window that holds the team read-only still opens every page here, so the header says
// why nothing can be changed, in the host's own sentence and above the refusal below it:
// the standing state first, the answer to one pressed control closest to that control.
typeof roster?.readOnly === 'string' && roster.readOnly.trim()
? h('div', { className: 'bt-hint bt-read-only', role: 'status', style: { padding: '0 20px 8px', textAlign: 'left' } }, hostText(roster.readOnly))
: null,
shown === 'usage' ? null : errorLine,
shown === 'usage' ? null : h('div', { key: pageKey, className: 'bt-settings-scroll bt-settings-page', 'data-move': last.current.move },
shown === 'general' ? h(GeneralPage, { roster, actions, run }) : null,
Expand Down
161 changes: 161 additions & 0 deletions core/test/settings-readonly-head.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// A window that holds the team read-only can still open Settings, and every page there offers
// its normal write controls. The one place that says why nothing happens — the host's own
// sentence in `roster.readOnly` — is printed by the sidebar and the Workflows page, never by the
// Settings header, so the dialog looks writable until a control is pressed and refused.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

const { outputFiles } = await build({
stdin: {
contents: "export { SettingsDialog } from './src/client/settings.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'],
})
const h = (type, props, ...children) => ({ type, props: { ...props, children: children.flat(Infinity) } })
// A hook runtime that survives a re-render, so one refused write can answer and the dialog can
// be drawn again with the refusal it stored next to the standing read-only sentence.
let slots = []
let index = 0
let effects = []
const react = {
createElement: h, Component: class {}, Fragment: Symbol('F'),
useMemo: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn(); return slots[at] },
useState: (initial) => {
const at = index++
if (!(at in slots)) slots[at] = typeof initial === 'function' ? initial() : initial
return [slots[at], (value) => { slots[at] = typeof value === 'function' ? value(slots[at]) : value }]
},
useCallback: (fn) => { const at = index++; if (!(at in slots)) slots[at] = fn; return slots[at] },
useRef: (value) => { const at = index++; if (!(at in slots)) slots[at] = { current: value }; return slots[at] },
useEffect: (fn) => { const at = index++; if (!(at in slots)) { slots[at] = true; effects.push(fn) } },
useLayoutEffect() {},
useSyncExternalStore: (_subscribe, get) => get(),
}
const win = { location: { hash: '' }, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, console, setTimeout, clearTimeout, requestAnimationFrame: () => 0, cancelAnimationFrame() {},
document: { hidden: false, addEventListener() {}, removeEventListener() {} },
navigator: { language: 'en' },
localStorage: { getItem: () => null, setItem() {} },
window: win,
require: (name) => {
if (name === 'react') return react
if (name === 'react-dom') return { createPortal: node => node }
if (name === '@deepseek-ai/dsh-client-ui-primitives') return {}
throw new Error(`Unexpected platform module ${name}`)
},
})
const { SettingsDialog } = module.exports

const render = (props) => { index = 0; return SettingsDialog(props) }
const flush = async () => {
const queued = effects
effects = []
for (const fn of queued) await fn()
await new Promise(resolve => setTimeout(resolve, 0))
}
const all = (node, out = []) => {
if (Array.isArray(node)) { for (const item of node) all(item, out); return out }
if (node && typeof node === 'object') { out.push(node); all(node.props?.children, out) }
return out
}
const ownText = (node) => {
const kids = node?.props?.children
const walk = (value) => {
if (Array.isArray(value)) return value.map(walk).join('')
if (typeof value === 'string' || typeof value === 'number') return String(value)
if (value && typeof value === 'object') return walk(value.props?.children)
return ''
}
return walk(kids)
}
const scrollingBox = (nodes) => nodes.find(node => typeof node.props?.className === 'string' && node.props.className.includes('bt-settings-scroll'))
// Everything the dialog draws above the page's scrolling box: the dialog navigation and the
// Settings header. A read-only sentence has to live here, or it scrolls away like #2194's copy.
const fixedTop = (nodes) => {
const at = nodes.indexOf(scrollingBox(nodes))
return at < 0 ? nodes : nodes.slice(0, at)
}
const shape = (nodes) => nodes.map(node => ({
type: typeof node.type === 'string' ? node.type : node.type?.name ?? 'fn',
className: node.props?.className ?? null,
role: node.props?.role ?? null,
text: ownText(node),
}))

const bot = { id: 'a', name: 'Chief', pinned: false, hidden: false, model: '', instructions: '', workDir: '' }
const rosterBase = { bots: [bot], rooms: [], byId: { a: bot }, roomsById: {}, prefs: {}, mainBotIds: ['a'], connectors: [], memory: false }
const rosterWith = (readOnly) => (readOnly === undefined ? { ...rosterBase } : { ...rosterBase, readOnly })
const actions = { openSettings() {}, closeSettings() {}, setFlags: () => Promise.resolve() }
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 PAGES = [
{ page: 'general' },
{ page: 'bots' },
{ page: 'bots', id: 'a' },
{ page: 'groups' },
{ page: 'connectors' },
{ page: 'schedules' },
{ page: 'projects' },
]

test('a read-only window says so in the Settings header, in the host\'s own words (#2199)', () => {
for (const where of PAGES) {
const tree = render({ ...where, roster: rosterWith(SENTENCE), actions, useUpdate: () => ({}) })
const nodes = all(tree)
const line = nodes.filter(node => typeof node === 'object' && ownText(node).trim() === SENTENCE)
assert.equal(line.length, 1, `${where.page}: the host's sentence is drawn once`)
assert.equal(ownText(line[0]).trim(), SENTENCE, `${where.page}: the sentence is verbatim, not rewritten`)
assert.equal(line[0].props?.role, 'status', `${where.page}: it is a status line`)
const scroll = scrollingBox(nodes)
assert.ok(scroll, `${where.page}: the page has its scrolling box`)
assert.equal(nodes.indexOf(line[0]) < nodes.indexOf(scroll), true, `${where.page}: the sentence sits above the scrolling page, so scrolling cannot hide it`)
assert.equal(all(scroll).includes(line[0]), false, `${where.page}: the sentence is not inside the scrolling page`)
}
})

test('a writable window, a blank sentence and a missing one draw no read-only line at all (#2199)', () => {
for (const where of PAGES) {
const baseline = shape(fixedTop(all(render({ ...where, roster: rosterWith(undefined), actions, useUpdate: () => ({}) }))))
const missing = shape(fixedTop(all(render({ ...where, roster: rosterBase, actions, useUpdate: () => ({}) }))))
assert.deepEqual(missing, baseline, `${where.page}: a roster without the field is the reference shape`)
for (const blank of ['', ' ', '\n\t ']) {
const tree = render({ ...where, roster: rosterWith(blank), actions, useUpdate: () => ({}) })
const nodes = all(tree)
assert.deepEqual(shape(fixedTop(nodes)), baseline, `${where.page}: readOnly ${JSON.stringify(blank)} draws nothing above the page`)
const scroll = scrollingBox(nodes)
assert.equal(all(scroll).some(node => typeof node.props?.className === 'string' && node.props.className.includes('bt-read-only')), false, `${where.page}: readOnly ${JSON.stringify(blank)} draws no read-only node in the page either`)
}
}
})

test('the standing read-only line stays above the refusal one pressed control adds (#2199)', async () => {
// A pressed control on the Bot page answers through the dialog's own `run`, which stores the
// host's sentence and re-renders. Both lines are then in one tree, and their order is what a
// reader sees: the standing state first, the answer to the press nearest the press.
const roster = rosterWith(SENTENCE)
const props = { page: 'bots', id: 'a', roster, actions, useUpdate: () => ({}) }
let tree = render(props)
const form = all(tree).find(node => node?.props?.roster === roster && node?.props?.bot === bot && typeof node.props.run === 'function')
assert.ok(form, 'the page is the Bot form, which reports through the dialog')
const REFUSAL = 'This window shows the team but cannot change it.'
await form.props.run(Promise.reject(new Error(REFUSAL)))
await flush()
tree = render(props)
const nodes = all(tree)
const status = nodes.find(node => ownText(node).trim() === SENTENCE)
const alert = nodes.find(node => node.props?.role === 'alert' && ownText(node).trim() === REFUSAL)
assert.ok(status, 'the read-only sentence is still on screen after the refused write')
assert.equal(status.props?.role, 'status', 'it is still the standing status line')
assert.ok(alert, 'the refusal is on screen too')
assert.equal(nodes.indexOf(status) < nodes.indexOf(alert), true, 'the standing sentence sits above the refusal a single press produced')
const scroll = scrollingBox(nodes)
assert.ok(scroll, 'the page is the scrolling box under the two lines')
assert.equal(nodes.indexOf(alert) < nodes.indexOf(scroll), true, 'the refusal stays above the scrolling page')
assert.equal(all(scroll).includes(status), false, 'the standing sentence is not inside the scrolling page')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- The "@" reader is shared with the host (`core/src/shared/mentions.js`, bundled into `core/client.js`). #287 added `unmatchedMentions` there (the `@` words that name nobody, used by the host for the face-room hint); `mentionSpans` and what the bubbles draw are unchanged.
- A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only.
- 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)). 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.
- 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 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.
- 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.
Expand Down
Loading
Loading