From c9b3d90d4c9fe53c2f941aca09aaa2f1878dad94 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:31:14 +0800 Subject: [PATCH] client: a failed connector's error text is one click to copy (#2027) A row in the error state can carry 2,000 characters of the host's own failure text, but its only actions were Try again and Disconnect, so passing that sentence on meant hand-selecting it. Copy error puts the stored text on the clipboard through copiedToClipboard() and says on the button whether it landed. Fixes #2027 Written-by: Dev-1 Owner: Dev-1 --- .../2027-connector-error-copy.fixed.md | 1 + core/client.js | 11 + core/src/client/connectors.js | 13 + core/src/client/i18n.js | 2 + core/test/connectors-page.test.js | 165 + docs/subsystems/core-client.md | 2 + patches/ds-bot/0001-core-fixes.patch | 25 +- patches/ds-bot/0008-client-bundle.patch | 3256 +++++------------ .../ds-bot/files/test/connectors-page.test.js | 165 + 9 files changed, 1358 insertions(+), 2282 deletions(-) create mode 100644 changelog.d/2027-connector-error-copy.fixed.md create mode 100644 core/test/connectors-page.test.js create mode 100644 patches/ds-bot/files/test/connectors-page.test.js diff --git a/changelog.d/2027-connector-error-copy.fixed.md b/changelog.d/2027-connector-error-copy.fixed.md new file mode 100644 index 000000000..7d69e193e --- /dev/null +++ b/changelog.d/2027-connector-error-copy.fixed.md @@ -0,0 +1 @@ +A failed connector's error text is now copyable in one click ([#2027](https://github.com/ThinkFlowLab/FlyBot/issues/2027)): since [#2022](https://github.com/ThinkFlowLab/FlyBot/issues/2022) a row in the `error` state can carry up to 2,000 characters of the host's own failure text, but its only actions were `Try again` and `Disconnect`, so passing that sentence on meant selecting it by hand — close to impossible on a phone. The row now offers `Copy error` next to `Try again`, which puts the exact stored text on the clipboard through the shared helper and reads `Copied` when it landed and `Could not copy the error` when the page has no clipboard or the write was refused, never a quiet success ([#1347](https://github.com/ThinkFlowLab/FlyBot/issues/1347)). `ready` and `off` rows are unchanged. diff --git a/core/client.js b/core/client.js index af59bea94..22833ea05 100644 --- a/core/client.js +++ b/core/client.js @@ -194,6 +194,8 @@ var ZH = { Close: "关闭", Copy: "复制", Copied: "已复制", + "Copy error": "复制错误信息", + "Could not copy the error": "无法复制错误信息", Content: "内容", "Referenced {labels}": "引用了 {labels}", ", ": "、", @@ -18024,6 +18026,7 @@ function ConnectorRow({ connector, roster, actions }) { const [busy, setBusy] = (0, import_react39.useState)(false); const [error, setError] = (0, import_react39.useState)(null); const [message, setMessage] = (0, import_react39.useState)(""); + const [copyState, setCopyState] = (0, import_react39.useState)("idle"); const run = (task, after) => { setBusy(true); setError(null); @@ -18058,6 +18061,9 @@ function ConnectorRow({ connector, roster, actions }) { live = false; }; }, [gateway, connector.state, appsKey, picking]); + (0, import_react39.useEffect)(() => { + setCopyState("idle"); + }, [connector.state, connector.error]); const search = (0, import_react39.useCallback)((query) => actions.connectorAppSearch(connector.id, query, connector.state === "off" ? token : void 0), [connector.id, connector.state, token]); const connect = () => { if (busy) return; @@ -18091,6 +18097,10 @@ function ConnectorRow({ connector, roster, actions }) { const on = connector.state !== "off"; const canPost = sender || channel && connector.mode === "webhook"; const perBot = !sender && (!channel || connector.mode === "app"); + const copyLabel = copyState === "copied" ? t("Copied") : copyState === "failed" ? t("Could not copy the error") : t("Copy error"); + const copyError = () => { + void copiedToClipboard(connector.error ?? "").then((written) => setCopyState(written ? "copied" : "failed")); + }; const Mark = MARKS[connector.id]; const row = (0, import_react39.useRef)(null); useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`); @@ -18111,6 +18121,7 @@ function ConnectorRow({ connector, roster, actions }) { "span", { className: "bt-connector-actions" }, connector.state === "error" ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t("Try again")) : null, + connector.state === "error" ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: copyError }, copyLabel) : null, on && gateway && !picking ? (0, import_react39.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", disabled: busy, onClick: () => { setApps(connector.apps ?? []); setPicking(true); diff --git a/core/src/client/connectors.js b/core/src/client/connectors.js index 0de3db10c..f660199cc 100644 --- a/core/src/client/connectors.js +++ b/core/src/client/connectors.js @@ -5,6 +5,7 @@ import { ConnectorIcon } from './icons.js' import { SwitchRow } from './bot-settings.js' import { ConnectorBots, usageText } from './connector-bots.js' import { t } from './i18n.js' +import { copiedToClipboard } from '../shared/remote-label.js' const TOKEN_URL = 'https://github.com/settings/personal-access-tokens/new' const COMPOSIO_KEY_URL = 'https://platform.composio.dev/' @@ -157,6 +158,8 @@ function ConnectorRow({ connector, roster, actions }) { const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [message, setMessage] = useState('') + // #2027: what the copy button last did — a new failure starts from `Copy error` again. + const [copyState, setCopyState] = useState('idle') const run = (task, after) => { setBusy(true) setError(null) @@ -185,6 +188,8 @@ function ConnectorRow({ connector, roster, actions }) { actions.connectorAppStatus(connector.id).then(value => { if (live) setStatuses(value ?? {}) }, () => { if (live) setStatuses({}) }) return () => { live = false } }, [gateway, connector.state, appsKey, picking]) + // A row that leaves the error state and fails again is a new failure, not the last copy's answer. + useEffect(() => { setCopyState('idle') }, [connector.state, connector.error]) const search = useCallback(query => actions.connectorAppSearch(connector.id, query, connector.state === 'off' ? token : undefined), [connector.id, connector.state, token]) const connect = () => { if (busy) return @@ -209,6 +214,13 @@ function ConnectorRow({ connector, roster, actions }) { // Choose Bots is for what gives a Bot tools or a way in. A webhook-only channel has neither, so the // switch would change nothing (acceptance 5, #1266). const perBot = !sender && (!channel || connector.mode === 'app') + // A failed row can carry 2,000 characters of the host's own text (#2022), so it is offered as one + // copy instead of a hand-selection on a phone. copiedToClipboard answers whether the text really + // landed, and a `false` says so on the button rather than reporting a quiet success (#1347). + const copyLabel = copyState === 'copied' ? t('Copied') : copyState === 'failed' ? t('Could not copy the error') : t('Copy error') + const copyError = () => { + void copiedToClipboard(connector.error ?? '').then(written => setCopyState(written ? 'copied' : 'failed')) + } const Mark = MARKS[connector.id] const row = useRef(null) useMorph(row, `${form}:${picking}:${bots}:${error !== null}:${connector.state}:${mode}`) @@ -220,6 +232,7 @@ function ConnectorRow({ connector, roster, actions }) { h('span', { className: 'bt-connector-status' }, statusText(connector))), h('span', { className: 'bt-connector-actions' }, connector.state === 'error' ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => void run(actions.connectorRetry(connector.id)) }, t('Try again')) : null, + connector.state === 'error' ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: copyError }, copyLabel) : null, on && gateway && !picking ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', disabled: busy, onClick: () => { setApps(connector.apps ?? []); setPicking(true) } }, t('Choose apps')) : null, on && perBot ? h('button', { type: 'button', className: 'bt-soft bt-soft-sm', 'aria-expanded': bots, onClick: () => setBots(value => !value) }, bots ? t('Done') : t('Choose Bots')) : null, on diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index b372df5ab..d653662b3 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -160,6 +160,8 @@ const ZH = { Close: '关闭', Copy: '复制', Copied: '已复制', + 'Copy error': '复制错误信息', + 'Could not copy the error': '无法复制错误信息', Content: '内容', 'Referenced {labels}': '引用了 {labels}', ', ': '、', diff --git a/core/test/connectors-page.test.js b/core/test/connectors-page.test.js new file mode 100644 index 000000000..9c64daf4c --- /dev/null +++ b/core/test/connectors-page.test.js @@ -0,0 +1,165 @@ +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' + +// #2027: since #2022 a connector row in the `error` state can carry up to 2,000 characters of the +// host's own failure text, and the row offered only `Try again`/`Disconnect`, so handing that +// sentence to someone meant selecting it by hand. `Copy error` puts the exact stored text on the +// clipboard through copiedToClipboard(), which answers true/false and never reports a quiet +// success (#1347); a refused copy says so on the button instead. +const { outputFiles } = await build({ + stdin: { + contents: "export { ConnectorsPage } from './src/client/connectors.js'; export { DICTIONARY } from './src/client/i18n.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 } }) +// One component instance's hook slots, reused by every redraw; a setter redraws it at once, the +// way React would after a click, so a button's new label is visible to the test. +const slots = [] +let cursor = 0 +let rerender = null +const react = { + createElement: h, + Fragment: Symbol('F'), + useEffect() { cursor += 1 }, + useLayoutEffect() { cursor += 1 }, + useState(initial) { + const slot = cursor++ + if (!(slot in slots)) slots[slot] = typeof initial === 'function' ? initial() : initial + return [slots[slot], next => { slots[slot] = typeof next === 'function' ? next(slots[slot]) : next; rerender?.() }] + }, + useMemo(factory) { cursor += 1; return factory() }, + useCallback(fn) { cursor += 1; return fn }, + useRef(current) { const slot = cursor++; if (!(slot in slots)) slots[slot] = { current }; return slots[slot] }, + useSyncExternalStore(_subscribe, get) { cursor += 1; return get() }, +} + +const writes = [] +const workingClipboard = { writeText: async text => { writes.push(text) } } +const sandbox = { + module: { exports: {} }, + exports: {}, + document: { hidden: false, addEventListener() {}, removeEventListener() {} }, + navigator: { language: 'en', clipboard: workingClipboard }, + localStorage: { getItem: () => null, setItem() {} }, + window: { matchMedia: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }) }, + setTimeout, + clearTimeout, + requestAnimationFrame: run => { run(); return 0 }, + cancelAnimationFrame() {}, + 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}`) + }, +} +sandbox.module.exports = sandbox.exports +runInNewContext(outputFiles[0].text, sandbox) +const { ConnectorsPage, DICTIONARY } = sandbox.module.exports + +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); all(node.props?.children, out) } + return out +} +const buttonsOf = node => all(node).filter(element => element.type === 'button') +const texts = (node, out = []) => { + if (typeof node === 'string') { out.push(node); return out } + if (Array.isArray(node)) { for (const child of node) texts(child, out); return out } + if (node && typeof node === 'object') texts(node.props?.children, out) + return out +} +const labelOf = button => texts(button.props?.children).join('') + +const roster = { bots: [], connectors: [], self: { memberId: 'me' } } +const actions = { connectorRetry: () => Promise.resolve(), connectorDisconnect: () => Promise.resolve() } + +// The row under test, drawn from the page so a row that is not exported is still reachable. +const mount = connector => { + slots.length = 0 + let node = null + const page = ConnectorsPage({ roster: { ...roster, connectors: [connector] }, actions }) + const row = all(page).find(element => typeof element.type === 'function') + const draw = () => { cursor = 0; node = row.type(row.props) } + rerender = draw + draw() + return { node: () => node } +} +const labels = node => buttonsOf(node).map(labelOf) +const buttonNamed = (node, label) => buttonsOf(node).find(button => labelOf(button) === label) +// copiedToClipboard answers on a promise; let it land before reading the row again. +const settle = () => new Promise(resolve => setImmediate(resolve)) + +const stored = `The gateway refused the request: ${'E'.repeat(1967)}` +const failed = { id: 'github', name: 'GitHub', state: 'error', error: stored } + +test('#2027: Copy error hands the full stored text to the clipboard, then reads Copied', async () => { + assert.equal(stored.length, 2000) + writes.length = 0 + const view = mount(failed) + const copy = buttonNamed(view.node(), 'Copy error') + assert.ok(copy, 'a failed row offers Copy error') + copy.props.onClick() + await settle() + assert.deepEqual(writes, [stored], 'the exact stored text lands on the clipboard') + assert.equal(labels(view.node()).includes('Copied'), true, 'the button reports the copy') + assert.equal(labels(view.node()).includes('Could not copy the error'), false) + assert.deepEqual(labels(view.node()).filter(label => label === 'Copy error'), [], 'the hint is the label now') + // The other two actions of a failed row are untouched. + assert.ok(buttonNamed(view.node(), 'Try again')) + assert.ok(buttonNamed(view.node(), 'Disconnect')) +}) + +test('#2027: no clipboard and a refused write both read Could not copy the error, never Copied', async () => { + writes.length = 0 + try { + sandbox.navigator.clipboard = undefined + const broken = mount(failed) + const copy = buttonNamed(broken.node(), 'Copy error') + assert.ok(copy, 'a failed row offers Copy error with no clipboard too') + copy.props.onClick() + await settle() + assert.equal(labels(broken.node()).includes('Could not copy the error'), true, 'a page with no clipboard says so') + assert.equal(labels(broken.node()).includes('Copied'), false, 'a copy that did not happen never says Copied') + assert.deepEqual(writes, [], 'nothing reached a clipboard that is not there') + + sandbox.navigator.clipboard = { writeText: async () => { throw new Error('NotAllowedError') } } + const refused = mount(failed) + buttonNamed(refused.node(), 'Copy error').props.onClick() + await settle() + assert.equal(labels(refused.node()).includes('Could not copy the error'), true, 'a refused write says so') + assert.equal(labels(refused.node()).includes('Copied'), false, 'a refused write never says Copied') + } finally { + sandbox.navigator.clipboard = workingClipboard + } +}) + +test('#2027: only an error row offers Copy error; ready and off keep their own actions', () => { + const ready = mount({ id: 'github', name: 'GitHub', state: 'ready', account: 'octo' }) + assert.equal(labels(ready.node()).includes('Copy error'), false, 'a ready connector has nothing to copy') + assert.ok(buttonNamed(ready.node(), 'Disconnect')) + + const off = mount({ id: 'github', name: 'GitHub', state: 'off' }) + assert.equal(labels(off.node()).includes('Copy error'), false, 'an off connector has nothing to copy') + assert.ok(buttonNamed(off.node(), 'Connect')) + + const broken = mount(failed) + assert.equal(labels(broken.node()).includes('Copy error'), true, 'only the failed row offers it') + assert.ok(buttonNamed(broken.node(), 'Try again'), 'Try again is unchanged') + assert.ok(buttonNamed(broken.node(), 'Disconnect'), 'Disconnect is unchanged') +}) + +test('#2027: both new labels speak Chinese too', () => { + const zh = DICTIONARY.zh + for (const key of ['Copy error', 'Could not copy the error']) { + assert.equal(typeof zh[key], 'string', `${key} has a Chinese entry`) + assert.match(zh[key], /[\u4e00-\u9fff]/, `${key} is Chinese, not a copied English line`) + } +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 60648895f..f9f088295 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -252,6 +252,8 @@ The Feishu row (#1263) is a **sender** (`kind: 'sender'` from the host, not an i The same row is the whole act of connecting Feishu (#1266): it draws one row, not two, and the form has a **mode** — **Custom bot webhook** (one masked URL, outbound only, exactly phase 1) or **App (long connection)** (App ID + App Secret, one field per `credential` the definition declares, the App ID not masked because it is not a secret). Connect submits `{ mode, credentials }` through `connectorConnect`, which passes an object body straight to `connector-connect` and keeps the older single `token` body for every other row; the status line names the mode that is live, and **Disconnect** clears both. With `kind: 'channel'` the row offers the same per-Bot **Choose Bots** switch GitHub and Composio have (`connectedConnectors` includes a channel, so `connector-bots.js` lists it; off by default, never for a Bot shared with someone else) — a webhook-only row still shows no switch. App mode also renders **允许的飞书用户**: the refused senders the host reports, each with **Allow** / **Block** (`connector-allow` / `connector-block`), plus a paste-an-`open_id` field for the case where nobody has messaged yet; nobody is allowed until the owner adds them, so connecting the app never lets a stranger spend the owner's tokens. +A row in the `error` state offers **Copy error** beside **Try again** (#2027): it hands the connector's stored `error` — up to the 2,000-character host window from [core-host](core-host.md) — to the shared `copiedToClipboard()`, reads **Copied** only when the write landed and **Could not copy the error** when the page has no clipboard or the write was refused, and is drawn only while the connector is in that state, so a `ready`/`off` row's actions are unchanged. Tests: `core/test/connectors-page.test.js`. + ### About FlyBot page (#374) `src/client/about.js` shows the FlyBot mark, `Version · ` (the part after the version only for a git install), the Star button (the repository from `update.repoUrl`, the live count only when it came back) and the star request line, then **Updates** (the **FlyBot core** row and the **DeepSeek Harness (underlying)** row, zh `DeepSeek Harness(底层)`) and **Links** (Source code → the repository, Release notes → its releases, Report a problem → Settings → Feedback). For a git install on a branch the **FlyBot core** row reads `Installed from GitHub (main 082563a). Updates come from the maintainer: reinstall FlyBot to get them.`, and after **Check for updates** `Up to date with main.`, `1 commit behind main. …`, `N commits behind main. …` or `Could not compare with main. …` plus `· Checked