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/2305-projects-readonly-writes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read-only window's Settings → **Projects** page now draws no write the host would refuse: no name field, no **New Project**, no **Bind to {name}** choice on a Project row and no **Unbind** on a Project or on a binding, because `project-create` and `project-bind` are writes a reading window is refused. **Open board**, the Project rows with their names, working folders and members stay readable, and a writable window draws exactly the tree it drew before. ([#2305](https://github.com/ThinkFlowLab/FlyBot/issues/2305))
10 changes: 7 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19407,6 +19407,7 @@ var projectMembers = (roster) => [
...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
];
function ProjectsList({ projects, roster, call: call4, reload }) {
const locked = lockedWindow(roster);
const members = projectMembers(roster);
const bind = async (choice, project) => {
const [kind, id] = String(choice ?? "").split(":");
Expand Down Expand Up @@ -19434,7 +19435,7 @@ function ProjectsList({ projects, roster, call: call4, reload }) {
"div",
{ className: "bt-project-row-controls" },
(0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
(0, import_react42.createElement)(
locked ? null : (0, import_react42.createElement)(
"select",
{
className: "bt-select",
Expand Down Expand Up @@ -19464,7 +19465,7 @@ function ProjectsList({ projects, roster, call: call4, reload }) {
label: member.name,
hint: member.kind === "group" ? t("Group") : t("Bot")
},
(0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
locked ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
void unbind(member);
} }, t("Unbind"))
))
Expand Down Expand Up @@ -19496,9 +19497,12 @@ function ProjectsPage({ roster, actions, run, call: call4 }) {
setState({ projects: state.projects, error: error?.message ?? String(error) });
}
};
const locked = lockedWindow(roster);
return [
state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
(0, import_react42.createElement)(
// The host refuses `project-create` and `project-bind` in a read-only window, so the page draws
// no control that would call them; the Project list and its facts stay readable.
locked ? null : (0, import_react42.createElement)(
SetSection,
{ key: "new", title: t("New Project") },
(0, import_react42.createElement)(
Expand Down
12 changes: 8 additions & 4 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { KeyIcon, SecretsPage } from './secret-card.js'
import { UsagePage } from './usage-page.js'
import { ConnectorsPage } from './connectors.js'
import { CapabilitiesPage } from './capability-settings.js'
import { SchedulesPage } from './schedules.js'
import { SchedulesPage, lockedWindow } from './schedules.js'
import { FeedbackIcon, FeedbackPage } from './feedback.js'
import { FolderMenu } from './folder-menu.js'
import { AboutPage } from './about.js'
Expand Down Expand Up @@ -272,6 +272,7 @@ const projectMembers = (roster) => [
]

export function ProjectsList({ projects, roster, call, reload }) {
const locked = lockedWindow(roster)
const members = projectMembers(roster)
// The binding is the member's own field (`bot.projectId`, `room.projectId`); the record is only
// asked again so the row that shows it is read from the host, not guessed here.
Expand All @@ -296,7 +297,7 @@ export function ProjectsList({ projects, roster, call, reload }) {
},
h('div', { className: 'bt-project-row-controls' },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openProjectBoard(project.id) }, t('Open board')),
h('select', {
locked ? null : h('select', {
className: 'bt-select', 'aria-label': t('Bind to {name}', { name: project.name }), value: '',
onChange: event => { void bind(event.target.value, project) },
},
Expand All @@ -308,7 +309,7 @@ export function ProjectsList({ projects, roster, call, reload }) {
key: `${member.kind}:${member.id}`, label: member.name,
hint: member.kind === 'group' ? t('Group') : t('Bot'),
},
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))),
locked ? null : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))),
]
}

Expand Down Expand Up @@ -339,9 +340,12 @@ export function ProjectsPage({ roster, actions, run, call }) {
setState({ projects: state.projects, error: error?.message ?? String(error) })
}
}
const locked = lockedWindow(roster)
return [
state.error === null ? null : h('div', { key: 'error', className: 'bt-error', role: 'alert' }, state.error),
h(SetSection, { key: 'new', title: t('New Project') },
// The host refuses `project-create` and `project-bind` in a read-only window, so the page draws
// no control that would call them; the Project list and its facts stay readable.
locked ? null : h(SetSection, { key: 'new', title: t('New Project') },
h(SetRow, { label: t('Name') },
h('input', {
className: 'bt-input', 'aria-label': t('Project name'), value: name, placeholder: t('e.g. Website'),
Expand Down
47 changes: 47 additions & 0 deletions core/test/project-settings-page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,50 @@ test('a row opens its board, a Bot binds with its Project id, and unbinding send
await new Promise(resolve => setTimeout(resolve, 0))
assert.deepEqual(calls[2], ['project-bind', { kind: 'group', id: 'r1', projectId: null }])
})

// #2305: the host refuses `project-create` and `project-bind` in a window that holds the team
// read-only, so the page draws none of the controls that call them — the name field, New Project,
// the row's Bind choice and every Unbind — while the readable half stays: the Project row and its
// facts, Open board, and the members already bound.
const writeControls = (nodes) => [
...nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name'),
...nodes.filter(node => node.type === 'select' && /^Bind to /.test(node.props['aria-label'] ?? '')),
...nodes.filter(node => node.type === 'button' && ['New Project', 'Unbind'].includes(text(node))),
]
const lockedRoster = { ...roster, readOnly: 'Another window holds this team read-only.' }

test('a read-only window draws no write on the Projects page', async () => {
const project = { id: 'project-1', name: 'Site', workDir: '/work/site' }
const call = async () => ({ projects: [project] })
slots = []
effects = []
render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call })
await flush()
const page = render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call })
const nodes = all(page)
assert.deepEqual(writeControls(nodes), [], 'a read-only window offers no Projects write')
assert.ok(!nodes.some(node => node.type === 'h3' && text(node) === 'New Project'), 'the create form is not drawn')
assert.ok(nodes.some(node => node.props?.label === 'Site'), 'the Project is still listed')
assert.ok(nodes.some(node => node.props?.hint === '/work/site'), 'the Project facts stay')
assert.ok(nodes.some(node => node.type === 'button' && text(node) === 'Open board'), 'Open board stays')
assert.ok(nodes.some(node => node.props?.label === 'Ops'), 'a bound member is still listed')

const list = all(ProjectsList({ projects: [project], roster: lockedRoster, call, reload: async () => {} }))
assert.deepEqual(writeControls(list), [], 'the list offers no Bind or Unbind either')
assert.ok(list.some(node => node.type === 'button' && text(node) === 'Open board'))
})

test('a writable window keeps every Projects write control', async () => {
const project = { id: 'project-1', name: 'Site', workDir: '/work/site' }
const call = async () => ({ projects: [project] })
slots = []
effects = []
render(ProjectsPage, { roster, actions, run: fn => fn, call })
await flush()
const nodes = all(render(ProjectsPage, { roster, actions, run: fn => fn, call }))
const controls = writeControls(nodes)
assert.equal(controls.length, 4, 'name field, New Project, Bind and one Unbind')
assert.equal(nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name').length, 1)
assert.equal(nodes.filter(node => node.type === 'select').length, 1)
assert.equal(nodes.filter(node => node.type === 'button' && text(node) === 'Unbind').length, 1)
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second
- 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)). 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. Tests: `core/test/project-settings-page.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.
Expand Down
12 changes: 8 additions & 4 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -24064,7 +24064,7 @@ diff --git a/client.js b/client.js
const run = (promise) => Promise.resolve(promise).then(() => {
setError("");
return true;
@@ -10779,104 +10779,254 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
@@ -10779,104 +10779,258 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false
});
const bot = page === "bots" && id ? roster.byId[id] : void 0;
const room2 = page === "groups" && id ? roster.roomsById[id] : void 0;
Expand Down Expand Up @@ -24203,6 +24203,7 @@ diff --git a/client.js b/client.js
+ ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null }))
+];
+function ProjectsList({ projects, roster, call: call4, reload }) {
+ const locked = lockedWindow(roster);
+ const members = projectMembers(roster);
+ const bind = async (choice, project) => {
+ const [kind, id] = String(choice ?? "").split(":");
Expand Down Expand Up @@ -24230,7 +24231,7 @@ diff --git a/client.js b/client.js
+ "div",
+ { className: "bt-project-row-controls" },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")),
+ (0, import_react42.createElement)(
+ locked ? null : (0, import_react42.createElement)(
+ "select",
+ {
+ className: "bt-select",
Expand Down Expand Up @@ -24260,7 +24261,7 @@ diff --git a/client.js b/client.js
+ label: member.name,
+ hint: member.kind === "group" ? t("Group") : t("Bot")
+ },
+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ locked ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => {
+ void unbind(member);
+ } }, t("Unbind"))
+ ))
Expand Down Expand Up @@ -24292,9 +24293,12 @@ diff --git a/client.js b/client.js
+ setState({ projects: state.projects, error: error?.message ?? String(error) });
+ }
+ };
+ const locked = lockedWindow(roster);
+ return [
+ state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error),
+ (0, import_react42.createElement)(
+ // The host refuses `project-create` and `project-bind` in a read-only window, so the page draws
+ // no control that would call them; the Project list and its facts stay readable.
+ locked ? null : (0, import_react42.createElement)(
+ SetSection,
+ { key: "new", title: t("New Project") },
+ (0, import_react42.createElement)(
Expand Down
Loading
Loading