diff --git a/changelog.d/1423.fixed.md b/changelog.d/1423.fixed.md new file mode 100644 index 000000000..594aaa710 --- /dev/null +++ b/changelog.d/1423.fixed.md @@ -0,0 +1 @@ +The Bot exchange sheet showed and copied a stored turn's routing along with its words: a leading `Chief → 助手` delegation trace, and a remote writer's `` fence, appeared in the bubble and in Copy as if the writer had typed them. Every stored line now goes through one reader, `exchangeText()`, which unwraps the fence while keeping its line breaks, drops the leading trace and the leftover fence fragments, and trims only the ends — so the paragraph breaks of a multi-paragraph reply survive, and the displayed text and the copied text are the same ([#1423](https://github.com/ThinkFlowLab/FlyBot/issues/1423)). diff --git a/core/client.js b/core/client.js index 075c43114..b6a740b4e 100644 --- a/core/client.js +++ b/core/client.js @@ -14052,6 +14052,29 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); // src/client/exchange.js var import_react32 = require("react"); var SHEET_EXIT_MS = 220; +function exchangeText(text) { + if (typeof text !== "string") return ""; + const CLOSE = ""; + let body = text; + for (let previous; previous !== body; ) { + previous = body; + const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body; + body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : "")); + } + return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim(); +} +function ExchangeLine({ entry, roster, actions }) { + const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; + return (0, import_react32.createElement)( + "div", + { className: "bt-group-msg" }, + (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), + (0, import_react32.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) + ); +} function ExchangeDialog({ pair, roster, actions }) { const [entries2, setEntries] = (0, import_react32.useState)(null); const [leaving, setLeaving] = (0, import_react32.useState)(false); @@ -14104,19 +14127,13 @@ function ExchangeDialog({ pair, roster, actions }) { "div", { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") }, entries2 === null ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { - const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; const showDay = entry.time - lastDay > 30 * 60 * 1e3; lastDay = entry.time; return (0, import_react32.createElement)( import_react32.Fragment, { key: entry.id }, showDay ? (0, import_react32.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, - (0, import_react32.createElement)( - "div", - { className: "bt-group-msg" }, - (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), - (0, import_react32.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) - ) + (0, import_react32.createElement)(ExchangeLine, { entry, roster, actions }) ); }) ), diff --git a/core/src/client/exchange.js b/core/src/client/exchange.js index 37486487d..9076a5397 100644 --- a/core/src/client/exchange.js +++ b/core/src/client/exchange.js @@ -1,12 +1,44 @@ import { createElement as h, Fragment, useEffect, useState } from 'react' import { inkText } from './inks.js' -import { dayLabel } from './text.js' +import { dayLabel, stripTrace } from './text.js' import { colorOf, BotMark } from './mark.js' import { Bubbles } from './cells.js' +import { stripFence } from '../shared/preview.js' import { useEscape, usePaneLeft } from './overlay-hooks.js' import { t } from './i18n.js' const SHEET_EXIT_MS = 220 + +// What a person reads and copies from the exchange sheet. A stored line carries its routing: a +// leading `Chief → 助手` delegation trace and, for a remote writer, an `` fence. +// Neither is content, so the sheet reads the line the way a sidebar row does (#1347) — except that a +// reply is not one line: the paragraph breaks are the writer's own and stay exactly as stored. +export function exchangeText(text) { + if (typeof text !== 'string') return '' + const CLOSE = '' + let body = text + // A complete wrap, possibly nested: keep the inner text, with its line breaks, and whatever + // stood before or after it. + for (let previous; previous !== body;) { + previous = body + const open = body.indexOf(' open ? body.slice(body.indexOf('>', open) + 1, close) : body + body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : '')) + } + // The trace can sit above the wrap or just inside it, after the fence's newline; stripFence + // catches the leftover fragments. A trace on a sentence is prose, and stripTrace leaves it. + return stripFence(stripTrace(body.replace(/^\s+/, ''))).trim() +} + +// One stored exchange turn: the same cleaned text reaches the bubble and the message tools' copy. +export function ExchangeLine({ entry, roster, actions }) { + const bot = roster.byId[entry.from] ?? { id: entry.from, name: t('You'), color: 'gray' } + return h('div', { className: 'bt-group-msg' }, + h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name), + h(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: h('span', { className: 'bt-lead' }, h(BotMark, { bot: bot, size: 22 })) })) +} export function ExchangeDialog({ pair, roster, actions }) { const [entries, setEntries] = useState(null) const [leaving, setLeaving] = useState(false) @@ -45,14 +77,11 @@ export function ExchangeDialog({ pair, roster, actions }) { entries === null ? h('div', { className: 'bt-hint' }, t('Loading…')) : entries.length === 0 ? h('div', { className: 'bt-hint' }, t('No messages yet')) : entries.map((entry) => { - const bot = roster.byId[entry.from] ?? { id: entry.from, name: t('You'), color: 'gray' } const showDay = entry.time - lastDay > 30 * 60 * 1000 lastDay = entry.time return h(Fragment, { key: entry.id }, showDay ? h('time', { className: 'bt-time' }, dayLabel(entry.time)) : null, - h('div', { className: 'bt-group-msg' }, - h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name), - h(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: h('span', { className: 'bt-lead' }, h(BotMark, { bot: bot, size: 22 })) }))) + h(ExchangeLine, { entry, roster, actions })) })), h('div', { className: 'bt-dialog-foot' }, h('button', { type: 'button', className: 'bt-soft', onClick: close }, t('Close Chat'))))) } diff --git a/core/test/exchange-read.test.js b/core/test/exchange-read.test.js new file mode 100644 index 000000000..15c8cf088 --- /dev/null +++ b/core/test/exchange-read.test.js @@ -0,0 +1,114 @@ +// The Bot-exchange sheet is a read surface: a stored line reaches it with its routing still on it — +// a leading `Chief → 助手` trace and an `` fence — and a person must see and +// copy the words only. A reply is not one line: the paragraph breaks are content and stay (#). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const bundle = async () => { + const { build } = await import('esbuild') + const { outputFiles } = await build({ + stdin: { + contents: "export { ExchangeLine, exchangeText } from './src/client/exchange.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 } }) + const state = [] + let hook = 0 + const react = { + createElement: h, + Fragment: Symbol('Fragment'), + useEffect: () => {}, + useLayoutEffect: () => {}, + useState: (initial) => { + const index = hook++ + if (!(index in state)) state[index] = typeof initial === 'function' ? initial() : initial + return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] + }, + useRef: (current) => { const index = hook++; return (state[index] ??= { current }) }, + useId: () => 'id', + } + const written = [] + const displayed = [] + const module = { exports: {} } + runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false, body: {} }, + window: { innerWidth: 1200, innerHeight: 800, addEventListener() {}, removeEventListener() {} }, + navigator: { clipboard: { writeText: text => { written.push(text); return Promise.resolve() } } }, + setTimeout: () => 0, clearTimeout: () => {}, URL, Blob, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: function MarkdownText({ text }) { displayed.push(text); return text } } + throw new Error(`Unexpected platform module ${name}`) + }, + }) + const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } + } + const render = (props) => { hook = 0; return expand(module.exports.ExchangeLine(props)) } + return { ...module.exports, render, written, displayed } +} + +const walk = (node, visit) => { + if (!node || typeof node !== 'object') return + if (Array.isArray(node)) { for (const child of node) walk(child, visit); return } + visit(node) + walk(node.props?.children, visit) +} +const byLabel = (tree, label) => { + let found + walk(tree, node => { if (!found && node.props?.['aria-label'] === label) found = node }) + return found +} +const byText = (tree, label) => { + let found + walk(tree, node => { + const children = node.props?.children + if (!found && Array.isArray(children) && children.includes(label)) found = node + }) + return found +} + +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const roster = { bots: [chief], byId: { chief } } +const row = (text) => ({ entry: { id: 'e1', from: 'chief', time: 1, text }, roster, actions: {} }) + +test('exchangeText reads a stored line: no trace, no fence, and the paragraph breaks stay', async () => { + const { exchangeText } = await bundle() + // A trace on its own first line is routing the tree prompt asked for. + assert.equal(exchangeText('Chief → 助手\n\nB kick test 4 > hello'), 'B kick test 4 > hello') + // A complete wrap: the marker is format, the inner text is the message. + assert.equal(exchangeText('\nfirst\n\nsecond\n'), 'first\n\nsecond') + // Fragments of a fence an older write left behind. + assert.equal(exchangeText('from="User"> a stored line'), 'a stored line') + assert.equal(exchangeText('a stored line { + const { render, written, displayed } = await bundle() + const stored = '\nChief → 助手\n\nfirst para\n\nsecond para\n' + const tree = render(row(stored)) + const shown = displayed[0] + assert.equal(shown, 'first para\n\nsecond para') + assert.match(shown, /first para\n\nsecond para/, 'the paragraph break survives the read') + assert.doesNotMatch(shown, / ({ left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }) } }) + const menu = render(row(stored)) + byText(menu, 'Copy').props.onClick() + assert.deepEqual(written, ['first para\n\nsecond para']) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a3cd1b0f8..8a088304f 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -24,6 +24,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | File | Role | | --- | --- | | `core/src/client/` | Client sources → build to `core/client.js`. | +| `core/src/client/exchange.js` | The Bot-exchange sheet: `ExchangeDialog` polls the stored turns between two Bots and `ExchangeLine` draws one. A stored line is read once, by `exchangeText`: a leading `Chief → 助手` delegation trace and an `` fence are routing, not words, so the bubble and the message tools' **Copy** receive the same cleaned string. Unlike a sidebar row it is not folded to one line — a reply's paragraph breaks are the writer's own and stay exactly as stored. Tests: `core/test/exchange-read.test.js`. | | `core/src/client/i18n.js` | The zh map the host `t()` reads. A plugin Settings page that calls `t('…')` adds the English key here (Settings → Bot Social friends and sharing, #1074); `npm run build` in `core/` copies the same map into `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | | `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). The shell ids differ by version — one `stats` footer in 0.2.0-rc.2, the counts split into an `activity` pill next to `usage` in 0.2.1-alpha.1 — so `statsDocks` registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); `usage` stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. | diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index b9dd2ba6b..1879811bb 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1198,6 +1198,71 @@ diff --git a/src/client/create-bot.js b/src/client/create-bot.js h('button', { type: 'button', className: 'bt-send bt-create-go', disabled: busy || clash || Boolean(problem), onClick: () => void create() }, busy ? t('Creating…') : t('Create')))) if (inline) return h('div', { ref: frame, className: 'bt-create-inline', role: 'group', 'aria-label': t('Create Bot') }, card) return h('div', { className: 'bt-create', role: 'dialog', 'aria-label': t('Create Bot') }, +diff --git a/src/client/exchange.js b/src/client/exchange.js +--- a/src/client/exchange.js ++++ b/src/client/exchange.js +@@ -1,12 +1,44 @@ + import { createElement as h, Fragment, useEffect, useState } from 'react' + import { inkText } from './inks.js' +-import { dayLabel } from './text.js' ++import { dayLabel, stripTrace } from './text.js' + import { colorOf, BotMark } from './mark.js' + import { Bubbles } from './cells.js' ++import { stripFence } from '../shared/preview.js' + import { useEscape, usePaneLeft } from './overlay-hooks.js' + import { t } from './i18n.js' + + const SHEET_EXIT_MS = 220 ++ ++// What a person reads and copies from the exchange sheet. A stored line carries its routing: a ++// leading `Chief → 助手` delegation trace and, for a remote writer, an `` fence. ++// Neither is content, so the sheet reads the line the way a sidebar row does (#1347) — except that a ++// reply is not one line: the paragraph breaks are the writer's own and stay exactly as stored. ++export function exchangeText(text) { ++ if (typeof text !== 'string') return '' ++ const CLOSE = '' ++ let body = text ++ // A complete wrap, possibly nested: keep the inner text, with its line breaks, and whatever ++ // stood before or after it. ++ for (let previous; previous !== body;) { ++ previous = body ++ const open = body.indexOf(' open ? body.slice(body.indexOf('>', open) + 1, close) : body ++ body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : '')) ++ } ++ // The trace can sit above the wrap or just inside it, after the fence's newline; stripFence ++ // catches the leftover fragments. A trace on a sentence is prose, and stripTrace leaves it. ++ return stripFence(stripTrace(body.replace(/^\s+/, ''))).trim() ++} ++ ++// One stored exchange turn: the same cleaned text reaches the bubble and the message tools' copy. ++export function ExchangeLine({ entry, roster, actions }) { ++ const bot = roster.byId[entry.from] ?? { id: entry.from, name: t('You'), color: 'gray' } ++ return h('div', { className: 'bt-group-msg' }, ++ h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name), ++ h(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: h('span', { className: 'bt-lead' }, h(BotMark, { bot: bot, size: 22 })) })) ++} + export function ExchangeDialog({ pair, roster, actions }) { + const [entries, setEntries] = useState(null) + const [leaving, setLeaving] = useState(false) +@@ -45,14 +45,11 @@ export function ExchangeDialog({ pair, roster, actions }) { + entries === null ? h('div', { className: 'bt-hint' }, t('Loading…')) + : entries.length === 0 ? h('div', { className: 'bt-hint' }, t('No messages yet')) + : entries.map((entry) => { +- const bot = roster.byId[entry.from] ?? { id: entry.from, name: t('You'), color: 'gray' } + const showDay = entry.time - lastDay > 30 * 60 * 1000 + lastDay = entry.time + return h(Fragment, { key: entry.id }, + showDay ? h('time', { className: 'bt-time' }, dayLabel(entry.time)) : null, +- h('div', { className: 'bt-group-msg' }, +- h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name), +- h(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: h('span', { className: 'bt-lead' }, h(BotMark, { bot: bot, size: 22 })) }))) ++ h(ExchangeLine, { entry, roster, actions })) + })), + h('div', { className: 'bt-dialog-foot' }, h('button', { type: 'button', className: 'bt-soft', onClick: close }, t('Close Chat'))))) + } diff --git a/src/client/icons.js b/src/client/icons.js --- a/src/client/icons.js +++ b/src/client/icons.js diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 706d605c7..c0e597a29 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -16080,13 +16080,36 @@ diff --git a/client.js b/client.js ) ) ) -@@ -8577,18 +8577,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8577,18 +8577,41 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); } // src/client/exchange.js -var import_react21 = require("react"); +var import_react32 = require("react"); var SHEET_EXIT_MS = 220; ++function exchangeText(text) { ++ if (typeof text !== "string") return ""; ++ const CLOSE = ""; ++ let body = text; ++ for (let previous; previous !== body; ) { ++ previous = body; ++ const open = body.indexOf(" open ? body.slice(body.indexOf(">", open) + 1, close) : body; ++ body = stripFence(body.slice(0, open) + inner + (close > open ? body.slice(close + CLOSE.length) : "")); ++ } ++ return stripFence(stripTrace(body.replace(/^\s+/, ""))).trim(); ++} ++function ExchangeLine({ entry, roster, actions }) { ++ const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; ++ return (0, import_react32.createElement)( ++ "div", ++ { className: "bt-group-msg" }, ++ (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), ++ (0, import_react32.createElement)(Bubbles, { texts: [exchangeText(entry.text)], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) ++ ); ++} function ExchangeDialog({ pair, roster, actions }) { - const [entries2, setEntries] = (0, import_react21.useState)(null); - const [leaving, setLeaving] = (0, import_react21.useState)(false); @@ -16103,7 +16126,7 @@ diff --git a/client.js b/client.js let stop = false; let timer; let seen = null; -@@ -8612,48 +8612,48 @@ function ExchangeDialog({ pair, roster, actions }) { +@@ -8612,48 +8612,42 @@ function ExchangeDialog({ pair, roster, actions }) { const right = roster.byId[pair[1]]; let lastDay = 0; const paneLeft = usePaneLeft(); @@ -16133,8 +16156,8 @@ diff --git a/client.js b/client.js "div", { className: "bt-dialog-log", role: "log", "aria-label": t("Bot exchange transcript") }, - entries2 === null ? (0, import_react21.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react21.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { +- const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; + entries2 === null ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("Loading…")) : entries2.length === 0 ? (0, import_react32.createElement)("div", { className: "bt-hint" }, t("No messages yet")) : entries2.map((entry) => { - const bot = roster.byId[entry.from] ?? { id: entry.from, name: t("You"), color: "gray" }; const showDay = entry.time - lastDay > 30 * 60 * 1e3; lastDay = entry.time; - return (0, import_react21.createElement)( @@ -16144,15 +16167,13 @@ diff --git a/client.js b/client.js { key: entry.id }, - showDay ? (0, import_react21.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, - (0, import_react21.createElement)( -+ showDay ? (0, import_react32.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, -+ (0, import_react32.createElement)( - "div", - { className: "bt-group-msg" }, +- "div", +- { className: "bt-group-msg" }, - (0, import_react21.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), - (0, import_react21.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react21.createElement)("span", { className: "bt-lead" }, (0, import_react21.createElement)(BotMark, { bot, size: 22 })) }) -+ (0, import_react32.createElement)("span", { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, bot.name), -+ (0, import_react32.createElement)(Bubbles, { texts: [entry.text], roster, selfId: bot.id, actions, lead: (0, import_react32.createElement)("span", { className: "bt-lead" }, (0, import_react32.createElement)(BotMark, { bot, size: 22 })) }) - ) +- ) ++ showDay ? (0, import_react32.createElement)("time", { className: "bt-time" }, dayLabel(entry.time)) : null, ++ (0, import_react32.createElement)(ExchangeLine, { entry, roster, actions }) ); }) ), diff --git a/patches/ds-bot/files/test/exchange-read.test.js b/patches/ds-bot/files/test/exchange-read.test.js new file mode 100644 index 000000000..15c8cf088 --- /dev/null +++ b/patches/ds-bot/files/test/exchange-read.test.js @@ -0,0 +1,114 @@ +// The Bot-exchange sheet is a read surface: a stored line reaches it with its routing still on it — +// a leading `Chief → 助手` trace and an `` fence — and a person must see and +// copy the words only. A reply is not one line: the paragraph breaks are content and stay (#). +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' + +const bundle = async () => { + const { build } = await import('esbuild') + const { outputFiles } = await build({ + stdin: { + contents: "export { ExchangeLine, exchangeText } from './src/client/exchange.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 } }) + const state = [] + let hook = 0 + const react = { + createElement: h, + Fragment: Symbol('Fragment'), + useEffect: () => {}, + useLayoutEffect: () => {}, + useState: (initial) => { + const index = hook++ + if (!(index in state)) state[index] = typeof initial === 'function' ? initial() : initial + return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] + }, + useRef: (current) => { const index = hook++; return (state[index] ??= { current }) }, + useId: () => 'id', + } + const written = [] + const displayed = [] + const module = { exports: {} } + runInNewContext(outputFiles[0].text, { + module, exports: module.exports, + document: { hidden: false, body: {} }, + window: { innerWidth: 1200, innerHeight: 800, addEventListener() {}, removeEventListener() {} }, + navigator: { clipboard: { writeText: text => { written.push(text); return Promise.resolve() } } }, + setTimeout: () => 0, clearTimeout: () => {}, URL, Blob, + require: (name) => { + if (name === 'react') return react + if (name === 'react-dom') return { createPortal: node => node } + if (name === '@deepseek-ai/dsh-client-ui-primitives') return { MarkdownText: function MarkdownText({ text }) { displayed.push(text); return text } } + throw new Error(`Unexpected platform module ${name}`) + }, + }) + const expand = (node) => { + if (node === null || node === undefined || typeof node === 'boolean') return null + if (Array.isArray(node)) return node.map(expand) + if (typeof node !== 'object') return node + if (typeof node.type === 'function') return expand(node.type(node.props)) + return { ...node, props: { ...node.props, children: expand(node.props.children) } } + } + const render = (props) => { hook = 0; return expand(module.exports.ExchangeLine(props)) } + return { ...module.exports, render, written, displayed } +} + +const walk = (node, visit) => { + if (!node || typeof node !== 'object') return + if (Array.isArray(node)) { for (const child of node) walk(child, visit); return } + visit(node) + walk(node.props?.children, visit) +} +const byLabel = (tree, label) => { + let found + walk(tree, node => { if (!found && node.props?.['aria-label'] === label) found = node }) + return found +} +const byText = (tree, label) => { + let found + walk(tree, node => { + const children = node.props?.children + if (!found && Array.isArray(children) && children.includes(label)) found = node + }) + return found +} + +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const roster = { bots: [chief], byId: { chief } } +const row = (text) => ({ entry: { id: 'e1', from: 'chief', time: 1, text }, roster, actions: {} }) + +test('exchangeText reads a stored line: no trace, no fence, and the paragraph breaks stay', async () => { + const { exchangeText } = await bundle() + // A trace on its own first line is routing the tree prompt asked for. + assert.equal(exchangeText('Chief → 助手\n\nB kick test 4 > hello'), 'B kick test 4 > hello') + // A complete wrap: the marker is format, the inner text is the message. + assert.equal(exchangeText('\nfirst\n\nsecond\n'), 'first\n\nsecond') + // Fragments of a fence an older write left behind. + assert.equal(exchangeText('from="User"> a stored line'), 'a stored line') + assert.equal(exchangeText('a stored line { + const { render, written, displayed } = await bundle() + const stored = '\nChief → 助手\n\nfirst para\n\nsecond para\n' + const tree = render(row(stored)) + const shown = displayed[0] + assert.equal(shown, 'first para\n\nsecond para') + assert.match(shown, /first para\n\nsecond para/, 'the paragraph break survives the read') + assert.doesNotMatch(shown, / ({ left: 0, top: 0, right: 10, bottom: 10, width: 10, height: 10 }) } }) + const menu = render(row(stored)) + byText(menu, 'Copy').props.onClick() + assert.deepEqual(written, ['first para\n\nsecond para']) +}) diff --git a/patches/ds-bot/topics.json b/patches/ds-bot/topics.json index a1d47044f..5733d92ac 100644 --- a/patches/ds-bot/topics.json +++ b/patches/ds-bot/topics.json @@ -70,6 +70,7 @@ "src/client/index.js", "src/client/sidebar.js", "src/client/cells.js", + "src/client/exchange.js", "src/client/context-menu.js", "src/client/at-menu.js", "src/client/bot-settings.js",