From 3156b09b634fdbdf8414b2174430190571260599 Mon Sep 17 00:00:00 2001
From: Day Dreamer <87640074+HugoXK@users.noreply.github.com>
Date: Wed, 28 Oct 2026 09:11:28 +0800
Subject: [PATCH 1/2] fix(client): the exchange sheet reads a stored line's
words, not its routing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Bot exchange drew `entry.text` as stored: a leading delegation trace and
an untrusted fence reached the bubble, and the message tools' copy carried the
same markers. `exchangeText` now reads the line once — unwrap a complete
`` wrap, drop a leading trace, strip leftover fragments —
and `ExchangeLine` gives that one string to the bubble and to the copy. The
paragraph breaks stay: a reply is not a sidebar row and is never folded.
---
core/client.js | 31 +++--
core/src/client/exchange.js | 39 +++++-
core/test/exchange-read.test.js | 114 ++++++++++++++++++
docs/subsystems/core-client.md | 1 +
patches/ds-bot/0003-flybot-api-client.patch | 65 ++++++++++
patches/ds-bot/0008-client-bundle.patch | 41 +++++--
.../ds-bot/files/test/exchange-read.test.js | 114 ++++++++++++++++++
patches/ds-bot/topics.json | 1 +
8 files changed, 384 insertions(+), 22 deletions(-)
create mode 100644 core/test/exchange-read.test.js
create mode 100644 patches/ds-bot/files/test/exchange-read.test.js
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",
From eaaf0c650f0648944d7b5ebb69c535bbf576f634 Mon Sep 17 00:00:00 2001
From: Day Dreamer <87640074+HugoXK@users.noreply.github.com>
Date: Wed, 28 Oct 2026 09:52:07 +0800
Subject: [PATCH 2/2] docs(changelog): note the exchange-sheet copy fix (#1423)
---
changelog.d/1423.fixed.md | 1 +
1 file changed, 1 insertion(+)
create mode 100644 changelog.d/1423.fixed.md
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)).