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/2312-readonly-toast-code.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A refused action away from any form now names the host's own error code in its toast, so a second-holder refusal reads `bot/read-only` instead of leaving the reader to guess what failed. When the code is `bot/read-only` the toast also carries **Reload** in place: it asks for the client's full-state read, so the window becomes writable once the other holder let go, without a page reload that would drop what is in the composer. Every other failure keeps the sentence and **Details** it had. ([#2312](https://github.com/ThinkFlowLab/FlyBot/issues/2312))
11 changes: 10 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -18854,10 +18854,17 @@ function ErrorToast({ useUi, actions }) {
}, [toast?.token]);
if (!toast) return null;
const info = toast.tone === "info";
const readOnly = toast.code === "bot/read-only";
const reload = () => {
actions.dismissToast(toast.token);
void Promise.resolve(actions.reloadTeam?.()).catch((error) => actions.reportError?.(error, "reload"));
};
return (0, import_react40.createElement)(
"div",
{ className: "bt-toast", role: info ? "status" : "alert", "data-tone": toast.tone, "data-leaving": leaving || void 0 },
(0, import_react40.createElement)("span", { className: "bt-toast-text" }, toast.text),
toast.code ? (0, import_react40.createElement)("span", { className: "bt-tag bt-toast-code" }, toast.code) : null,
readOnly ? (0, import_react40.createElement)("button", { type: "button", className: "bt-toast-link bt-toast-reload", onClick: reload }, t("Reload")) : null,
info ? null : (0, import_react40.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => {
actions.dismissToast(toast.token);
actions.openSettings("feedback");
Expand Down Expand Up @@ -18887,6 +18894,7 @@ var FEEDBACK_CSS = `
.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
.bt-toast .bt-toast-code{max-width:180px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
.bt-toast-close{flex:none}
@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
Expand Down Expand Up @@ -22817,7 +22825,8 @@ function apply(ctx) {
// A failed action away from any form (a menu, the palette) shows as a toast.
reportError: (error, where = "action") => {
noteClientError(where, error);
ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } }));
const code = typeof error?.code === "string" ? error.code : void 0;
ui2.set((state) => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now(), ...code ? { code } : {} } }));
},
// A short confirmation (e.g. "Invite link copied") in the same toast, without the error styling.
notify: (text2) => ui2.set((state) => ({ ...state, toast: { text: String(text2), tone: "info", token: Date.now() } })),
Expand Down
11 changes: 11 additions & 0 deletions core/src/client/feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,18 @@ export function ErrorToast({ useUi, actions }) {
}, [toast?.token])
if (!toast) return null
const info = toast.tone === 'info'
// A read-only refusal is the one failure the window can end by itself: the other holder is gone,
// so the client's full-state read makes this window writable without a page reload that would
// drop what is in the composer.
const readOnly = toast.code === 'bot/read-only'
const reload = () => {
actions.dismissToast(toast.token)
void Promise.resolve(actions.reloadTeam?.()).catch(error => actions.reportError?.(error, 'reload'))
}
return h('div', { className: 'bt-toast', role: info ? 'status' : 'alert', 'data-tone': toast.tone, 'data-leaving': leaving || undefined },
h('span', { className: 'bt-toast-text' }, toast.text),
toast.code ? h('span', { className: 'bt-tag bt-toast-code' }, toast.code) : null,
readOnly ? h('button', { type: 'button', className: 'bt-toast-link bt-toast-reload', onClick: reload }, t('Reload')) : null,
info ? null : h('button', { type: 'button', className: 'bt-toast-link', onClick: () => { actions.dismissToast(toast.token); actions.openSettings('feedback') } }, t('Details')),
h('button', { type: 'button', className: 'bt-icon-btn bt-toast-close', 'aria-label': t('Dismiss'), onClick: () => actions.dismissToast(toast.token) }, h(CloseIcon)))
}
Expand All @@ -190,6 +200,7 @@ export const FEEDBACK_CSS = `
.bt-toast-text::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:#e02135;margin:0 8px 1px 0}
.bt-toast-link{flex:none;border:0;background:none;color:var(--bt-ink-2);font:inherit;font-size:12px;cursor:pointer;padding:4px 6px;border-radius:6px}
.bt-toast-link:hover{background:var(--bt-hover);color:var(--bt-ink)}
.bt-toast .bt-toast-code{max-width:180px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px}
.bt-toast-close{flex:none}
@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}}
@media (prefers-reduced-motion:reduce){.bt-toast{animation:none}.bt-toast[data-leaving]{transition:none}}
Expand Down
5 changes: 4 additions & 1 deletion core/src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,10 @@ export function apply(ctx) {
// A failed action away from any form (a menu, the palette) shows as a toast.
reportError: (error, where = 'action') => {
noteClientError(where, error)
ui.set(state => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now() } }))
// The code is what the toast can act on: a read-only refusal is answered by reading the team
// again, and its sentence never says `bot/read-only`.
const code = typeof error?.code === 'string' ? error.code : undefined
ui.set(state => ({ ...state, toast: { text: error?.message ?? String(error), token: Date.now(), ...(code ? { code } : {}) } }))
},
// A short confirmation (e.g. "Invite link copied") in the same toast, without the error styling.
notify: text => ui.set(state => ({ ...state, toast: { text: String(text), tone: 'info', token: Date.now() } })),
Expand Down
180 changes: 180 additions & 0 deletions core/test/error-toast-code.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// P-502: a refused action away from any form ("The Bot team is open in another dsh.") landed in
// the toast as its sentence alone. `reportError` built the payload as `{ text, token }`, so the
// host's code — `bot/read-only` — was dropped on the way, and the reader had no way to tell a
// second-holder refusal from any other failure. The sentence also told them to reload while the
// only Reload sat in the sidebar, behind the scrim that the refusal itself raises.
//
// The toast now carries the code, draws it, and for `bot/read-only` offers the client's own
// full-state read (`actions.reloadTeam`) in place. The window becomes writable without a page
// reload that would drop what is in the composer.
//
// The toast is rendered with real hooks; a `useEffect() {}` stub would never install its timers.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { runInNewContext } from 'node:vm'
import { build } from 'esbuild'

const { outputFiles } = await build({
stdin: {
contents: "export { ErrorToast } from './src/client/feedback.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) } })
const all = (node, out = []) => {
if (Array.isArray(node)) { for (const child of node) all(child, out); return out }
if (node && typeof node === 'object') {
out.push(node)
for (const child of [node.props?.children].flat(Infinity)) all(child, out)
}
return out
}
const text = node => [node?.props?.children].flat(Infinity)
.filter(child => typeof child === 'string' || typeof child === 'number')
.join('')
const byClass = (tree, name) => all(tree).filter(node => {
const value = node?.props?.className
return typeof value === 'string' && value.split(' ').includes(name)
})

// Just enough of the hooks runtime for a component with state and an effect.
function hooks() {
const states = []
const effects = []
let cursor = 0
return {
react: {
createElement: h,
Fragment: Symbol('Fragment'),
Component: class {},
useState: (initial) => {
const slot = cursor++
if (!(slot in states)) states[slot] = typeof initial === 'function' ? initial() : initial
return [states[slot], (value) => { states[slot] = typeof value === 'function' ? value(states[slot]) : value }]
},
useEffect: (fn) => { const slot = cursor++; if (!effects.includes(slot)) { effects.push(slot); fn() } },
useLayoutEffect: (fn) => { const slot = cursor++; if (!effects.includes(slot)) { effects.push(slot); fn() } },
useMemo: make => make(),
useCallback: fn => fn,
useRef: value => ({ current: value }),
},
}
}

const context = () => ({
module: { exports: {} },
exports: {},
document: { addEventListener() {}, removeEventListener() {}, activeElement: null, querySelector: () => null },
navigator: { language: 'en' },
localStorage: { getItem: () => null, setItem() {} },
window: { addEventListener() {}, removeEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) },
console, Date, JSON, Promise, Symbol,
// The toast's own timers only decide when it fades; the test drives every press itself.
setTimeout: () => 0,
clearTimeout: () => {},
__flybot__: { slots: { entries: () => [], subscribe: () => () => {}, version: () => 0 }, call: () => {} },
require: (name) => {
if (name === 'react') return hooks().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 load = () => {
const sandbox = context()
const runtime = hooks()
sandbox.require = (name) => {
if (name === 'react') return runtime.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}`)
}
sandbox.module.exports = sandbox.exports
runInNewContext(outputFiles[0].text, sandbox)
return sandbox.module.exports
}

const { ErrorToast } = load()

function mount(toast, { reloadTeam, reportError } = {}) {
const dismissed = []
const actions = {
dismissToast: token => dismissed.push(token),
openSettings() {},
reloadTeam,
reportError,
}
const tree = ErrorToast({ useUi: select => select({ toast }), actions })
return { tree, dismissed, byClass: name => byClass(tree, name), text }
}

const READ_ONLY = 'The Bot team is open in another dsh.'

test('P-502: the toast names the code the host sent', () => {
const view = mount({ text: READ_ONLY, token: 7, code: 'bot/read-only' })
const tags = view.byClass('bt-toast-code')
assert.equal(tags.length, 1, 'the refusal code is drawn once')
assert.equal(view.text(tags[0]), 'bot/read-only', 'the reader sees the host\'s own code')
assert.ok(view.text(view.byClass('bt-toast-text')[0]).includes('open in another dsh'), 'the sentence is still the toast\'s copy')
})

test('P-502: a read-only toast offers a Reload that reads the team again in place', async () => {
let calls = 0
const view = mount({ text: READ_ONLY, token: 7, code: 'bot/read-only' }, { reloadTeam: () => { calls += 1; return Promise.resolve() } })
const buttons = view.byClass('bt-toast-reload')
assert.equal(buttons.length, 1, 'the toast carries the way out of the refusal')
const [button] = buttons
assert.equal(button.type, 'button', 'it is a button')
assert.equal(button.props.type, 'button', 'it does not submit anything')
assert.equal(view.text(button), 'Reload', 'its label comes from the shared copy')
button.props.onClick()
await Promise.resolve()
await Promise.resolve()
assert.equal(calls, 1, 'one press reads the team once')
assert.deepEqual(view.dismissed, [7], 'the refusal leaves once the read has started')
})

test('P-502: another failure keeps its code and offers no Reload', () => {
const view = mount({ text: 'The Bot refused that.', token: 8, code: 'bot/refused' })
const tags = view.byClass('bt-toast-code')
assert.equal(tags.length, 1)
assert.equal(view.text(tags[0]), 'bot/refused', 'any code the host sent is drawn')
assert.equal(view.byClass('bt-toast-reload').length, 0, 'only the read-only refusal can be answered by a reload')
})

test('P-502: a toast with no code draws no tag and keeps its Details link', () => {
const view = mount({ text: 'Choose a Bot first.', token: 9 })
assert.equal(view.byClass('bt-toast-code').length, 0, 'no code, no tag')
assert.equal(view.byClass('bt-toast-reload').length, 0)
const links = view.byClass('bt-toast-link')
assert.equal(links.length, 1, 'the Details entry is unchanged')
assert.equal(view.text(links[0]), 'Details')
})

test('P-502: a reload that fails says so through the error channel, not an unhandled rejection', async () => {
const failure = new Error('network error: Failed to fetch')
const seen = []
const view = mount({ text: READ_ONLY, token: 11, code: 'bot/read-only' }, {
reloadTeam: () => Promise.reject(failure),
reportError: (error, where) => seen.push([error, where]),
})
view.byClass('bt-toast-reload')[0].props.onClick()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
assert.deepEqual(seen, [[failure, 'reload']], 'the failure goes to the same channel the sidebar reload uses')
})

test('P-502: reportError keeps the host code in the toast payload it sets', () => {
const source = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
const at = source.indexOf('reportError: (error, where')
assert.notEqual(at, -1, 'the client still registers reportError')
const body = source.slice(at, source.indexOf('\n notify:', at))
assert.match(body, /toast:\s*\{[^}]*\bcode\b/, 'the payload carries the code, or no toast can ever name bot/read-only')
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,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).
- 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
Loading
Loading