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/2503-composer-group-admin-locked.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window a new group chat that already picked its admin no longer draws a **Send** arrow that does nothing: the composer draws no send control at all, and the host's own read-only sentence stays once, on the To: line where **Create group chat** would be. A writable window is unchanged ([#2503](https://github.com/ThinkFlowLab/FlyBot/issues/2503)).
1 change: 1 addition & 0 deletions changelog.d/2506-newchat-add-agent-locked.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A read-only window's New… menu no longer offers **Add coding agent**: `addAgent` is a write the host answers `bot/read-only`, so the row is not drawn at all while the other rows stay as a preview, and a writable window keeps the row ([#2506](https://github.com/ThinkFlowLab/FlyBot/issues/2506)).
4 changes: 2 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -16158,7 +16158,7 @@ function NewChat({ mode, roster, actions }) {
}
});
}
if ((roster.agents ?? []).length === 0) {
if (!locked && (roster.agents ?? []).length === 0) {
options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
await actions.addAgent();
close();
Expand Down Expand Up @@ -16327,7 +16327,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
}
}
}),
locked && !(group && admin !== null) ? (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
locked ? group && admin !== null ? null : (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
)
)
)
Expand Down
11 changes: 7 additions & 4 deletions core/src/client/new-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,9 @@ export function NewChat({ mode, roster, actions }) {
},
})
}
// A team holds at most one Agent; the entry exists only while there is none.
if ((roster.agents ?? []).length === 0) {
// A team holds at most one Agent; the entry exists only while there is none, and a read-only
// window does not offer it at all: the host answers `add-agent` with `bot/read-only` (P-571b).
if (!locked && (roster.agents ?? []).length === 0) {
options.push({ key: 'agent', icon: h(TerminalIcon), label: t('Add coding agent'), hint: t('A coding session without a Bot'), run: () => run(async () => { await actions.addAgent(); close() }) })
}
}
Expand Down Expand Up @@ -295,7 +296,9 @@ export function NewChat({ mode, roster, actions }) {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); sendMessage() }
},
}),
locked && !(group && admin !== null)
? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))
locked
? (group && admin !== null
? null
: h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)))
: h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon))))))
}
50 changes: 48 additions & 2 deletions core/test/composer-send-readonly.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
// `create-room` + `send` for several, and `openBot` sent the typed line when a picker row was
// pressed — every shape reached a host that can only answer `bot/read-only` after the fact. A
// locked window now draws the host's own sentence beside the composer instead of a live Send, no
// press writes, and a writable window sends exactly as before.
// press writes, and a writable window sends exactly as before. The New… menu is the same shape one
// step earlier (P-571b): `Add coding agent` asked a host that answers `bot/read-only`, so a locked
// menu does not draw it and a writable one keeps it.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
Expand Down Expand Up @@ -73,7 +75,7 @@ const actionsOf = () => {
send: async (id, text) => { calls.push(['send', id, text]) },
openSession: id => { calls.push(['open-session', id]) }, closeOverlay() {}, notify() {},
setFlags: async (id, flags) => { calls.push(['set-flags', id, flags]) },
newChatActionRows() {}, addAgent: async () => {}, createBot: async () => ({ id: 'new' }),
newChatActionRows() {}, addAgent: async () => { calls.push(['add-agent']) }, createBot: async () => ({ id: 'new' }),
openSettings() {}, openPalette() {},
},
}
Expand Down Expand Up @@ -172,6 +174,12 @@ const groupSend = async (roster, picks, { enter = false } = {}) => {
const sent = await view.send({ enter })
return { calls: view.calls, ...sent }
}
// The New… menu itself: a short action list, no To: row and no composer.
const menuPane = (roster) => {
const { calls, actions } = actionsOf()
const pane = mount({ mode: 'new', roster, actions })
return { calls, nodes: () => pane.draw() }
}
// A picker row pressed with a line typed is the other send path (`openBot`, a search pane).
const searchThenOpen = async (roster, name) => {
const { calls, actions } = actionsOf()
Expand Down Expand Up @@ -208,6 +216,26 @@ test('a read-only window sends nothing when a picker row is pressed with a line
assert.ok(locked.calls.some(call => call[0] === 'open-session' && call[1] === 'chief'), 'opening the session may stay')
})

// A read-only group chat whose admin is still chosen keeps its Create slot as the host's sentence
// on the To: line; the composer below must not draw a second live Send for it.
test('a read-only window draws no Send for a group that still has its admin chosen', async () => {
const view = groupCompose(rosterWith(SENTENCE))
await view.pick('Chief')
await view.pick('Scout')
let nodes = view.pane.draw()
typeLine(nodes, 'hello there')
await flush()
nodes = view.pane.draw()
assert.equal(sendButton(nodes), undefined, 'the composer draws no live Send control')
enterInComposer(nodes)
await flush()
nodes = view.pane.draw()
assert.deepEqual(view.calls, [], 'Enter writes nothing')
const line = readOnlyLine(nodes)
assert.equal(line.length, 1, "the host's own sentence is shown exactly once")
assert.equal(ownText(line[0]).trim(), SENTENCE)
})

test('a writable window still sends once from the composer', async () => {
for (const roster of [rosterWith(undefined), rosterWith(''), rosterWith(' ')]) {
const open = await groupSend(roster, ['Scout'])
Expand All @@ -221,3 +249,21 @@ test('a writable window still sends once from the composer', async () => {
assert.deepEqual(many.calls[2], ['open-session', 'r-new'], 'then open the new room')
}
})

// P-571b: the Add coding agent row is an ask the host answers `bot/read-only`, so a locked window
// must not offer it at all; a writable one keeps the row and it still posts.
test('a read-only New… menu draws no Add coding agent row', async () => {
const menu = menuPane(rosterWith(SENTENCE))
const nodes = menu.nodes()
assert.ok(nodes.some(optionRow('New group chat')), 'the menu itself is drawn')
assert.equal(nodes.some(optionRow('Add coding agent')), false, 'no Add coding agent row in a read-only window')
assert.deepEqual(menu.calls, [], 'drawing the menu writes nothing')
})

test('a writable New… menu keeps Add coding agent and asks once when it is pressed', async () => {
const menu = menuPane(rosterWith(undefined))
const nodes = menu.nodes()
press(nodes, optionRow('Add coding agent'))
await flush()
assert.deepEqual(menu.calls, [['add-agent']], 'one press asks add-agent exactly once')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ The member's own chat keeps its `busyAcks[sessionId]` bubble and its visibility
- Create Bot starts on the Main Bot's model (#230): `startModel()` (`bot-draft.js`) picks the Main Bot's chosen model, else the one it last ran on, when it is served; then `roster.defaultModel`, then the first served model. Only served models are picked, so a provider without a credential is never preselected.
- A read-only window draws no Create control in the new-Bot form (#2307): `CreateBot` (`create-bot.js`) reads `lockedWindow(roster)`, the one predicate the other surfaces use, and when it is locked the footer shows the host's own sentence (`.bt-hint.bt-read-only`, `hostText(roster.readOnly)`) where `Create` was, and `create()` returns before `create-bot`; the rest of the form stays as a preview. Both entries end in the same component — the sidebar **+** pane (`new-chat.js`) and Settings → Bots (`settings.js` inline) — so both are covered by the one gate. A writable window is unchanged. Test: `core/test/create-bot-readonly.test.js`.
- Group creation follows the same rule (#2496): `NewGroupForm` (`group-settings.js`) and the group mode of `NewChat` (`new-chat.js`) each read `lockedWindow(roster)`, keep their pickers as a preview, and put `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` where `Create` was; `create()` and `makeGroup()` return before `create-room`. The entries and the action layer are unchanged. Test: `core/test/group-create-readonly.test.js`.
- The composer's Send follows it (#2499): in a read-only window `sendMessage` returns before `send` and `create-room`, and `openBot` keeps the session open (a read) while dropping the unhide and the typed line; the composer puts `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` (`role="status"`) where the Send button was, drawn once (the group form's own line already stands when it picked an admin). A writable window sends exactly as before. Test: `core/test/composer-send-readonly.test.js`.
- The composer's Send follows it (#2499): in a read-only window `sendMessage` returns before `send` and `create-room`, and `openBot` keeps the session open (a read) while dropping the unhide and the typed line; the composer draws no Send button at all: where it was, it shows `hostText(roster.readOnly)` in a `.bt-hint.bt-read-only` (`role="status"`); in a group chat that already picked its admin the sentence stands on the To: line, where Create was, and the composer draws nothing, so it appears once. A writable window sends exactly as before. The New… menu follows the same lock (#2506): the short action menu in `NewChat` reads the one `lockedWindow` predicate and, in a read-only window, draws no **Add coding agent** row at all — `addAgent` is a write the host refuses with `bot/read-only` — while the other rows stay drawn as a preview and a writable window keeps the row, where one press asks `add-agent` exactly once. Test: `core/test/composer-send-readonly.test.js`.
- A missing model credential has its fix under the error (#405): `TurnErrorCell` (`model-menu.js`) shadows DSH's `turn-error` chat node (`locale: 'chat'`, so title and the AUTH / QUOTA / account copy are DSH's own strings). When the failure says the route has no API key (`missingModelKey`, e.g. `no API key for provider route "deepseek-official"`), the row says the Bot's model has no API key yet (the raw provider text is not drawn, not even on hover, since a provider may echo part of a key) and, for a `deepseek*` route, offers the same chips as a Bot with no model (`setupChoices`: Sign in to DeepSeek where the shell can, Enter a DeepSeek API key, Open settings to set up a custom model); any other or unnamed route gets Manage models. Under the DeepSeek chips (here and in the no-model dock) a **How to set a DeepSeek API key** link (`KeyHelpLink`, new tab) opens [docs/deepseek-api-key.md](../deepseek-api-key.md) on GitHub: DSH's own key dialog keeps only a typed or pasted key (#451), so the guide also gives the credentials file and `DEEPSEEK_API_KEY` ways. Every other failure reads as DSH writes it, with no buttons.
- **Models and providers…**, **Manage models** and **Open settings to set up a custom model** open DSH's Settings on its Models page (#407), not General: the `settings.launcher` seat's `openSettings()` takes no section, so `openHarnessModels()` (`sidebar.js`) calls the seat's `openOnboarding('flybot-models')`. That `settings.onboarding` step (`ModelsSettingsStep`, order 1000, after DSH's own steps) is handed `openSection`: opened on purpose (`explicit`) it calls `openSection('models')`, then `complete()`; in first-run onboarding it only completes. With no seat it falls back to `openHarnessSettings()`. FlyBot's own **Harness settings** entry still opens DSH settings as a whole.
- Settings say each thing once (#230): the Model and Main Bot cards show no row label that repeats the card title (`SwitchRow` `showLabel: false` keeps the switch's accessible name), the Create Bot dialog has its Back / Close in the header only (the footer Back / Cancel is for the inline form), and the account menu is as wide as its longest item (`width:max-content`, capped to the viewport) instead of cutting `Switch to classic Agent mode`.
Expand Down
18 changes: 11 additions & 7 deletions patches/ds-bot/0003-flybot-api-client.patch
Original file line number Diff line number Diff line change
Expand Up @@ -2343,7 +2343,7 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js
const text = message.trim()
if (group && admin !== null) { await makeGroup(); return }
if (text === '' || picked.length === 0) return
@@ -87,24 +87,51 @@ export function NewChat({ mode, roster, actions }) {
@@ -87,24 +87,52 @@ export function NewChat({ mode, roster, actions }) {
close()
return
}
Expand All @@ -2364,6 +2364,8 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js
- options.push({ key: 'new', icon: h(PlusIcon), label: t('Create new Bot'), run: createBot })
- options.push({ key: 'group', icon: h(PeopleIcon), label: t('Create group chat'), run: () => { setGroup(true); setCursor(0); inputRef.current?.focus() } })
- // A team holds at most one DSH Agent; the entry exists only while there is none.
- if ((roster.agents ?? []).length === 0) {
- options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) })
+ if (menuOnly) {
+ options.push({ key: 'chat', icon: h(ChatIcon), label: t('New chat'), hint: t('Message one of your Bots'), run: () => { setCompose(true); setCursor(0); setTimeout(() => inputRef.current?.focus()) } })
+ }
Expand All @@ -2383,9 +2385,9 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js
+ },
+ })
+ }
+ // A team holds at most one Agent; the entry exists only while there is none.
if ((roster.agents ?? []).length === 0) {
- options.push({ key: 'agent', icon: h(FishLogo, { size: 16 }), label: t('Add DSH Agent'), run: () => run(async () => { await actions.addAgent(); close() }) })
+ // A team holds at most one Agent; the entry exists only while there is none, and a read-only
+ // window does not offer it at all: the host answers `add-agent` with `bot/read-only` (P-571b).
+ if (!locked && (roster.agents ?? []).length === 0) {
+ options.push({ key: 'agent', icon: h(TerminalIcon), label: t('Add coding agent'), hint: t('A coding session without a Bot'), run: () => run(async () => { await actions.addAgent(); close() }) })
}
}
Expand Down Expand Up @@ -2483,13 +2485,15 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js
h('div', { className: 'bt-nc-card' },
h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)),
h('textarea', {
@@ -176,5 +176,7 @@ export function NewChat({ mode, roster, actions }) {
@@ -176,5 +176,9 @@ export function NewChat({ mode, roster, actions }) {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); sendMessage() }
},
}),
- h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon))))))
+ locked && !(group && admin !== null)
+ ? h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly))
+ locked
+ ? (group && admin !== null
+ ? null
+ : h('div', { className: 'bt-hint bt-read-only', role: 'status' }, hostText(roster.readOnly)))
+ : h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon))))))
}
diff --git a/src/client/overlay-hooks.js b/src/client/overlay-hooks.js
Expand Down
7 changes: 4 additions & 3 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -20587,6 +20587,8 @@ diff --git a/client.js b/client.js
setCursor(0);
inputRef.current?.focus();
} });
- if ((roster.agents ?? []).length === 0) {
- options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => {
+ for (const action of newChatActionRows({ mode, group, picked, query, roster })) {
+ options.push({
+ key: action.key,
Expand All @@ -20601,8 +20603,7 @@ diff --git a/client.js b/client.js
+ }
+ });
+ }
if ((roster.agents ?? []).length === 0) {
- options.push({ key: "agent", icon: (0, import_react20.createElement)(import_dsh_client_ui_primitives4.FishLogo, { size: 16 }), label: t("Add DSH Agent"), run: () => run(async () => {
+ if (!locked && (roster.agents ?? []).length === 0) {
+ options.push({ key: "agent", icon: (0, import_react34.createElement)(TerminalIcon), label: t("Add coding agent"), hint: t("A coding session without a Bot"), run: () => run(async () => {
await actions.addAgent();
close();
Expand Down Expand Up @@ -20774,7 +20775,7 @@ diff --git a/client.js b/client.js
}
}),
- (0, import_react20.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react20.createElement)(ArrowUpIcon))
+ locked && !(group && admin !== null) ? (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
+ locked ? group && admin !== null ? null : (0, import_react34.createElement)("div", { className: "bt-hint bt-read-only", role: "status" }, hostText(roster.readOnly)) : (0, import_react34.createElement)("button", { type: "button", className: "bt-nc-send", "aria-label": t("Send message"), disabled: busy || message.trim() === "" || picked.length === 0, onClick: sendMessage }, (0, import_react34.createElement)(ArrowUpIcon))
)
)
)
Expand Down
Loading
Loading