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/2411-client-empty-error-message.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A refusal the host sends with no message of its own no longer reaches the screen as a blank error: the client's `api/bot` wrapper answers it with `Request failed`, so a surface that prints the failure says what went wrong instead of drawing nothing — and a Bot's memory row settles on that sentence instead of sitting on `Loading…` ([#2411](https://github.com/ThinkFlowLab/FlyBot/issues/2411)).
4 changes: 2 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1730,10 +1730,10 @@ var call = async (endpoint, payload, signal) => {
const result = await response.json();
if (!result.ok) {
const code = result.error?.code ?? "bot/error";
const message = hostText(result.error?.message ?? "Request failed");
const message = hostText(result.error?.message || "Request failed");
const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details });
if (!REFUSALS.has(code)) {
noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code }));
noteClientError(endpoint, Object.assign(new Error(result.error?.message || "Request failed"), { code }));
markNoted(error);
}
throw error;
Expand Down
4 changes: 2 additions & 2 deletions core/src/client/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ export const call = async (endpoint, payload, signal) => {
const result = await response.json()
if (!result.ok) {
const code = result.error?.code ?? 'bot/error'
const message = hostText(result.error?.message ?? 'Request failed')
const message = hostText(result.error?.message || 'Request failed')
const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details })
if (!REFUSALS.has(code)) {
noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code }))
noteClientError(endpoint, Object.assign(new Error(result.error?.message || 'Request failed'), { code }))
markNoted(error)
}
throw error
Expand Down
161 changes: 161 additions & 0 deletions core/test/api-empty-message.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// A host refusal whose `message` is an empty string was thrown as `new Error('')`: `??` keeps an
// empty string, and `hostText('')` returns it unchanged. Every consumer that prints
// `error.message` then draws nothing, and the memory row states its count through
// `if (failure)`, so an empty failure is falsy and the row settles on "Loading…" for a read that
// already failed (#2411). A refusal with no sentence of its own must still read as a refusal.
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 { call } from './src/client/api.js'",
"export { MemoryRow } from './src/client/memory-panel.js'",
"export { hostText } from './src/client/i18n.js'",
"export { clientErrors, clearClientErrors } from './src/client/diagnostics.js'",
].join('\n'),
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) } })
// The row counts through an async effect, so it is drawn once as "Loading…" and again with the
// view. The hook runtime keeps its slots across a re-render, like the renderer's own.
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() {} }) }
// One fetch stub per call: the api wrapper posts every request through the browser's own fetch.
let answer = null
const fetchStub = async () => answer
const module = { exports: {} }
runInNewContext(outputFiles[0].text, {
module, exports: module.exports, console, setTimeout, clearTimeout, setInterval: () => 0, clearInterval() {},
requestAnimationFrame: () => 0, cancelAnimationFrame() {},
document: { hidden: false, addEventListener() {}, removeEventListener() {} },
navigator: { language: 'en' },
localStorage: { getItem: () => null, setItem() {} },
fetch: fetchStub,
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 { call, MemoryRow, hostText, clientErrors, clearClientErrors } = module.exports

// The row's tree holds component elements around the hook, so a draw pass renders every function
// component the way the renderer would, with one hook index continuing through the tree.
const draw = (node) => {
if (Array.isArray(node)) return node.map(draw)
if (!node || typeof node !== 'object') return node
if (typeof node.type === 'function') return draw(node.type(node.props))
if (node.props?.children === undefined) return node
return { ...node, props: { ...node.props, children: draw(node.props.children) } }
}
const render = (props) => { index = 0; return draw(MemoryRow(props)) }
const reset = () => { slots = []; effects = []; index = 0 }
const flush = async () => {
const queued = effects
effects = []
for (const fn of queued) await fn()
for (let turn = 0; turn < 6; turn += 1) await Promise.resolve()
}
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 textOf = (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 byClass = (node, name) => all(node).find(item => typeof item.props?.className === 'string' && item.props.className.split(' ').includes(name))

// The host's own reply shape: an HTTP 200 whose body is `{ ok: false, error: { code, message, details } }`.
const refusal = (code, message, details) => ({ ok: true, status: 200, json: async () => ({ ok: false, error: { code, message, details } }) })

async function rejection(promise) {
try { await promise } catch (error) { return error }
throw new Error('the call resolved, but the host refused it')
}

async function rowOf(botId, memoryView) {
reset()
const props = { bot: { id: botId, name: 'Scout' }, actions: { memoryView, openMemory() {} } }
render(props)
await flush()
const tree = render(props)
const copy = byClass(tree, 'bt-mem-row-copy')
assert.ok(copy, 'the row draws its count line')
return { tree, label: textOf(copy) }
}

test('#2411: a refusal with an empty message is thrown as a readable error', async () => {
answer = refusal('bot/refused', '')
const error = await rejection(call('memory/view', { botId: 'bot-a' }))
assert.equal(typeof error.message, 'string')
assert.notEqual(error.message, '', 'an empty host message must not become an empty error')
assert.equal(error.message, 'Request failed', 'the refusal keeps a sentence of its own')
assert.equal(error.code, 'bot/refused', 'the code still passes through')
})

test('#2411: the memory row says the read failed instead of "Loading…"', async () => {
answer = refusal('bot/refused', '')
const { label } = await rowOf('bot-empty-refusal', () => call('memory/view', { botId: 'bot-empty-refusal' }))
assert.doesNotMatch(label, /Loading/, 'a settled failure never draws the pending text')
assert.equal(label, `The memory could not be read: ${hostText('Request failed')}`)
})

test('#2411: the fallback also covers a code that is not a refusal', async () => {
clearClientErrors()
answer = refusal('bot/error', '', { ticket: 'x-1' })
const error = await rejection(call('memory/view', {}))
assert.equal(error.message, 'Request failed', 'the noted copy is not empty either')
assert.deepEqual(error.details, { ticket: 'x-1' }, 'details still pass through')
const noted = clientErrors()
assert.ok(noted.length > 0, 'a non-refusal is still noted for the diagnostics')
const last = noted.at(-1)
assert.match(last.text, /Request failed$/, 'the diagnostics line names the failure, not an empty string')
assert.notEqual(last.text, 'memory/view: ', 'no dangling separator for an empty sentence')
})

test('#2411: an internal failure no longer leaves a leading space', async () => {
answer = refusal('bot/internal', '')
const error = await rejection(call('memory/view', {}))
assert.doesNotMatch(error.message, /^ /, 'the sentence starts with a word, not a space')
assert.match(error.message, /^Request failed /, 'the fallback sentence leads the note')
})

test('#2411: a non-empty host message is drawn byte-for-byte', async () => {
answer = refusal('bot/refused', 'Unknown Bot')
const error = await rejection(call('memory/view', {}))
assert.equal(error.message, 'Unknown Bot', 'the fix does not rewrite a sentence the host sent')
const { label } = await rowOf('bot-host-sentence', () => call('memory/view', {}))
assert.equal(label, 'The memory could not be read: Unknown Bot')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl
- Chat context rows: a tool the plugin registers at install draws one `tool-addition` row, and `cells.js` keeps every name in `TEAM_TOOLS` out of the chat (`SILENT_REGISTRATION_TOOLS`, #1101) — the Project Assistant's `project_digest` joins them (#1712), so its 09:00 Session shows the digest, not its registration.
- A Settings dialog takes focus when it opens (`useDialogFocus` in `core/src/client/overlay-hooks.js`; the `.bt-settings` dialog carries `tabIndex -1` and a ref) and gives it back to the still-connected opener when it closes; it is not a focus trap, so Tab can still leave the dialog for the page behind the scrim (#2251). It is drawn for `DIALOG_EXIT_MS` (160 ms) with `pointer-events: none` on the leaving layer, so the unmount restores focus only while the dialog still holds it (or nothing does) — a click on the page during that window keeps its focus (#2270).
- Settings → Feedback's error list (`feedback.js`) still titles the block with the whole log (`Recent errors · 60`) but lists only the newest eight, so when the report holds more rows one `Show more` control (`aria-expanded`) sits under them and draws every loaded error, folding back to `Show less`; a report of eight or fewer draws no control (#2266).
- An error toast keeps the host's code: `reportError` in `index.js` puts `error.code` into the toast payload and `feedback.js` draws it as a `bt-tag bt-toast-code`, so a reader can tell `bot/read-only` from any other refusal. That code is also the one action a toast can take: a `bot/read-only` toast offers **Reload** (`bt-toast-reload`) in place, which dismisses the toast and runs the client's own full-state read (`actions.reloadTeam`), so the window becomes writable once the other holder let go without a page reload that would drop the composer; a failed read goes to `actions.reportError(error, 'reload')` the way the sidebar's Reload does. Every other code, and a toast with no code, draws the sentence and **Details** exactly as before (P-502).
- An error toast keeps the host's code: `reportError` in `index.js` puts `error.code` into the toast payload and `feedback.js` draws it as a `bt-tag bt-toast-code`, so a reader can tell `bot/read-only` from any other refusal. That code is also the one action a toast can take: a `bot/read-only` toast offers **Reload** (`bt-toast-reload`) in place, which dismisses the toast and runs the client's own full-state read (`actions.reloadTeam`), so the window becomes writable once the other holder let go without a page reload that would drop the composer; a failed read goes to `actions.reportError(error, 'reload')` the way the sidebar's Reload does. Every other code, and a toast with no code, draws the sentence and **Details** exactly as before (P-502). That sentence is never blank: `api.js` answers a host refusal that carries no message of its own with `Request failed` (`result.error?.message || 'Request failed'` on the thrown error and on the noted copy alike), so a surface that prints `error.message` shows a refusal rather than nothing, and a memory row whose count sentence tests the failure for truth settles on it instead of `Loading…` (#2411). Tests: `core/test/api-empty-message.test.js`.
- A link picked in a chat message opens in FlyBot's own panel: a capture-phase click handler on each bubble and on the person's own line (`bubbleLinkClick` in `cells.js`; a group card's container carries none, so a reply's link opens the tab of the Bot that spoke) takes an `http(s)` anchor before the host's delegate does, puts a tab in the panel's browser view (the globe button's view) with `actions.browserNewTab` and carries the URL with `actions.browserNavigate`; `browserNewTab` answers `null` for a Bot outside the roster, and with no tab — or no Desktop bridge — the page opens in the window itself. A mention, a file link and a modified or middle click are left alone, and the bubble renders bare `MarkdownText` so the host's `openFile`/`fileImages` reach it: a nested `MarkdownDelegateProvider` replaces the host's capabilities, and one that lacked `fileImages` drew every bubble image as its alt text (#2319). Left to the host a click opened a tab in the host's right sidebar, which this panel's fixed layer covers, so the content area looked blank (#2201). Tests: `core/test/chat-link-open.test.js`.

## Does not own
Expand Down
11 changes: 8 additions & 3 deletions patches/ds-bot/0008-client-bundle.patch
Original file line number Diff line number Diff line change
Expand Up @@ -1138,15 +1138,20 @@ diff --git a/client.js b/client.js
body: JSON.stringify({ endpoint, payload: payload ?? null }),
signal
});
@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => {
@@ -1086,10 +1086,10 @@ var call = async (endpoint, payload, signal) => {
const result = await response.json();
if (!result.ok) {
const code = result.error?.code ?? "bot/error";
const message = hostText(result.error?.message ?? "Request failed");
- const message = hostText(result.error?.message ?? "Request failed");
- const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code });
+ const message = hostText(result.error?.message || "Request failed");
+ const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details });
if (!REFUSALS.has(code)) {
noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code }));
- noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code }));
+ noteClientError(endpoint, Object.assign(new Error(result.error?.message || "Request failed"), { code }));
markNoted(error);
}
throw error;
@@ -1097,6 +1097,406 @@ var call = async (endpoint, payload, signal) => {
return result.value;
};
Expand Down
11 changes: 8 additions & 3 deletions patches/ds-bot/0012-room-membership.patch
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ diff --git a/src/client/api.js b/src/client/api.js
body: JSON.stringify({ endpoint, payload: payload ?? null }),
signal,
})
@@ -31,7 +31,7 @@ export const call = async (endpoint, payload, signal) => {
@@ -30,10 +30,10 @@ export const call = async (endpoint, payload, signal) => {
const result = await response.json()
if (!result.ok) {
const code = result.error?.code ?? 'bot/error'
const message = hostText(result.error?.message ?? 'Request failed')
- const message = hostText(result.error?.message ?? 'Request failed')
- const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code })
+ const message = hostText(result.error?.message || 'Request failed')
+ const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details })
if (!REFUSALS.has(code)) {
noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code }))
- noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code }))
+ noteClientError(endpoint, Object.assign(new Error(result.error?.message || 'Request failed'), { code }))
markNoted(error)
}
throw error
diff --git a/src/client/composer.js b/src/client/composer.js
--- a/src/client/composer.js
+++ b/src/client/composer.js
Expand Down
Loading
Loading