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/2027-connector-error-copy.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ var ZH = {
Close: "关闭",
Copy: "复制",
Copied: "已复制",
"Copy error": "复制错误信息",
"Could not copy the error": "无法复制错误信息",
Content: "内容",
"Referenced {labels}": "引用了 {labels}",
", ": "、",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
Expand All @@ -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);
Expand Down
13 changes: 13 additions & 0 deletions core/src/client/connectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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}`)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ const ZH = {
Close: '关闭',
Copy: '复制',
Copied: '已复制',
'Copy error': '复制错误信息',
'Could not copy the error': '无法复制错误信息',
Content: '内容',
'Referenced {labels}': '引用了 {labels}',
', ': '、',
Expand Down
165 changes: 165 additions & 0 deletions core/test/connectors-page.test.js
Original file line number Diff line number Diff line change
@@ -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`)
}
})
2 changes: 2 additions & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <core version> · <branch> <commit>` (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 <time>`; a row with no known commit keeps the first sentence. An install pinned to a commit (`build.pinned`) reads `Pinned to commit 6d04db6. Updates come from the maintainer: reinstall FlyBot to get them.` and the version line shows only the short commit (#379). Other installs keep their old lines (`Updates are not offered for this install.`, `Installed from a local folder; update it there.`, `Update checks are off.`). Every string has a zh entry.
Expand Down
Loading
Loading