From 6d18a0a8fbb5f5c5a2c0b0435b183c2566730a44 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:49:39 +0800 Subject: [PATCH] client: the error toast keeps the host's code and offers Reload on a read-only refusal A refused action away from any form lost the host's code, so a second-holder refusal read as a bare sentence with no way to act on it. reportError now carries error.code into the toast and a bot/read-only toast offers Reload, which runs the client's own full-state read instead of a page reload that would drop the composer. --- changelog.d/2312-readonly-toast-code.fixed.md | 1 + core/client.js | 11 +- core/src/client/feedback.js | 11 ++ core/src/client/index.js | 5 +- core/test/error-toast-code.test.js | 180 ++++++++++++++++++ docs/subsystems/core-client.md | 1 + patches/ds-bot/0001-core-fixes.patch | 15 +- patches/ds-bot/0003-flybot-api-client.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 68 ++++--- .../files/test/error-toast-code.test.js | 180 ++++++++++++++++++ 10 files changed, 448 insertions(+), 34 deletions(-) create mode 100644 changelog.d/2312-readonly-toast-code.fixed.md create mode 100644 core/test/error-toast-code.test.js create mode 100644 patches/ds-bot/files/test/error-toast-code.test.js diff --git a/changelog.d/2312-readonly-toast-code.fixed.md b/changelog.d/2312-readonly-toast-code.fixed.md new file mode 100644 index 000000000..7caea7244 --- /dev/null +++ b/changelog.d/2312-readonly-toast-code.fixed.md @@ -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)) diff --git a/core/client.js b/core/client.js index e091a5807..15be36923 100644 --- a/core/client.js +++ b/core/client.js @@ -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"); @@ -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}} @@ -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() } })), diff --git a/core/src/client/feedback.js b/core/src/client/feedback.js index 4d4d80778..c284d958e 100644 --- a/core/src/client/feedback.js +++ b/core/src/client/feedback.js @@ -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))) } @@ -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}} diff --git a/core/src/client/index.js b/core/src/client/index.js index 5258d6520..ec06591b2 100644 --- a/core/src/client/index.js +++ b/core/src/client/index.js @@ -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() } })), diff --git a/core/test/error-toast-code.test.js b/core/test/error-toast-code.test.js new file mode 100644 index 000000000..6848dee02 --- /dev/null +++ b/core/test/error-toast-code.test.js @@ -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') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 34af7041d..1e4e4bb8c 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -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 diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index ec1b2fb5f..f651d5c4a 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -618,20 +618,30 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js ] } -@@ -133,9 +133,10 @@ export function ErrorToast({ useUi, actions }) { +@@ -133,9 +133,20 @@ export function ErrorToast({ useUi, actions }) { return () => { clearTimeout(fade); clearTimeout(gone) } }, [toast?.token]) if (!toast) return null - return h('div', { className: 'bt-toast', role: 'alert', 'data-leaving': leaving || undefined }, + 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), - h('button', { type: 'button', className: 'bt-toast-link', onClick: () => { actions.dismissToast(toast.token); actions.openSettings('feedback') } }, t('Details')), ++ 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))) } -@@ -155,13 +155,13 @@ export const FEEDBACK_CSS = ` +@@ -155,13 +155,14 @@ export const FEEDBACK_CSS = ` .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -643,6 +653,7 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js .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:translate(-50%,16px) scale(.94)}} +@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}} diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index e046de78c..ce09b8238 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1873,9 +1873,15 @@ diff --git a/src/client/index.js b/src/client/index.js scheduleDelete: (sessionId, id) => call('schedule-delete', { sessionId, id }), exchange: (a, b) => call('exchange', { a, b }).catch(() => []), roomProgress: roomId => call('room-progress', { roomId }).catch(() => []), -@@ -566,6 +566,8 @@ export function apply(ctx) { +@@ -564,8 +564,13 @@ 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() } })) +- 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() } })), diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index c0eb5368e..5e84ece74 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -11194,20 +11194,7 @@ diff --git a/client.js b/client.js -var PHRASES = { - thinking: ["Thinking about it", "Weighing the options", "Working out a plan", "Sorting out the details"], - working: ["On it", "Busy with it", "Moving ahead", "Taking care of it", "Making headway"] -+ -+// src/client/board-card.js -+var import_react16 = require("react"); -+var TASK_SKIN = { -+ card: "bt-board-card", -+ top: "bt-board-card-top", -+ id: "bt-board-id", -+ badge: "bt-board-badge", -+ title: "bt-board-title", -+ labels: "bt-board-labels", -+ label: "bt-board-label", -+ owner: "bt-board-who", -+ note: "bt-board-note" - }; +-}; -var TOOL_PHRASES = [ - [/web_?search|search_?web/i, "Searching online"], - [/fetch|browse|browser|web/i, "Reading a page"], @@ -11243,6 +11230,20 @@ diff --git a/client.js b/client.js - case "update_bot": { - const target = findBot(arg("name")) ?? findBot(arg("bot")) ?? (arg("bot") ? void 0 : self); - return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; ++ ++// src/client/board-card.js ++var import_react16 = require("react"); ++var TASK_SKIN = { ++ card: "bt-board-card", ++ top: "bt-board-card-top", ++ id: "bt-board-id", ++ badge: "bt-board-badge", ++ title: "bt-board-title", ++ labels: "bt-board-labels", ++ label: "bt-board-label", ++ owner: "bt-board-who", ++ note: "bt-board-note" ++}; +function BoardCard({ + card, + skin = TASK_SKIN, @@ -13016,7 +13017,11 @@ diff --git a/client.js b/client.js - (0, import_react8.createElement)("div", { className: "bt-update-text" }, dshText), - dshMessage ? (0, import_react8.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, - (0, import_react8.createElement)( -- "div", ++ (0, import_react19.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), ++ (0, import_react19.createElement)("div", { className: "bt-update-text" }, dshText), ++ dshMessage ? (0, import_react19.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, ++ (0, import_react19.createElement)( + "div", - { className: "bt-update-actions" }, - dsh.kind === "app" ? dsh.available ? (0, import_react8.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { - }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react8.createElement)("button", { @@ -13285,11 +13290,6 @@ diff --git a/client.js b/client.js -} -function BrandName() { - return (0, import_react8.createElement)("span", { style: { fontWeight: 600, fontSize: 14 } }, "DS Bot"); -+ (0, import_react19.createElement)("div", { className: "bt-update-title" }, t("DSH {version}", { version: dsh.verified })), -+ (0, import_react19.createElement)("div", { className: "bt-update-text" }, dshText), -+ dshMessage ? (0, import_react19.createElement)("div", { className: "bt-update-error", role: "alert" }, t("The update did not finish: {reason}", { reason: dshMessage })) : null, -+ (0, import_react19.createElement)( -+ "div", + { className: "bt-update-actions" }, + dsh.kind === "app" ? dsh.available ? (0, import_react19.createElement)("button", { type: "button", className: "bt-update-go", onClick: () => void actions.openAppUpdates(dsh.verified).catch(() => { + }) }, t("Open updates")) : null : !dsh.available && dshPhase === "idle" ? null : dshPhase === "installed" ? null : (0, import_react19.createElement)("button", { @@ -22445,16 +22445,16 @@ diff --git a/client.js b/client.js { width: 20, height: 20, viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": true }, - (0, import_react25.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) + (0, import_react39.createElement)("path", { d: "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" }) -+); + ); +-var MARKS = { github: GitHubMark }; +-var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; +var FeishuMark = () => (0, import_react39.createElement)( + "svg", + { width: 20, height: 20, viewBox: "0 0 20 20", fill: "currentColor", "aria-hidden": true }, + (0, import_react39.createElement)("path", { d: "M3.2 6.6c3 .1 6.4 1.5 8.6 3.9-1.9.4-3.5 1.4-4.7 2.9-1.9-.6-3.4-1.9-3.9-3.6-.2-.7-.2-1.5 0-3.2z" }), + (0, import_react39.createElement)("path", { d: "M16.8 5.4c-2.9.2-5.6 1.5-7.4 3.4 1.7 1.2 3.1 2.8 3.9 4.9 2.2-1.1 3.8-3.2 4-5.6.1-.9-.1-1.9-.4-2.7z" }), + (0, import_react39.createElement)("path", { d: "M8.6 12.1c.9.7 1.7 1.6 2.2 2.7-1.1.2-2.3 0-3.3-.6.2-.8.6-1.5 1.1-2.1z" }) - ); --var MARKS = { github: GitHubMark }; --var ABOUT = { github: () => t("Repositories, issues, and pull requests") }; ++); +var MARKS = { github: GitHubMark, composio: ConnectorIcon, feishu: FeishuMark }; +var ABOUT = { + github: () => t("Repositories, issues, and pull requests"), @@ -23023,12 +23023,17 @@ diff --git a/client.js b/client.js if (!toast) return void 0; setLeaving(false); const fade = setTimeout(() => setLeaving(true), TOAST_MS); -@@ -10389,15 +10389,16 @@ function ErrorToast({ useUi, actions }) { +@@ -10389,15 +10389,23 @@ function ErrorToast({ useUi, actions }) { }; }, [toast?.token]); if (!toast) return null; - return (0, import_react26.createElement)( + 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: "alert", "data-leaving": leaving || void 0 }, @@ -23036,6 +23041,8 @@ diff --git a/client.js b/client.js - (0, import_react26.createElement)("button", { type: "button", className: "bt-toast-link", onClick: () => { + { 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"); @@ -23045,7 +23052,7 @@ diff --git a/client.js b/client.js ); } var FEEDBACK_CSS = ` -@@ -10416,47 +10416,56 @@ var FEEDBACK_CSS = ` +@@ -10416,47 +10416,57 @@ var FEEDBACK_CSS = ` .bt-fb-text{overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:break-word} .bt-fb-code{flex:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px} .bt-event-error{color:#e02135} @@ -23057,6 +23064,7 @@ diff --git a/client.js b/client.js .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:translate(-50%,16px) scale(.94)}} +@keyframes bt-toast-in{from{transform:translateY(16px) scale(.94)}} @@ -26235,9 +26243,13 @@ diff --git a/client.js b/client.js memoryForget: (botId, entry) => call("memory-forget", { botId, scope: entry.scope, topic: entry.topic, text: entry.text }), diagnostics: (payload) => call("diagnostics", payload), diagnosticsLog: () => call("diagnostics-log"), -@@ -13846,6 +13846,8 @@ function apply(ctx) { +@@ -13844,8 +13844,11 @@ 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() } })); +- 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() } })), diff --git a/patches/ds-bot/files/test/error-toast-code.test.js b/patches/ds-bot/files/test/error-toast-code.test.js new file mode 100644 index 000000000..6848dee02 --- /dev/null +++ b/patches/ds-bot/files/test/error-toast-code.test.js @@ -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') +})