diff --git a/changelog.d/1406.fixed.md b/changelog.d/1406.fixed.md new file mode 100644 index 00000000..46b5b63f --- /dev/null +++ b/changelog.d/1406.fixed.md @@ -0,0 +1 @@ +Copying a conversation in the details drawer now copies each reply's own words, keeping the line breaks the reply was written with: the `untrusted-message` fence and a delegation trace whose first hop is one of the Bot's own names are dropped, while a line like `Settings → Models 页面` is kept as written ([#1406](https://github.com/ThinkFlowLab/FlyBot/issues/1406)). diff --git a/core/client.js b/core/client.js index 075c4311..8713cb56 100644 --- a/core/client.js +++ b/core/client.js @@ -2382,6 +2382,72 @@ function stripTrace(text) { if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; return text.slice(line2.length).replace(/^\s+/, ""); } +var COPY_FENCE = /untrusted-(?:message|file)\b/i; +var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +var sortedNames = (names) => (Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter(Boolean).sort((a, b) => b.length - a.length); +var isTraceLine = (line2, known) => { + if (known.length === 0 || /[.!?。!?::,,]/.test(line2.replace(/→/g, ""))) return false; + const hop = `(?:${known.map(escapeName).join("|")})`; + return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2); +}; +function stripFlatTrace(text, names = []) { + const body = String(text ?? ""); + const known = sortedNames(names); + if (known.length === 0) return body; + const hop = `(?:${known.map(escapeName).join("|")})`; + const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`); + const found = trace.exec(body); + return found ? body.slice(found[0].length) : body; +} +function copyBodyOf(text, names = []) { + let body = String(text ?? ""); + for (let previous; previous !== body; ) { + previous = body; + body = stripHeader(body); + } + const lines = []; + const known = sortedNames(names); + let first = true; + for (const raw of body.split("\n")) { + if (COPY_FENCE.test(raw)) { + const words = visibleText(raw); + if (words !== "") { + lines.push(first ? stripFlatTrace(words, known) : words); + first = false; + } + continue; + } + const line2 = raw.trim(); + if (line2 === "") { + lines.push(""); + continue; + } + if (isTraceLine(line2, known)) continue; + lines.push(first ? stripFlatTrace(line2, known) : line2); + first = false; + } + return lines.join("\n").trim(); +} +function copyNames(roster) { + const names = []; + const records = [...Object.values(roster?.byId ?? {}), ...Array.isArray(roster?.bots) ? roster.bots : []]; + for (const bot of records) { + const name = typeof bot?.name === "string" ? bot.name.trim() : ""; + if (name && !names.includes(name)) names.push(name); + } + return names.sort((a, b) => b.length - a.length); +} +function transcriptOf(list, id, name, hiddenTurns = [], names = []) { + const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; + if (!Array.isArray(outline)) return ""; + const lines = []; + for (const entry of outline) { + if (hiddenTurns.includes(entry.turn)) continue; + if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); + if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`); + } + return lines.join("\n\n"); +} function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; return ids.reduce((best, id) => at(id) > at(best) ? id : best, ids[0] ?? null); @@ -17168,17 +17234,6 @@ function migrateStorage(storage = globalThis.localStorage) { // src/client/details-drawer.js var import_react41 = require("react"); -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; - const lines = []; - for (const entry of outline) { - if (hiddenTurns.includes(entry.turn)) continue; - if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); - if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`); - } - return lines.join("\n\n"); -} var TAB_LABELS = { details: "Details", browser: "Browser" }; var hostOf = (url) => { try { @@ -17468,7 +17523,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a const bot = owner; const running = useSessionStatus((map) => map.get(part)?.running === true); const doing = useActivity((map) => map[bot?.id ?? sessionId]); - const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part])); + const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster))); const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); const run = (promise) => Promise.resolve(promise).then(() => { setError(""); diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index 13d01f31..7997141b 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -1,6 +1,6 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { isMainOf } from './sources.js' -import { HEADER, currentPart, stripHeader, usageOf } from './text.js' +import { copyNames, currentPart, transcriptOf, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' import { GlobeIcon, Favicon } from './browser-pane.js' @@ -18,18 +18,6 @@ import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-l import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' import { call } from './api.js' -function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline - if (!Array.isArray(outline)) return '' - const lines = [] - for (const entry of outline) { - if (hiddenTurns.includes(entry.turn)) continue - if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`) - if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`) - } - return lines.join('\n\n') -} - const TAB_LABELS = { details: 'Details', browser: 'Browser' } const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return '' } } @@ -251,7 +239,7 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro const bot = owner const running = useSessionStatus(map => map.get(part)?.running === true) const doing = useActivity(map => map[bot?.id ?? sessionId]) - const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part])) + const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part], copyNames(roster))) const usage = useSessions(list => usageOf(list, [part, ...(bot?.parts ?? room?.parts ?? [])])) const run = promise => Promise.resolve(promise) .then(() => { setError(''); return true }) diff --git a/core/src/client/text.js b/core/src/client/text.js index f65d91fc..8a6f3d7d 100644 --- a/core/src/client/text.js +++ b/core/src/client/text.js @@ -115,6 +115,96 @@ export function stripTrace(text) { return text.slice(line.length).replace(/^\s+/, '') } +// The copy export reads a stored turn the way the drawer's rows read one (#130, #1347): a +// delegation trace or an untrusted fence is routing or format, never something a person copies. +// Copy is read line by line, so the paragraph breaks a person typed stay paragraph breaks (#1406): +// only a line that is nothing but a trace goes, and only the first line loses a leading trace. +const COPY_FENCE = /untrusted-(?:message|file)\b/i +const escapeName = name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +const sortedNames = names => (Array.isArray(names) ? names : []) + .map(name => String(name ?? '').trim()) + .filter(Boolean) + .sort((a, b) => b.length - a.length) +// A whole line that is only a trace: every hop is a known Bot name, so `Chief → SelfB1` is routing +// and goes, while `Chief → Product Hi there` stays a line whose leading `Chief → Product` is then +// read as the trace it is. Without names no hop is known, so no whole line is dropped. +const isTraceLine = (line, known) => { + if (known.length === 0 || /[.!?。!?::,,]/.test(line.replace(/→/g, ''))) return false + const hop = `(?:${known.map(escapeName).join('|')})` + return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line) +} +// The flat trace: `Chief → Product` and the words on the same line. Only a first hop that is a +// Bot's name — whole, exactly, longest name first so `Dev Lead` never matches as `Dev` — is +// routing. `Settings → Models 页面` is a person naming a setting, so it stays as written. +export function stripFlatTrace(text, names = []) { + const body = String(text ?? '') + const known = sortedNames(names) + if (known.length === 0) return body + const hop = `(?:${known.map(escapeName).join('|')})` + const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`) + const found = trace.exec(body) + return found ? body.slice(found[0].length) : body +} +// `names` are the Bots a trace can start from: the roster's, longest first. Without them no first +// hop can be proven to be a name, so nothing is stripped — a missing roster must never eat prose. +export function copyBodyOf(text, names = []) { + let body = String(text ?? '') + for (let previous; previous !== body;) { + previous = body + body = stripHeader(body) + } + const lines = [] + const known = sortedNames(names) + let first = true + for (const raw of body.split('\n')) { + // Only the fence marker itself goes through the fence reader (a marker is a wrap, not words); + // what it empties was never a paragraph, so it is dropped rather than kept as a blank line. + if (COPY_FENCE.test(raw)) { + const words = visibleText(raw) + if (words !== '') { + lines.push(first ? stripFlatTrace(words, known) : words) + first = false + } + continue + } + const line = raw.trim() + if (line === '') { + lines.push('') + continue + } + if (isTraceLine(line, known)) continue + // The first line of the reply is the one a delegation trace can stand on. + lines.push(first ? stripFlatTrace(line, known) : line) + first = false + } + return lines.join('\n').trim() +} + +// The names a flat trace may start from: every Bot the drawer already knows, longest first, so a +// name that contains a shorter one is one alternative and never two ("Dev Lead" before "Dev"). +export function copyNames(roster) { + const names = [] + const records = [...Object.values(roster?.byId ?? {}), ...(Array.isArray(roster?.bots) ? roster.bots : [])] + for (const bot of records) { + const name = typeof bot?.name === 'string' ? bot.name.trim() : '' + if (name && !names.includes(name)) names.push(name) + } + return names.sort((a, b) => b.length - a.length) +} +// The conversation copy export: one `**Name:**` line per turn, the owner's own lines kept as they +// were, and every stored marker read away. `list` is the session list the drawer already holds. +export function transcriptOf(list, id, name, hiddenTurns = [], names = []) { + const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline + if (!Array.isArray(outline)) return '' + const lines = [] + for (const entry of outline) { + if (hiddenTurns.includes(entry.turn)) continue + if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`) + if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`) + } + return lines.join('\n\n') +} + // Of the Bots in a conversation, the one whose memory changed last, by the host's // clock: changes reaching one window in the same poll share a `seen` time. export function latestMemoryChange(news, ids) { diff --git a/core/test/details-copy.test.js b/core/test/details-copy.test.js new file mode 100644 index 00000000..b50dc86e --- /dev/null +++ b/core/test/details-copy.test.js @@ -0,0 +1,102 @@ +// The details drawer's copy export is a read surface: a person copies the words they see, not the +// stored markers. Two shapes reach the clipboard today — a delegation trace ("Chief → SelfB1") and +// an untrusted fence ("…") — and both are routing/format, never +// content. A line with no marker keeps its bytes exactly, line breaks included. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fenceUntrusted } from '../src/host/delivery.js' + +const read = name => readFileSync(new URL(`../src/client/${name}`, import.meta.url), 'utf8') +// The names a trace can start from: the drawer passes the roster's, longest first. +const BOT_NAMES = ['Chief', 'Product', 'Dev Lead', 'SelfB1', '助手'] +// The stored shape the host writes: the delivery header, then the fenced body. +const stored = (header, from, body) => `${header}\n${fenceUntrusted(from, body)}` +const outlineOf = (...entries) => ({ projectionsBySession: { s1: { values: { turnOutline: entries } } } }) +// Loaded lazily so a red base reports the missing reader per test, not as one import crash. +const copyReader = () => import('../src/client/text.js').then(module => module.transcriptOf) + +test('the conversation copy shows the words of a stored line, never its trace or fence', async () => { + const transcriptOf = await copyReader() + assert.equal(typeof transcriptOf, 'function', 'the copy reader is exported from the text helpers') + const body = 'B kick test 4 > hello' + const withTrace = `Chief → SelfB1\n\n${stored('[Message from User]', 'User', body)}` + const groupLine = stored('[Group chat "Build" · new message]', 'Chief', 'another stored line') + const text = transcriptOf(outlineOf( + { turn: 1, prompt: 'typed by the owner', response: withTrace }, + { turn: 2, response: groupLine }, + { turn: 3, response: 'plain words\nsecond line' }, + ), 's1', 'Chief', [], BOT_NAMES) + assert.doesNotMatch(text, / { + const drawer = read('details-drawer.js') + // On today's main this line is the leak: the stored response goes to the clipboard with only its + // delivery header removed, so the trace and the fence travel with it. + assert.doesNotMatch(drawer, /stripHeader\(entry\.response\)/, 'a stored reply is not copied raw') + assert.match(drawer, /import \{[^}]*\btranscriptOf\b[^}]*\} from '\.\/text\.js'/, 'the drawer uses the shared reader') + assert.doesNotMatch(drawer, /function transcriptOf\(/, 'the drawer keeps no second copy of the reader') + const text = read('text.js') + assert.match(text, /export function transcriptOf\(/, 'the reader is testable and exported') + assert.match(text, /visibleText\(/, 'the copy goes through the same fence-free text a row reads') +}) + +// A stored outline line is flat: the trace and the words share one line, so a reader that only +// looks for a trace line ending in a newline copies `Chief → Product Hi there` verbatim. The copy +// path flattens the line and removes a leading trace only when the first hop is a Bot's name — +// exactly, whole — because `Settings → Models 页面` is prose a person may be quoting. +const copyBody = () => import('../src/client/text.js').then(module => module.copyBodyOf) + +test('the copy drops a flat trace whose first hop is a known Bot name', async () => { + const copyBodyOf = await copyBody() + assert.equal(typeof copyBodyOf, 'function', 'copyBodyOf is exported') + assert.equal(copyBodyOf('Chief → Product Hi there', BOT_NAMES), 'Hi there') +}) + +test('the copy drops a flat trace that sits behind a delivery header', async () => { + const copyBodyOf = await copyBody() + assert.equal(copyBodyOf('[Reply from Chief] Chief → Product Hi there', BOT_NAMES), 'Hi there') +}) + +test('the copy drops a flat trace with more than one hop', async () => { + const copyBodyOf = await copyBody() + assert.equal(copyBodyOf('Chief → Dev Lead → Product 收到,开始', BOT_NAMES), '收到,开始') +}) + +test('the copy keeps a line whose first hop is not a Bot name, and keeps every line without names', async () => { + const copyBodyOf = await copyBody() + // A model list is prose, not routing: stripping it to `页面` would delete what the person said. + assert.equal(copyBodyOf('Settings → Models 页面', BOT_NAMES), 'Settings → Models 页面') + // A soft line break is kept, not folded into one line: only a recognized trace is removed. + assert.equal(copyBodyOf('Settings → Models\n页面', BOT_NAMES), 'Settings → Models\n页面') + // No names means no evidence that the first hop is a Bot, so nothing is stripped at all. + assert.equal(copyBodyOf('Chief → Product Hi there'), 'Chief → Product Hi there') + assert.equal(copyBodyOf('Chief → Product Hi there', []), 'Chief → Product Hi there') +}) + +// A copy is the words on their own lines. Folding a reply's paragraphs into one line is a format +// regression, not a fix: the reader the drawer replaces kept the newlines (`stripHeader(...).trim()`), +// and D2's exchange reader keeps them too. +test('the copy keeps the paragraph breaks a stored reply was written with', async () => { + const copyBodyOf = await copyBody() + const body = 'para one\n\npara two\n\npara three' + assert.equal(copyBodyOf(body, BOT_NAMES), body, 'every paragraph stays its own paragraph') + assert.equal(copyBodyOf(body, BOT_NAMES).split('\n').length, 5, 'four line breaks, one blank line between paragraphs') +}) + +test('the copy drops a flat trace on the first line and keeps the paragraphs under it', async () => { + const copyBodyOf = await copyBody() + const text = copyBodyOf('Chief → Product Hi there\n\nsecond para', BOT_NAMES) + assert.equal(text, 'Hi there\n\nsecond para', 'only the first line loses the trace; the break stays') + // The same line read flat: the words are the ones a single-line read would show. + assert.equal(text.split('\n').map(line => line.trim()).filter(Boolean).join(' '), 'Hi there second para') + assert.equal(text.split('\n').length, 3, 'the paragraph break is not folded away') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index f692f8b6..fe01d262 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -38,7 +38,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/schedules.js` | Settings → Scheduled tasks, a Bot's card and the details line. Each row has **Edit** (pencil; the row turns into the same form that creates a task, filled in, with **Save changes**; an edited task keeps its time zone, a cron rule it cannot show is kept), **Copy** (`CopyRow`, #1287: the row turns into the create form's target picker, minus the chat the task already runs in, and **Copy** posts `schedule-create` with `copyFrom: { sessionId, id }` — the host writes the row, so the copy keeps the source's next run) and **Pause** / **Resume**; a paused row reads **Paused** (#354). A row with a recorded run adds one line under it: "Running since …", "Last run …", "Last run … · Stopped" or, in red, "Last run … · Failed: "; a failed run of a task that is not paused gets **Retry**, which runs the task once now (`actions.scheduleRetry`, #473). With Automation tasks off, the hint reads "Switch on Automation tasks on the Plugins page." and the button **Open Plugins** closes the Settings dialog and opens DSH's Plugins panel (`actions.selectPanel('plugins')`); the Harness settings dialog has no such switch (#378). The page opens with the team's **Quiet hours** card (#1166): **Off** with a **Turn on** button, or **On, 22:00–07:00** with the window's two time fields and **Save window** (the settings write refuses an invalid window, and the host's named refusal appears on the form's error line). While quiet hours are on and an `every`/`cron` task exists, the view's `quietUnchecked` flag draws one line saying such a task can still run inside the window: that flag is the only place the rule is computed — the page does not scan the task list itself (#1199) — and it asks for the view again right after the settings write, so the line does not wait for the 30-second poll. Never a silent claim of silence, and no suppressed count. Tests: `core/test/schedules-client.test.js`. | | `core/src/client/inbox.js` | Agent inbox, step 1 (#349): the **Inbox** row at the top of the sidebar (`InboxRow`, count badge = cards in *Needs you*) and the inbox dialog (`InboxDialog`, `ui.inbox`, `actions.openInbox` / `closeInbox`) with the groups **Needs you**, **In progress** and **Done** (`inboxModel`); failed turns, a *Latest* line and a per-card `N tool calls` count (#471); ask_user cards (a group's and the Bot's own chat) answered in the inbox (#552, #1140); a *Needs you* title and reason wrap in full so the ask can be read on the card (#1177); a result card accepted (**Accept**) or sent back with a note (**Send back** → the box → **Send it back**), with its `result.links` evidence and the producing Bot's model, and a sent-back card reading *Sent back: …* (#401). Tests: `core/test/inbox.test.js`. | | `core/src/client/turn-failures.js` | Dead-turn lines (#1044): a turn that died with no text draws one line above the composer (`flybot.session.dock`, `data-flybot=turn-failures`), newest last, at most 3, naming the provider error from the host's `ended` entry and the streak when it is above 1. Registered by `index.js` (`flybot-turn-failures`, order 20); `failureLines(snapshot, place, t)` lives in `core/src/shared/turn-failures.js` so the test reads it without React. Since #1347 the error goes through `failureSentence` (`core/src/shared/failure-copy.js`), which turns a provider refusal stored as a cut-off JSON body into a sentence that names the feature and where to change the model; the Inbox reads the same helper. It never posts, retries or counts as a reply. Tests: `core/test/dead-turn.test.js`, `core/test/ui-clickthrough.test.js`. | -| `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it. Tests: `core/test/ui-clickthrough.test.js`. | +| `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | | `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. Tests: `core/test/kanban.test.js`. | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 90ede286..4519e7f2 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -2326,7 +2326,7 @@ diff --git a/src/client/text.js b/src/client/text.js // The prompts in a due scheduled task's message. DSH frames it for the model: one // task as `reminder_prompt_json: "…"`, several as `reminders_json: [{reminder_prompt}]`. export function reminderPrompts(text) { -@@ -62,6 +62,15 @@ export function reminderPrompts(text) { +@@ -62,6 +62,105 @@ export function reminderPrompts(text) { } } export const stripHeader = text => text.replace(HEADER, '') @@ -2338,6 +2338,96 @@ diff --git a/src/client/text.js b/src/client/text.js + const line = text.match(TRACE)?.[0] + if (!line || /[.!?。!?::,,]/.test(line.replace(/[^\S\n]*\n+$/, '').replace(/→/g, ''))) return text + return text.slice(line.length).replace(/^\s+/, '') ++} ++ ++// The copy export reads a stored turn the way the drawer's rows read one (#130, #1347): a ++// delegation trace or an untrusted fence is routing or format, never something a person copies. ++// Copy is read line by line, so the paragraph breaks a person typed stay paragraph breaks (#1406): ++// only a line that is nothing but a trace goes, and only the first line loses a leading trace. ++const COPY_FENCE = /untrusted-(?:message|file)\b/i ++const escapeName = name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') ++const sortedNames = names => (Array.isArray(names) ? names : []) ++ .map(name => String(name ?? '').trim()) ++ .filter(Boolean) ++ .sort((a, b) => b.length - a.length) ++// A whole line that is only a trace: every hop is a known Bot name, so `Chief → SelfB1` is routing ++// and goes, while `Chief → Product Hi there` stays a line whose leading `Chief → Product` is then ++// read as the trace it is. Without names no hop is known, so no whole line is dropped. ++const isTraceLine = (line, known) => { ++ if (known.length === 0 || /[.!?。!?::,,]/.test(line.replace(/→/g, ''))) return false ++ const hop = `(?:${known.map(escapeName).join('|')})` ++ return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line) ++} ++// The flat trace: `Chief → Product` and the words on the same line. Only a first hop that is a ++// Bot's name — whole, exactly, longest name first so `Dev Lead` never matches as `Dev` — is ++// routing. `Settings → Models 页面` is a person naming a setting, so it stays as written. ++export function stripFlatTrace(text, names = []) { ++ const body = String(text ?? '') ++ const known = sortedNames(names) ++ if (known.length === 0) return body ++ const hop = `(?:${known.map(escapeName).join('|')})` ++ const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`) ++ const found = trace.exec(body) ++ return found ? body.slice(found[0].length) : body ++} ++// `names` are the Bots a trace can start from: the roster's, longest first. Without them no first ++// hop can be proven to be a name, so nothing is stripped — a missing roster must never eat prose. ++export function copyBodyOf(text, names = []) { ++ let body = String(text ?? '') ++ for (let previous; previous !== body;) { ++ previous = body ++ body = stripHeader(body) ++ } ++ const lines = [] ++ const known = sortedNames(names) ++ let first = true ++ for (const raw of body.split('\n')) { ++ // Only the fence marker itself goes through the fence reader (a marker is a wrap, not words); ++ // what it empties was never a paragraph, so it is dropped rather than kept as a blank line. ++ if (COPY_FENCE.test(raw)) { ++ const words = visibleText(raw) ++ if (words !== '') { ++ lines.push(first ? stripFlatTrace(words, known) : words) ++ first = false ++ } ++ continue ++ } ++ const line = raw.trim() ++ if (line === '') { ++ lines.push('') ++ continue ++ } ++ if (isTraceLine(line, known)) continue ++ // The first line of the reply is the one a delegation trace can stand on. ++ lines.push(first ? stripFlatTrace(line, known) : line) ++ first = false ++ } ++ return lines.join('\n').trim() ++} ++ ++// The names a flat trace may start from: every Bot the drawer already knows, longest first, so a ++// name that contains a shorter one is one alternative and never two ("Dev Lead" before "Dev"). ++export function copyNames(roster) { ++ const names = [] ++ const records = [...Object.values(roster?.byId ?? {}), ...(Array.isArray(roster?.bots) ? roster.bots : [])] ++ for (const bot of records) { ++ const name = typeof bot?.name === 'string' ? bot.name.trim() : '' ++ if (name && !names.includes(name)) names.push(name) ++ } ++ return names.sort((a, b) => b.length - a.length) ++} ++// The conversation copy export: one `**Name:**` line per turn, the owner's own lines kept as they ++// were, and every stored marker read away. `list` is the session list the drawer already holds. ++export function transcriptOf(list, id, name, hiddenTurns = [], names = []) { ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline ++ if (!Array.isArray(outline)) return '' ++ const lines = [] ++ for (const entry of outline) { ++ if (hiddenTurns.includes(entry.turn)) continue ++ if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`) ++ if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`) ++ } ++ return lines.join('\n\n') +} // Of the Bots in a conversation, the one whose memory changed last, by the host's diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 706d605c..a8819a2d 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -1689,7 +1689,7 @@ diff --git a/client.js b/client.js function reminderPrompts(text) { const one = /^reminder_prompt_json: (.*)$/m.exec(text); const batch = /^reminders_json: (.*)$/m.exec(text); -@@ -1494,6 +1494,13 @@ function reminderPrompts(text) { +@@ -1494,6 +1494,79 @@ function reminderPrompts(text) { } } var stripHeader = (text) => text.replace(HEADER, ""); @@ -1699,6 +1699,72 @@ diff --git a/client.js b/client.js + const line2 = text.match(TRACE)?.[0]; + if (!line2 || /[.!?。!?::,,]/.test(line2.replace(/[^\S\n]*\n+$/, "").replace(/→/g, ""))) return text; + return text.slice(line2.length).replace(/^\s+/, ""); ++} ++var COPY_FENCE = /untrusted-(?:message|file)\b/i; ++var escapeName = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); ++var sortedNames = (names) => (Array.isArray(names) ? names : []).map((name) => String(name ?? "").trim()).filter(Boolean).sort((a, b) => b.length - a.length); ++var isTraceLine = (line2, known) => { ++ if (known.length === 0 || /[.!?。!?::,,]/.test(line2.replace(/→/g, ""))) return false; ++ const hop = `(?:${known.map(escapeName).join("|")})`; ++ return new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})+[ \\t]*$`).test(line2); ++}; ++function stripFlatTrace(text, names = []) { ++ const body = String(text ?? ""); ++ const known = sortedNames(names); ++ if (known.length === 0) return body; ++ const hop = `(?:${known.map(escapeName).join("|")})`; ++ const trace = new RegExp(`^[ \\t]*${hop}(?:[ \\t]*→[ \\t]*${hop})*[ \\t]*(?=\\S)`); ++ const found = trace.exec(body); ++ return found ? body.slice(found[0].length) : body; ++} ++function copyBodyOf(text, names = []) { ++ let body = String(text ?? ""); ++ for (let previous; previous !== body; ) { ++ previous = body; ++ body = stripHeader(body); ++ } ++ const lines = []; ++ const known = sortedNames(names); ++ let first = true; ++ for (const raw of body.split("\n")) { ++ if (COPY_FENCE.test(raw)) { ++ const words = visibleText(raw); ++ if (words !== "") { ++ lines.push(first ? stripFlatTrace(words, known) : words); ++ first = false; ++ } ++ continue; ++ } ++ const line2 = raw.trim(); ++ if (line2 === "") { ++ lines.push(""); ++ continue; ++ } ++ if (isTraceLine(line2, known)) continue; ++ lines.push(first ? stripFlatTrace(line2, known) : line2); ++ first = false; ++ } ++ return lines.join("\n").trim(); ++} ++function copyNames(roster) { ++ const names = []; ++ const records = [...Object.values(roster?.byId ?? {}), ...Array.isArray(roster?.bots) ? roster.bots : []]; ++ for (const bot of records) { ++ const name = typeof bot?.name === "string" ? bot.name.trim() : ""; ++ if (name && !names.includes(name)) names.push(name); ++ } ++ return names.sort((a, b) => b.length - a.length); ++} ++function transcriptOf(list, id, name, hiddenTurns = [], names = []) { ++ const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; ++ if (!Array.isArray(outline)) return ""; ++ const lines = []; ++ for (const entry of outline) { ++ if (hiddenTurns.includes(entry.turn)) continue; ++ if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); ++ if (entry.response) lines.push(`**${name}:** ${copyBodyOf(entry.response, names)}`); ++ } ++ return lines.join("\n\n"); +} function latestMemoryChange(news, ids) { const at = (id) => news?.[id]?.seen ? news[id].at ?? 0 : 0; @@ -19158,7 +19224,7 @@ diff --git a/client.js b/client.js prefs: "ds-bot:prefs", surface: "ds-bot:surface", agentSessions: "ds-bot:agent-sessions", -@@ -11006,265 +11006,40 @@ var migrateBrowsers = (storage) => { +@@ -11006,276 +11006,40 @@ var migrateBrowsers = (storage) => { const tab = { id: `v1-${botId}`, url: page.url ?? "", title: page.title ?? "" }; return [botId, { tabs: [tab], active: tab.id }]; })); @@ -19451,9 +19517,20 @@ diff --git a/client.js b/client.js // src/client/details-drawer.js -var import_react31 = require("react"); +var import_react41 = require("react"); - function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; - if (!Array.isArray(outline)) return ""; +-function transcriptOf(list, id, name, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline; +- if (!Array.isArray(outline)) return ""; +- const lines = []; +- for (const entry of outline) { +- if (hiddenTurns.includes(entry.turn)) continue; +- if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t("You")}:** ${entry.prompt.trim()}`); +- if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`); +- } +- return lines.join("\n\n"); +-} + var TAB_LABELS = { details: "Details", browser: "Browser" }; + var hostOf = (url) => { + try { @@ -11289,10 +11289,10 @@ var EMPTY_TABS = { ids: [], active: null }; var PERMISSION_LABELS = { "read-only": "Read only", "workspace-write": "Workspace", "danger-full-access": "Full access" }; var DANGER = /* @__PURE__ */ new Set(["danger-full-access"]); @@ -19783,6 +19860,15 @@ diff --git a/client.js b/client.js if (last.current.view !== view) last.current = { view, tab, move: view === "settings" ? "in" : "out" }; else if (last.current.tab !== tab) last.current = { view, tab, move: "tab" }; const move = last.current.move; +@@ -11457,7 +11457,7 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a + const bot = owner; + const running = useSessionStatus((map) => map.get(part)?.running === true); + const doing = useActivity((map) => map[bot?.id ?? sessionId]); +- const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part])); ++ const transcript = useSessions((list) => transcriptOf(list, part, bot?.name ?? room2?.name ?? t("Bot"), roster.exchangeTurns[part], copyNames(roster))); + const usage = useSessions((list) => usageOf(list, [part, ...bot?.parts ?? room2?.parts ?? []])); + const run = (promise) => Promise.resolve(promise).then(() => { + setError(""); @@ -11467,41 +11467,67 @@ function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, roster, a return false; }); diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 7fedf1bb..c6c075fd 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -74,9 +74,11 @@ diff --git a/src/client/composer.js b/src/client/composer.js diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js -@@ -2,11 +2,11 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react +@@ -1,12 +1,12 @@ + import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { isMainOf } from './sources.js' - import { HEADER, currentPart, stripHeader, usageOf } from './text.js' +-import { HEADER, currentPart, stripHeader, usageOf } from './text.js' ++import { copyNames, currentPart, transcriptOf, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' -import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' +import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' @@ -88,16 +90,28 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js import { ModelMenu, modelOf } from './model-menu.js' import { FolderMenu } from './folder-menu.js' import { MemoryRow } from './memory-panel.js' -@@ -14,6 +14,9 @@ import { ScheduleSummary } from './schedules.js' +@@ -14,18 +14,9 @@ import { ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' import { t } from './i18n.js' +- +-function transcriptOf(list, id, name, hiddenTurns = []) { +- const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline +- if (!Array.isArray(outline)) return '' +- const lines = [] +- for (const entry of outline) { +- if (hiddenTurns.includes(entry.turn)) continue +- if (entry.prompt && !HEADER.test(entry.prompt)) lines.push(`**${t('You')}:** ${entry.prompt.trim()}`) +- if (entry.response) lines.push(`**${name}:** ${stripHeader(entry.response).trim()}`) +- } +- return lines.join('\n\n') +-} +import { remoteBotTitle, roomTitle, copiedToClipboard } from '../shared/remote-label.js' +import { FlyBotRoomOverview, FlyBotDetails } from './flybot-slots.js' +import { call } from './api.js' - function transcriptOf(list, id, name, hiddenTurns = []) { - const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline + const TAB_LABELS = { details: 'Details', browser: 'Browser' } + @@ -113,16 +113,100 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are @@ -221,7 +235,14 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js ] } -@@ -170,13 +170,21 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro +@@ -164,19 +164,27 @@ export function DetailsDrawer({ sessionId, startRenaming, startTab, hasStrip, ro + const bot = owner + const running = useSessionStatus(map => map.get(part)?.running === true) + const doing = useActivity(map => map[bot?.id ?? sessionId]) +- const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part])) ++ const transcript = useSessions(list => transcriptOf(list, part, bot?.name ?? room?.name ?? t('Bot'), roster.exchangeTurns[part], copyNames(roster))) + const usage = useSessions(list => usageOf(list, [part, ...(bot?.parts ?? room?.parts ?? [])])) + const run = promise => Promise.resolve(promise) .then(() => { setError(''); return true }) .catch(failure => { setError(failure?.message ?? String(failure)); return false }) const share = () => { diff --git a/patches/ds-bot/files/test/details-copy.test.js b/patches/ds-bot/files/test/details-copy.test.js new file mode 100644 index 00000000..b50dc86e --- /dev/null +++ b/patches/ds-bot/files/test/details-copy.test.js @@ -0,0 +1,102 @@ +// The details drawer's copy export is a read surface: a person copies the words they see, not the +// stored markers. Two shapes reach the clipboard today — a delegation trace ("Chief → SelfB1") and +// an untrusted fence ("…") — and both are routing/format, never +// content. A line with no marker keeps its bytes exactly, line breaks included. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fenceUntrusted } from '../src/host/delivery.js' + +const read = name => readFileSync(new URL(`../src/client/${name}`, import.meta.url), 'utf8') +// The names a trace can start from: the drawer passes the roster's, longest first. +const BOT_NAMES = ['Chief', 'Product', 'Dev Lead', 'SelfB1', '助手'] +// The stored shape the host writes: the delivery header, then the fenced body. +const stored = (header, from, body) => `${header}\n${fenceUntrusted(from, body)}` +const outlineOf = (...entries) => ({ projectionsBySession: { s1: { values: { turnOutline: entries } } } }) +// Loaded lazily so a red base reports the missing reader per test, not as one import crash. +const copyReader = () => import('../src/client/text.js').then(module => module.transcriptOf) + +test('the conversation copy shows the words of a stored line, never its trace or fence', async () => { + const transcriptOf = await copyReader() + assert.equal(typeof transcriptOf, 'function', 'the copy reader is exported from the text helpers') + const body = 'B kick test 4 > hello' + const withTrace = `Chief → SelfB1\n\n${stored('[Message from User]', 'User', body)}` + const groupLine = stored('[Group chat "Build" · new message]', 'Chief', 'another stored line') + const text = transcriptOf(outlineOf( + { turn: 1, prompt: 'typed by the owner', response: withTrace }, + { turn: 2, response: groupLine }, + { turn: 3, response: 'plain words\nsecond line' }, + ), 's1', 'Chief', [], BOT_NAMES) + assert.doesNotMatch(text, / { + const drawer = read('details-drawer.js') + // On today's main this line is the leak: the stored response goes to the clipboard with only its + // delivery header removed, so the trace and the fence travel with it. + assert.doesNotMatch(drawer, /stripHeader\(entry\.response\)/, 'a stored reply is not copied raw') + assert.match(drawer, /import \{[^}]*\btranscriptOf\b[^}]*\} from '\.\/text\.js'/, 'the drawer uses the shared reader') + assert.doesNotMatch(drawer, /function transcriptOf\(/, 'the drawer keeps no second copy of the reader') + const text = read('text.js') + assert.match(text, /export function transcriptOf\(/, 'the reader is testable and exported') + assert.match(text, /visibleText\(/, 'the copy goes through the same fence-free text a row reads') +}) + +// A stored outline line is flat: the trace and the words share one line, so a reader that only +// looks for a trace line ending in a newline copies `Chief → Product Hi there` verbatim. The copy +// path flattens the line and removes a leading trace only when the first hop is a Bot's name — +// exactly, whole — because `Settings → Models 页面` is prose a person may be quoting. +const copyBody = () => import('../src/client/text.js').then(module => module.copyBodyOf) + +test('the copy drops a flat trace whose first hop is a known Bot name', async () => { + const copyBodyOf = await copyBody() + assert.equal(typeof copyBodyOf, 'function', 'copyBodyOf is exported') + assert.equal(copyBodyOf('Chief → Product Hi there', BOT_NAMES), 'Hi there') +}) + +test('the copy drops a flat trace that sits behind a delivery header', async () => { + const copyBodyOf = await copyBody() + assert.equal(copyBodyOf('[Reply from Chief] Chief → Product Hi there', BOT_NAMES), 'Hi there') +}) + +test('the copy drops a flat trace with more than one hop', async () => { + const copyBodyOf = await copyBody() + assert.equal(copyBodyOf('Chief → Dev Lead → Product 收到,开始', BOT_NAMES), '收到,开始') +}) + +test('the copy keeps a line whose first hop is not a Bot name, and keeps every line without names', async () => { + const copyBodyOf = await copyBody() + // A model list is prose, not routing: stripping it to `页面` would delete what the person said. + assert.equal(copyBodyOf('Settings → Models 页面', BOT_NAMES), 'Settings → Models 页面') + // A soft line break is kept, not folded into one line: only a recognized trace is removed. + assert.equal(copyBodyOf('Settings → Models\n页面', BOT_NAMES), 'Settings → Models\n页面') + // No names means no evidence that the first hop is a Bot, so nothing is stripped at all. + assert.equal(copyBodyOf('Chief → Product Hi there'), 'Chief → Product Hi there') + assert.equal(copyBodyOf('Chief → Product Hi there', []), 'Chief → Product Hi there') +}) + +// A copy is the words on their own lines. Folding a reply's paragraphs into one line is a format +// regression, not a fix: the reader the drawer replaces kept the newlines (`stripHeader(...).trim()`), +// and D2's exchange reader keeps them too. +test('the copy keeps the paragraph breaks a stored reply was written with', async () => { + const copyBodyOf = await copyBody() + const body = 'para one\n\npara two\n\npara three' + assert.equal(copyBodyOf(body, BOT_NAMES), body, 'every paragraph stays its own paragraph') + assert.equal(copyBodyOf(body, BOT_NAMES).split('\n').length, 5, 'four line breaks, one blank line between paragraphs') +}) + +test('the copy drops a flat trace on the first line and keeps the paragraphs under it', async () => { + const copyBodyOf = await copyBody() + const text = copyBodyOf('Chief → Product Hi there\n\nsecond para', BOT_NAMES) + assert.equal(text, 'Hi there\n\nsecond para', 'only the first line loses the trace; the break stays') + // The same line read flat: the words are the ones a single-line read would show. + assert.equal(text.split('\n').map(line => line.trim()).filter(Boolean).join(' '), 'Hi there second para') + assert.equal(text.split('\n').length, 3, 'the paragraph break is not folded away') +})