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/2499-composer-send-readonly.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
In a read-only window the new-chat composer no longer writes: pressing **Send** or Enter sends nothing, several picks create no room, and a picker row pressed with a line typed opens the session without sending that line; the host's own read-only sentence stands where the **Send** button was. A writable window is unchanged ([#2499](https://github.com/ThinkFlowLab/FlyBot/issues/2499)).
9 changes: 6 additions & 3 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -16070,8 +16070,10 @@ function NewChat({ mode, roster, actions }) {
pick2(bot);
return;
}
if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
if (message.trim()) await actions.send(bot.id, message.trim());
if (!locked) {
if (bot.hidden) await actions.setFlags(bot.id, { hidden: false });
if (message.trim()) await actions.send(bot.id, message.trim());
}
actions.openSession(bot.id);
close();
});
Expand Down Expand Up @@ -16099,6 +16101,7 @@ function NewChat({ mode, roster, actions }) {
};
const createGroup = () => run(makeGroup);
const sendMessage = () => run(async () => {
if (locked) return;
const text2 = message.trim();
if (group && admin !== null) {
await makeGroup();
Expand Down Expand Up @@ -16321,7 +16324,7 @@ ${listed}`, { axis: "y", ...QUICK_EDGES });
}
}
}),
(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) ? (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
16 changes: 11 additions & 5 deletions core/src/client/new-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ export function NewChat({ mode, roster, actions }) {
const paneLeft = usePaneLeft()
const wanted = query.trim().toLowerCase()
const bots = listedChatBots(roster)
// A read-only window keeps the group pickers as a preview, offers no create, and sends nothing
// from them: the host could only answer `bot/read-only` after the press (P-570).
// A read-only window keeps the pickers as a preview, offers no create and no Send, and writes
// nothing: the host could only answer `bot/read-only` after the press (P-570, P-571).
const locked = lockedWindow(roster)
const choosingAdmin = group && admin === null
const pool = choosingAdmin ? bots.filter(bot => !bot.remote) : bots
Expand Down Expand Up @@ -105,8 +105,11 @@ export function NewChat({ mode, roster, actions }) {
const openBot = bot => run(async () => {
if (choosingAdmin) { chooseAdmin(bot); return }
if (group || picked.length > 0) { pick(bot); return }
if (bot.hidden) await actions.setFlags(bot.id, { hidden: false })
if (message.trim()) await actions.send(bot.id, message.trim())
// Opening the session is a read and stays; unhiding the Bot and sending the typed line are writes.
if (!locked) {
if (bot.hidden) await actions.setFlags(bot.id, { hidden: false })
if (message.trim()) await actions.send(bot.id, message.trim())
}
actions.openSession(bot.id)
close()
})
Expand Down Expand Up @@ -140,6 +143,7 @@ export function NewChat({ mode, roster, actions }) {
}
const createGroup = () => run(makeGroup)
const sendMessage = () => run(async () => {
if (locked) return
const text = message.trim()
if (group && admin !== null) { await makeGroup(); return }
if (text === '' || picked.length === 0) return
Expand Down Expand Up @@ -291,5 +295,7 @@ 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))
: h('button', { type: 'button', className: 'bt-nc-send', 'aria-label': t('Send message'), disabled: busy || message.trim() === '' || picked.length === 0, onClick: sendMessage }, h(ArrowUpIcon))))))
}
223 changes: 223 additions & 0 deletions core/test/composer-send-readonly.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// P-571: a read-only window's composer still wrote. `sendMessage` sent `send` for one pick and
// `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.
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 { NewChat } from './src/client/new-chat.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: what the composer draws and what a press sends have to
// come out of the component's own tree, not a re-implementation of it.
let slots = []
let index = 0
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] = fn },
useLayoutEffect() {},
useSyncExternalStore: (_subscribe, get) => get(),
}
const win = { location: { hash: '' }, innerHeight: 800, addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) }
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, console, setTimeout, clearTimeout, setImmediate, queueMicrotask,
requestAnimationFrame: () => 0, cancelAnimationFrame() {},
document: { hidden: false, addEventListener() {}, removeEventListener() {}, body: {}, querySelector: () => null },
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 { NewChat } = module.exports

const chief = { id: 'chief', name: 'Chief', remote: false }
const scout = { id: 'scout', name: 'Scout', remote: false }
const third = { id: 'third', name: 'Third', remote: false }
const base = {
bots: [chief, scout, third], byId: { chief, scout, third }, rooms: [], roomsById: {}, agents: [],
prefs: {}, mainBotIds: ['chief'], connectors: [], memory: false, exchangeTurns: {}, questions: {},
}
const SENTENCE = 'Another FlyBot window has this Bot team open, so this window is read-only.'
const rosterWith = readOnly => (readOnly === undefined ? { ...base } : { ...base, readOnly })
const actionsOf = () => {
const calls = []
return {
calls,
actions: {
createRoom: async (members, options) => { calls.push(['create-room', members, options]); return { id: 'r-new', name: 'New group' } },
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' }),
openSettings() {}, openPalette() {},
},
}
}
const expand = (node) => {
if (Array.isArray(node)) return node.map(expand)
if (!node || typeof node !== 'object') return node
const props = node.props ?? {}
if (typeof node.type === 'function') return expand(node.type(props))
return { ...node, props: { ...props, children: expand(props.children) } }
}
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 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(node?.props?.children)
}
const hasClass = (node, name) => String(node.props?.className ?? '').split(/\s+/).includes(name)
const flush = () => new Promise(resolve => setImmediate(resolve))

// Each mount starts from empty slots so a pane never reads another mount's state.
const mount = (props) => {
slots = []
const draw = () => {
index = 0
return all(expand(NewChat(props)))
}
return { draw }
}
const press = (nodes, predicate) => {
const node = nodes.find(predicate)
assert.ok(node, 'the control under test is drawn')
node.props.onClick?.()
return node
}
const optionRow = name => node => node.props?.role === 'option' && ownText(node).includes(name)
const textarea = nodes => nodes.find(node => node.type === 'textarea')
const typeLine = (nodes, text) => {
const field = textarea(nodes)
assert.ok(field, 'the composer is drawn')
field.props.onChange({ target: { value: text } })
}
const sendButton = nodes => nodes.find(node => hasClass(node, 'bt-nc-send'))
const readOnlyLine = nodes => nodes.filter(node => hasClass(node, 'bt-read-only'))
const enterInComposer = (nodes) => {
const field = textarea(nodes)
assert.ok(field, 'the composer is drawn')
field.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: false }, preventDefault() {} })
}
// New… → New group chat picks its admin first, then the other Bots; the admin token's own × puts
// the admin back to none, which is the shape whose Send reaches `sendMessage`: one pick sends to
// that Bot, several make the room without an admin.
const groupCompose = (roster) => {
const { calls, actions } = actionsOf()
const pane = mount({ mode: 'group', roster, actions })
const pick = async (name) => {
const nodes = pane.draw()
press(nodes, optionRow(name))
await flush()
}
const dropAdmin = async () => {
const nodes = pane.draw()
const token = nodes.find(node => node.props?.['aria-label'] === 'Remove Chief')
assert.ok(token, 'the admin token is drawn with its own remove control')
token.props.onClick()
await flush()
}
const send = async ({ enter = false } = {}) => {
const nodes = pane.draw()
typeLine(nodes, 'hello there')
await flush()
const drawn = pane.draw()
if (enter) enterInComposer(drawn)
else {
const button = sendButton(drawn)
if (button && !button.props.disabled) button.props.onClick()
}
await flush()
return { nodes: pane.draw(), send: sendButton(pane.draw()) }
}
return { calls, pane, pick, dropAdmin, send }
}
const groupSend = async (roster, picks, { enter = false } = {}) => {
const view = groupCompose(roster)
await view.pick('Chief')
for (const name of picks) await view.pick(name)
await view.dropAdmin()
const sent = await view.send({ enter })
return { calls: view.calls, ...sent }
}
// 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()
const pane = mount({ mode: 'search', roster, actions })
let nodes = pane.draw()
typeLine(nodes, 'hello there')
await flush()
nodes = pane.draw()
press(nodes, optionRow(name))
await flush()
return { calls, nodes }
}

test('a read-only window sends nothing when one Bot is picked and Send is pressed', async () => {
const locked = await groupSend(rosterWith(SENTENCE), ['Scout'])
assert.deepEqual(locked.calls, [], 'no write action reaches the host')
assert.equal(locked.send, undefined, 'no live Send control is drawn')
const line = readOnlyLine(locked.nodes)
assert.equal(line.length, 1, 'the composer says why it cannot send')
assert.equal(ownText(line[0]).trim(), SENTENCE, "the host's own sentence is shown")
assert.equal(line[0].props.role, 'status')
})

test('a read-only window creates no room when several Bots are picked and Enter is pressed', async () => {
const locked = await groupSend(rosterWith(SENTENCE), ['Scout', 'Third'], { enter: true })
assert.deepEqual(locked.calls, [], 'no create-room and no send')
assert.equal(readOnlyLine(locked.nodes).length, 1, 'the read-only sentence is shown once')
})

test('a read-only window sends nothing when a picker row is pressed with a line typed', async () => {
const locked = await searchThenOpen(rosterWith(SENTENCE), 'Chief')
const writes = locked.calls.filter(call => call[0] !== 'open-session')
assert.deepEqual(writes, [], 'the picker row writes nothing')
assert.ok(locked.calls.some(call => call[0] === 'open-session' && call[1] === 'chief'), 'opening the session may stay')
})

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'])
assert.equal(open.send?.props?.disabled, false, 'Send is drawn and enabled')
assert.deepEqual(open.calls, [['send', 'scout', 'hello there'], ['open-session', 'scout']], 'one Send reaches the host')
assert.equal(readOnlyLine(open.nodes).length, 0, 'no read-only line in a writable window')

const many = await groupSend(roster, ['Scout', 'Third'], { enter: true })
assert.equal(many.calls[0][0], 'create-room', 'several picks make the room')
assert.deepEqual(many.calls[1], ['send', 'r-new', 'hello there'], 'then send the line')
assert.deepEqual(many.calls[2], ['open-session', 'r-new'], 'then open the new room')
}
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +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`.
- 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
Loading
Loading