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