From 61b890c8ee67c88b79143a2c3396473eaed5ced4 Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 18 Oct 2026 20:44:12 +0800 Subject: [PATCH 1/2] Show a held Bot line once: text in the approval card, bubble only says it waits A Bot line held for approval in a Face-to-face room was drawn as a full bubble and again as the preview in its approval card. Danyang's decision: the card keeps the text; the bubble shows only "Waiting for approval" and returns to the full line once the hold is decided. GroupMessage matches the line to a face_room_message entry of room.pendingApprovals. Lines the owner typed are never held, and a Bot's message to a friend has no bubble, so neither changes. Part of #306 item 5. --- changelog.d/306-held-line-once.fixed.md | 1 + core/client.js | 46 +++++++- core/src/client/cells.js | 28 ++++- core/src/client/styles.js | 1 + core/test/held-line.test.js | 123 ++++++++++++++++++++ docs/subsystems/core-client.md | 1 + docs/subsystems/social.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 45 ++++++- patches/ds-bot/0008-client-bundle.patch | 81 ++++++++++--- patches/ds-bot/files/test/held-line.test.js | 123 ++++++++++++++++++++ 10 files changed, 423 insertions(+), 28 deletions(-) create mode 100644 changelog.d/306-held-line-once.fixed.md create mode 100644 core/test/held-line.test.js create mode 100644 patches/ds-bot/files/test/held-line.test.js diff --git a/changelog.d/306-held-line-once.fixed.md b/changelog.d/306-held-line-once.fixed.md new file mode 100644 index 000000000..a7a148e6e --- /dev/null +++ b/changelog.d/306-held-line-once.fixed.md @@ -0,0 +1 @@ +A Bot line held for your approval in a Face-to-face room shows its text once, in the approval card; its bubble in the chat reads only **Waiting for approval** and shows the line again after you decide ([#306](https://github.com/ThinkFlowLab/FlyBot/issues/306), item 5). diff --git a/core/client.js b/core/client.js index 55f55a30a..e88b8f20e 100644 --- a/core/client.js +++ b/core/client.js @@ -2999,6 +2999,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} +.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} @@ -5013,21 +5014,54 @@ function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { }); return (0, import_react7.createElement)("div", { className: "bt-event" }, parts); } +var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); +function isHeldLine(room2, text) { + if (room2?.origin !== "face-to-face") return false; + const line = heldForm(text); + if (!line) return false; + return (room2.pendingApprovals ?? []).some((item) => { + if (item?.kind !== "face_room_message") return false; + const held = heldForm(item.text); + return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); + }); +} function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { const lead = (0, import_react7.createElement)( "button", { type: "button", className: "bt-lead", "aria-label": t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, (0, import_react7.createElement)(BotMark, { bot, size: 22 }) ); + const name = (0, import_react7.createElement)( + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + bot.name, + room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); + if (isHeldLine(room2, text)) { + return (0, import_react7.createElement)( + "div", + { className: "bt-group-msg" }, + name, + (0, import_react7.createElement)( + "div", + { className: "bt-astack", "data-lead": "" }, + (0, import_react7.createElement)( + "div", + { className: "bt-msg" }, + (0, import_react7.createElement)( + "div", + { className: "bt-msg-line" }, + lead, + (0, import_react7.createElement)("div", { className: "bt-bubble bt-bubble-held", "data-flybot": "held-line", role: "status" }, t("Waiting for approval")) + ) + ) + ) + ); + } return (0, import_react7.createElement)( "div", { className: "bt-group-msg" }, - (0, import_react7.createElement)( - "span", - { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, - bot.name, - room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null - ), + name, (0, import_react7.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) ); } diff --git a/core/src/client/cells.js b/core/src/client/cells.js index 8a2c7d690..b93d70d63 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -192,13 +192,35 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } +// A Bot line that Bot Social holds for the owner's approval has its text in the approval card; the +// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) +// carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. +const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() +export function isHeldLine(room, text) { + if (room?.origin !== 'face-to-face') return false + const line = heldForm(text) + if (!line) return false + return (room.pendingApprovals ?? []).some((item) => { + if (item?.kind !== 'face_room_message') return false + const held = heldForm(item.text) + return held !== '' && (held === line || (held.length >= 799 && line.startsWith(held))) + }) +} + // One Bot message inside a group chat: avatar, name (with its group role), bubble. function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, h(BotMark, { bot, size: 22 })) - return h('div', { className: 'bt-group-msg' }, - h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, - room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null), + const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, + room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) + if (isHeldLine(room, text)) { + return h('div', { className: 'bt-group-msg' }, name, + h('div', { className: 'bt-astack', 'data-lead': '' }, + h('div', { className: 'bt-msg' }, + h('div', { className: 'bt-msg-line' }, lead, + h('div', { className: 'bt-bubble bt-bubble-held', 'data-flybot': 'held-line', role: 'status' }, t('Waiting for approval')))))) + } + return h('div', { className: 'bt-group-msg' }, name, h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } diff --git a/core/src/client/styles.js b/core/src/client/styles.js index dc7f5e7aa..88e9bb42b 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -194,6 +194,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} +.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} diff --git a/core/test/held-line.test.js b/core/test/held-line.test.js new file mode 100644 index 000000000..f3fccd663 --- /dev/null +++ b/core/test/held-line.test.js @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #306 item 5: a Bot line held for approval has its text in the approval card once; its bubble in a +// Face-to-face room says only that it waits. + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const { GroupReplies, ToolCell, isHeldLine } = await load('cells.js') + +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const held = (text, over = {}) => ({ id: 'ap1', kind: 'face_room_message', status: 'pending', from: 'Chief', roomId: 'frm_1', text, ...over }) +const roomWith = (pendingApprovals, origin = 'face-to-face') => ({ id: 'g1', origin, members: ['chief'], participants: [], pendingApprovals }) +const draw = (room, replies) => { + const roster = { bots: [chief], byId: { chief }, roomsById: { g1: room } } + const content = [{ type: 'text', text: JSON.stringify({ replies }) }] + return expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) +} +const said = (text) => ({ botId: 'chief', name: 'Chief', text }) +const shownText = drawn => find(drawn, node => node.type?.name === 'MarkdownText').map(node => node.props.text) + +test('a held Bot line is one short waiting bubble; its text stays in the approval card', () => { + const drawn = draw(roomWith([held('Draft: ship it on Friday')]), [said('Draft: ship it on Friday')]) + const [bubble] = find(drawn, node => node.props?.['data-flybot'] === 'held-line') + assert.ok(bubble, 'a waiting bubble') + assert.deepEqual(bubble.children, ['Waiting for approval']) + assert.equal(shownText(drawn).length, 0, 'the line text is not drawn in the room') + // The Bot's name and avatar stay, so the waiting state is still the Bot's. + assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Chief')).length, 1) + assert.equal(find(drawn, node => node.props?.className === 'bt-lead').length, 1) +}) + +test('only the held line waits: other lines, and once the hold is decided, the line, are full bubbles', () => { + const room = roomWith([held('Draft: ship it on Friday')]) + const drawn = draw(room, [said('Earlier note'), said('Draft: ship it on Friday')]) + assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1) + assert.deepEqual(shownText(drawn), ['Earlier note']) + const decided = draw(roomWith([]), [said('Draft: ship it on Friday')]) + assert.equal(find(decided, node => node.props?.['data-flybot'] === 'held-line').length, 0) + assert.deepEqual(shownText(decided), ['Draft: ship it on Friday']) +}) + +test('the held text matches however the card cleaned it: spacing, the "To the room:" lead, the 800-character cap', () => { + assert.equal(isHeldLine(roomWith([held('hello there world')]), 'hello there\nworld'), true) + assert.equal(isHeldLine(roomWith([held('hello there')]), 'To face room: hello there'), true) + assert.equal(isHeldLine(roomWith([held('hello there')]), 'hello there!'), false) + const long = 'word '.repeat(300) + const capped = long.replace(/\s+/g, ' ').trim().slice(0, 800).trim() + assert.equal(isHeldLine(roomWith([held(capped)]), long), true) + assert.equal(isHeldLine(roomWith([held('')]), ''), false) +}) + +test('only a Face-to-face room hides a held line, and only for a held room line', () => { + assert.equal(isHeldLine(roomWith([held('hello')], 'group'), 'hello'), false) + assert.equal(isHeldLine(roomWith([held('hello', { kind: 'outbound_message' })]), 'hello'), false) + assert.equal(isHeldLine(undefined, 'hello'), false) + assert.equal(isHeldLine({ origin: 'face-to-face' }, 'hello'), false) +}) + +test('the waiting copy has a Chinese string', () => { + const zh = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') + assert.match(zh, /'Waiting for approval': '等待批准'/) +}) + +test('the waiting bubble has a style of its own', () => { + const css = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') + assert.match(css, /\.bt-bubble-held\{[^}]*dashed/) +}) + +test('a held message to a friend is no bubble at all: the Bot\'s send is not drawn, the card is the one place', () => { + const root = { + kind: 'tool-result', name: 'message_friend_bot', callId: 'c9', isError: false, subCalls: [], + call: { name: 'message_friend_bot', argsRaw: JSON.stringify({ to: '@coder.bob.home', message: 'Draft: ship it on Friday' }) }, + content: [{ type: 'text', text: "Waiting for the user's approval before it goes to @coder.bob.home. Do not resend." }], + } + const roster = { bots: [chief], byId: { chief }, rooms: [], roomsById: {}, exchangeTurns: {}, questions: {}, secretRequests: {}, secrets: [] } + const drawn = ToolCell({ + node: { key: 'n', data: { root }, location: { turn: { turn: 1 } } }, sessionId: 'chief', + useRoster: select => select(roster), useUi: select => select({ reactions: {}, visits: {} }), + actions: { noteLive() {}, noteCards() {} }, + }) + assert.equal(drawn, null) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8306294e6..87a00cca7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -59,6 +59,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Room remote bubbles unwrap the fence - Group `RemoteMessage` (`cells.js`) shows `remoteMessageText(reply.text)`, the same unwrap the 1:1 friend chat already uses. Stored room lines stay fenced for the model; the bubble shows the inner words, not the `` wrapper (#98). +- A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): `GroupMessage` (`cells.js`) asks `isHeldLine(room, text)`, which matches the line to a `face_room_message` entry of `room.pendingApprovals` (whitespace-collapsed, without a `To the room:` lead, allowing for the 800-character cap), and then draws only a `.bt-bubble-held` bubble reading **Waiting for approval** (`等待批准`) beside the Bot's avatar and name. Once the hold is decided the entry leaves `pendingApprovals` and the line shows in full. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged. ### Remote Bot display diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 3e5ab678d..4d2e579ae 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -99,7 +99,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Friend Bot details overview is Auto-send plus a muted **Remove this Bot** link (not a primary button). The DSH settings-shell chrome around empty local panes is unchanged. The host opens that overview via the compact remote Bot settings sheet (`remote-bot-settings`); the sidebar gear is a sibling of the friend's `bt-row`, not nested inside it (#166). The Auto-send box holds that setting only, and Remove this Bot sits in its own row below it; the sheet takes focus when it opens and closes on Escape, and inside a tree item the row with its gear shrinks to fit (`.bt-tree-item>.bt-row-wrap`), so focusing the gear never scrolls the sidebar sideways (#274). - Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past-room **Open** button keeps its own spacing from avatar and chip (#170). -- In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. +- In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and its room bubble (`GroupMessage`, `core/src/client/cells.js`, matched to the card by `room.pendingApprovals`) shrinks to a **Waiting for approval** state (`data-flybot=held-line`, **等待批准**) and returns to the full line once the hold is decided; a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. - The composer **Message {name}** accessible name is set by core `HeaderPill` (placeholder CSS var plus `aria-label` on `[data-composer-input]`, restored on cleanup); chat-header tok jargon stays in pristine DSH / ds-bot. ### Face-room poll identity (#122) diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 6ee1fa3fe..359389f80 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -220,7 +220,42 @@ diff --git a/src/client/cells.js b/src/client/cells.js } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -180,6 +180,91 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { +@@ -170,16 +170,123 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { + return h('div', { className: 'bt-event' }, parts) + } + ++// A Bot line that Bot Social holds for the owner's approval has its text in the approval card; the ++// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) ++// carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. ++const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() ++export function isHeldLine(room, text) { ++ if (room?.origin !== 'face-to-face') return false ++ const line = heldForm(text) ++ if (!line) return false ++ return (room.pendingApprovals ?? []).some((item) => { ++ if (item?.kind !== 'face_room_message') return false ++ const held = heldForm(item.text) ++ return held !== '' && (held === line || (held.length >= 799 && line.startsWith(held))) ++ }) ++} ++ + // One Bot message inside a group chat: avatar, name (with its group role), bubble. + function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { + const lead = h('button', { type: 'button', className: 'bt-lead', 'aria-label': t("Open {name}'s chat", { name: bot.name }), onClick: () => actions.openSession(bot.id) }, + h(BotMark, { bot, size: 22 })) +- return h('div', { className: 'bt-group-msg' }, +- h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, +- room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null), ++ const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, ++ room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) ++ if (isHeldLine(room, text)) { ++ return h('div', { className: 'bt-group-msg' }, name, ++ h('div', { className: 'bt-astack', 'data-lead': '' }, ++ h('div', { className: 'bt-msg' }, ++ h('div', { className: 'bt-msg-line' }, lead, ++ h('div', { className: 'bt-bubble bt-bubble-held', 'data-flybot': 'held-line', role: 'status' }, t('Waiting for approval')))))) ++ } ++ return h('div', { className: 'bt-group-msg' }, name, h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } @@ -1490,6 +1525,14 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} +@@ -174,6 +174,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + + .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} + .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} ++.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} + .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} + .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} + .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} @@ -267,6 +267,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index f2e1ccaa3..1a09d5e87 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -711,10 +711,15 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,488 @@ function maskOf(shape) { - } - var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); - +@@ -1238,9 +1238,491 @@ function maskOf(shape) { + mask = svgUrl(`${holes(def, def.eyes.map(eyeMarkup).join("").replaceAll('class="bt-eye"', 'fill="#000"'))}${def.wrap ? `${body}` : body}`, def.size); + maskCache.set(shape, mask); + } +- return mask; ++ return mask; ++} ++var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); ++ +// src/client/mark.js +var import_react = require("react"); +var STATES = ["idle", "thinking", "searching", "working", "sending", "orbit", "alert", "done"]; @@ -1170,7 +1175,8 @@ diff --git a/client.js b/client.js + }).concat(error ? [(0, import_react2.createElement)("div", { key: "error", className: "bt-flybot-approval-error", role: "alert" }, error)] : []) + ) : null + ); -+} + } +-var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); +var fileSize = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`; +var FLYBOT_CSS = ` +.bt-flybot-session-dock{width:100%;max-width:min(992px,100%);margin:0 auto;padding:0 0 6px;box-sizing:border-box} @@ -1196,10 +1202,9 @@ diff --git a/client.js b/client.js +.bt-flybot-pointer-note { opacity: .65; white-space: nowrap; } +.bt-flybot-pointer button { border: 0; border-radius: 999px; padding: 3px 10px; font-size: 12px; cursor: pointer; background: var(--bt-accent, #2563eb); color: #fff; } +`; -+ + // src/client/sources.js function createSource(initial) { - let value = initial; @@ -1262,7 +1262,7 @@ function createSource(initial) { } }; @@ -1507,6 +1512,14 @@ diff --git a/client.js b/client.js .bt-head{position:relative;display:flex;justify-content:center;align-items:center;height:40px;flex:none;pointer-events:none} .bt-pill{pointer-events:auto;display:inline-flex;align-items:center;gap:8px;height:40px;padding:7.5px 13.5px 7.5px 7.5px;border-radius:999px;background:var(--bt-main);border:.5px solid var(--bt-line-2);box-shadow:0 2px 8px -1px rgba(0,0,0,.06),0 1px 2px rgba(0,0,0,.04);color:var(--bt-ink);font-family:inherit;font-size:14px;line-height:20px;cursor:pointer;max-width:70%;box-sizing:border-box;transition:background-color .12s ease,transform ${SPRING_TAP.duration}ms ${SPRING_TAP.easing}} +@@ -1992,6 +1992,7 @@ body[data-bt-motion=quiet] .bt-mark[data-poke]{cursor:default} + + .bt-astack{display:flex;flex-direction:column;align-items:flex-start;gap:4px;margin:0} + .bt-bubble{max-width:min(80%,560px,calc(100% - 82px));background:var(--bt-bubble);color:var(--bt-ink);border-radius:18px;padding:7px 12px;font-size:var(--dsh-content-font-size,14px);line-height:calc(var(--dsh-content-font-size,14px) + 8px);box-sizing:border-box;overflow-wrap:anywhere} ++.bt-bubble-held{background:transparent;border:1px dashed var(--bt-line-2);color:var(--bt-ink-2)} + .bt-msg:not(:first-child)>.bt-msg-line>.bt-bubble{border-start-start-radius:6px} + .bt-msg:not(:last-child)>.bt-msg-line>.bt-bubble{border-end-start-radius:6px} + .bt-bubble [class*="_markdown"]>*+*{margin-top:10px} @@ -2085,6 +2085,7 @@ button.bt-q-opt:active{background:var(--bt-active)} @media (prefers-reduced-motion:reduce){.bt-q{animation:bt-fade .12s ease-out backwards}.bt-q-settled{animation:none}.bt-q-submit{animation:none}} @@ -2832,7 +2845,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,163 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,196 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -2887,6 +2900,17 @@ diff --git a/client.js b/client.js }); - return (0, import_react6.createElement)("div", { className: "bt-event" }, parts); + return (0, import_react7.createElement)("div", { className: "bt-event" }, parts); ++} ++var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); ++function isHeldLine(room2, text) { ++ if (room2?.origin !== "face-to-face") return false; ++ const line = heldForm(text); ++ if (!line) return false; ++ return (room2.pendingApprovals ?? []).some((item) => { ++ if (item?.kind !== "face_room_message") return false; ++ const held = heldForm(item.text); ++ return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); ++ }); } function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi }) { - const lead = (0, import_react6.createElement)( @@ -2897,17 +2921,38 @@ diff --git a/client.js b/client.js + (0, import_react7.createElement)(BotMark, { bot, size: 22 }) ); - return (0, import_react6.createElement)( ++ const name = (0, import_react7.createElement)( ++ "span", ++ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, ++ bot.name, ++ room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ++ ); ++ if (isHeldLine(room2, text)) { ++ return (0, import_react7.createElement)( ++ "div", ++ { className: "bt-group-msg" }, ++ name, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-astack", "data-lead": "" }, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-msg" }, ++ (0, import_react7.createElement)( ++ "div", ++ { className: "bt-msg-line" }, ++ lead, ++ (0, import_react7.createElement)("div", { className: "bt-bubble bt-bubble-held", "data-flybot": "held-line", role: "status" }, t("Waiting for approval")) ++ ) ++ ) ++ ) ++ ); ++ } + return (0, import_react7.createElement)( "div", { className: "bt-group-msg" }, - (0, import_react6.createElement)( -+ (0, import_react7.createElement)( - "span", - { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, - bot.name, -- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -+ room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null -+ ), ++ name, + (0, import_react7.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + ); +} @@ -2956,8 +3001,10 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-group-msg bt-remote-msg", "data-remote": handle }, + (0, import_react7.createElement)( -+ "span", -+ { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, + "span", + { className: "bt-group-name", style: { color: inkText(colorOf(bot)) } }, +- bot.name, +- room2?.admin === bot.id ? (0, import_react6.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + name, + (0, import_react7.createElement)("span", { className: "bt-remote-handle", title: source.remote.home ?? handle, "aria-label": t("Friend's Bot") }, (0, import_react7.createElement)(GlobeMark), handle) ), diff --git a/patches/ds-bot/files/test/held-line.test.js b/patches/ds-bot/files/test/held-line.test.js new file mode 100644 index 000000000..f3fccd663 --- /dev/null +++ b/patches/ds-bot/files/test/held-line.test.js @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #306 item 5: a Bot line held for approval has its text in the approval card once; its bubble in a +// Face-to-face room says only that it waits. + +async function load(entry) { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL(`../src/client/${entry}`, import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + runInNewContext(bundle.outputFiles[0].text, { + module, exports: module.exports, setTimeout, clearTimeout, + require: (name) => { + const platform = { + react: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + }) + return module.exports +} + +const expand = (node, names) => { + if (Array.isArray(node)) return node.flatMap(child => expand(child, names)) + if (node === null || typeof node !== 'object') return node + if (names.includes(node.type?.name)) return expand(node.type({ ...node.props, children: node.children }), names) + return { ...node, children: (node.children ?? []).map(child => expand(child, names)) } +} +const find = (node, match) => { + if (Array.isArray(node)) return node.flatMap(child => find(child, match)) + if (node === null || typeof node !== 'object') return [] + return [...(match(node) ? [node] : []), ...find(node.children, match)] +} + +const { GroupReplies, ToolCell, isHeldLine } = await load('cells.js') + +const chief = { id: 'chief', name: 'Chief', color: 'green' } +const held = (text, over = {}) => ({ id: 'ap1', kind: 'face_room_message', status: 'pending', from: 'Chief', roomId: 'frm_1', text, ...over }) +const roomWith = (pendingApprovals, origin = 'face-to-face') => ({ id: 'g1', origin, members: ['chief'], participants: [], pendingApprovals }) +const draw = (room, replies) => { + const roster = { bots: [chief], byId: { chief }, roomsById: { g1: room } } + const content = [{ type: 'text', text: JSON.stringify({ replies }) }] + return expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) +} +const said = (text) => ({ botId: 'chief', name: 'Chief', text }) +const shownText = drawn => find(drawn, node => node.type?.name === 'MarkdownText').map(node => node.props.text) + +test('a held Bot line is one short waiting bubble; its text stays in the approval card', () => { + const drawn = draw(roomWith([held('Draft: ship it on Friday')]), [said('Draft: ship it on Friday')]) + const [bubble] = find(drawn, node => node.props?.['data-flybot'] === 'held-line') + assert.ok(bubble, 'a waiting bubble') + assert.deepEqual(bubble.children, ['Waiting for approval']) + assert.equal(shownText(drawn).length, 0, 'the line text is not drawn in the room') + // The Bot's name and avatar stay, so the waiting state is still the Bot's. + assert.equal(find(drawn, node => node.props?.className === 'bt-group-name' && node.children.includes('Chief')).length, 1) + assert.equal(find(drawn, node => node.props?.className === 'bt-lead').length, 1) +}) + +test('only the held line waits: other lines, and once the hold is decided, the line, are full bubbles', () => { + const room = roomWith([held('Draft: ship it on Friday')]) + const drawn = draw(room, [said('Earlier note'), said('Draft: ship it on Friday')]) + assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1) + assert.deepEqual(shownText(drawn), ['Earlier note']) + const decided = draw(roomWith([]), [said('Draft: ship it on Friday')]) + assert.equal(find(decided, node => node.props?.['data-flybot'] === 'held-line').length, 0) + assert.deepEqual(shownText(decided), ['Draft: ship it on Friday']) +}) + +test('the held text matches however the card cleaned it: spacing, the "To the room:" lead, the 800-character cap', () => { + assert.equal(isHeldLine(roomWith([held('hello there world')]), 'hello there\nworld'), true) + assert.equal(isHeldLine(roomWith([held('hello there')]), 'To face room: hello there'), true) + assert.equal(isHeldLine(roomWith([held('hello there')]), 'hello there!'), false) + const long = 'word '.repeat(300) + const capped = long.replace(/\s+/g, ' ').trim().slice(0, 800).trim() + assert.equal(isHeldLine(roomWith([held(capped)]), long), true) + assert.equal(isHeldLine(roomWith([held('')]), ''), false) +}) + +test('only a Face-to-face room hides a held line, and only for a held room line', () => { + assert.equal(isHeldLine(roomWith([held('hello')], 'group'), 'hello'), false) + assert.equal(isHeldLine(roomWith([held('hello', { kind: 'outbound_message' })]), 'hello'), false) + assert.equal(isHeldLine(undefined, 'hello'), false) + assert.equal(isHeldLine({ origin: 'face-to-face' }, 'hello'), false) +}) + +test('the waiting copy has a Chinese string', () => { + const zh = readFileSync(new URL('../src/client/i18n.js', import.meta.url), 'utf8') + assert.match(zh, /'Waiting for approval': '等待批准'/) +}) + +test('the waiting bubble has a style of its own', () => { + const css = readFileSync(new URL('../src/client/styles.js', import.meta.url), 'utf8') + assert.match(css, /\.bt-bubble-held\{[^}]*dashed/) +}) + +test('a held message to a friend is no bubble at all: the Bot\'s send is not drawn, the card is the one place', () => { + const root = { + kind: 'tool-result', name: 'message_friend_bot', callId: 'c9', isError: false, subCalls: [], + call: { name: 'message_friend_bot', argsRaw: JSON.stringify({ to: '@coder.bob.home', message: 'Draft: ship it on Friday' }) }, + content: [{ type: 'text', text: "Waiting for the user's approval before it goes to @coder.bob.home. Do not resend." }], + } + const roster = { bots: [chief], byId: { chief }, rooms: [], roomsById: {}, exchangeTurns: {}, questions: {}, secretRequests: {}, secrets: [] } + const drawn = ToolCell({ + node: { key: 'n', data: { root }, location: { turn: { turn: 1 } } }, sessionId: 'chief', + useRoster: select => select(roster), useUi: select => select({ reactions: {}, visits: {} }), + actions: { noteLive() {}, noteCards() {} }, + }) + assert.equal(drawn, null) +}) From e638af919b218ffe6b4643ccd071b8543c4e7f83 Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 18 Oct 2026 21:17:50 +0800 Subject: [PATCH 2/2] fix(face-rooms): a held line waits only for the Bot that sent it (#306) Docs: docs/subsystems/core-client.md updated for the sender match. --- core/client.js | 6 ++++-- core/src/client/cells.js | 9 +++++++-- core/test/held-line.test.js | 19 +++++++++++++++++++ docs/subsystems/core-client.md | 2 +- patches/ds-bot/0003-flybot-api-client.patch | 11 ++++++++--- patches/ds-bot/0008-client-bundle.patch | 8 +++++--- patches/ds-bot/files/test/held-line.test.js | 19 +++++++++++++++++++ 7 files changed, 63 insertions(+), 11 deletions(-) diff --git a/core/client.js b/core/client.js index e88b8f20e..a1926c1f9 100644 --- a/core/client.js +++ b/core/client.js @@ -5015,12 +5015,14 @@ function EventLine({ prefix, bots, joiner = t("and"), onOpen, whole = false }) { return (0, import_react7.createElement)("div", { className: "bt-event" }, parts); } var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -function isHeldLine(room2, text) { +var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); +function isHeldLine(room2, text, bot) { if (room2?.origin !== "face-to-face") return false; const line = heldForm(text); if (!line) return false; return (room2.pendingApprovals ?? []).some((item) => { if (item?.kind !== "face_room_message") return false; + if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; const held = heldForm(item.text); return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); }); @@ -5037,7 +5039,7 @@ function GroupMessage({ bot, text, room: room2, roster, actions, msgKey, useUi } bot.name, room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null ); - if (isHeldLine(room2, text)) { + if (isHeldLine(room2, text, bot)) { return (0, import_react7.createElement)( "div", { className: "bt-group-msg" }, diff --git a/core/src/client/cells.js b/core/src/client/cells.js index b93d70d63..1a28cc1f3 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -196,12 +196,17 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { // bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) // carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() -export function isHeldLine(room, text) { +// The card names the sending Bot in `from`; a bubble is held only when its own Bot sent the held text, so +// another Bot's identical words are not turned into "Waiting for approval" (#306). A card that names no sender +// matches by text alone, as before. +const sameName = (a, b) => String(a ?? '').replace(/\s+/g, ' ').trim().toLowerCase() === String(b ?? '').replace(/\s+/g, ' ').trim().toLowerCase() +export function isHeldLine(room, text, bot) { if (room?.origin !== 'face-to-face') return false const line = heldForm(text) if (!line) return false return (room.pendingApprovals ?? []).some((item) => { if (item?.kind !== 'face_room_message') return false + if (bot !== undefined && String(item.from ?? '').trim() !== '' && !sameName(item.from, bot.name)) return false const held = heldForm(item.text) return held !== '' && (held === line || (held.length >= 799 && line.startsWith(held))) }) @@ -213,7 +218,7 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { h(BotMark, { bot, size: 22 })) const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) - if (isHeldLine(room, text)) { + if (isHeldLine(room, text, bot)) { return h('div', { className: 'bt-group-msg' }, name, h('div', { className: 'bt-astack', 'data-lead': '' }, h('div', { className: 'bt-msg' }, diff --git a/core/test/held-line.test.js b/core/test/held-line.test.js index f3fccd663..1e2c58d3f 100644 --- a/core/test/held-line.test.js +++ b/core/test/held-line.test.js @@ -90,6 +90,25 @@ test('the held text matches however the card cleaned it: spacing, the "To the ro assert.equal(isHeldLine(roomWith([held('')]), ''), false) }) +test('the held line waits only for the Bot that sent it: another Bot saying the same words stays a full bubble', () => { + const writer = { id: 'writer', name: 'Writer', color: 'blue' } + const room = { id: 'g1', origin: 'face-to-face', members: ['chief', 'writer'], participants: [], pendingApprovals: [held('Draft: ship it on Friday')] } + const roster = { bots: [chief, writer], byId: { chief, writer }, roomsById: { g1: room } } + const content = [{ type: 'text', text: JSON.stringify({ replies: [ + { botId: 'writer', name: 'Writer', text: 'Draft: ship it on Friday' }, + { botId: 'chief', name: 'Chief', text: 'Draft: ship it on Friday' }, + ] }) }] + const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) + assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1, 'only Chief\'s line waits') + assert.deepEqual(shownText(drawn), ['Draft: ship it on Friday'], 'Writer\'s identical line is drawn') + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', writer), false) + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', chief), true) + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', { id: 'chief', name: ' chief ' }), true, 'names compare without case or spacing') + // A card that names no sender still matches by text. + const unnamed = { ...room, pendingApprovals: [held('Draft: ship it on Friday', { from: '' })] } + assert.equal(isHeldLine(unnamed, 'Draft: ship it on Friday', writer), true) +}) + test('only a Face-to-face room hides a held line, and only for a held room line', () => { assert.equal(isHeldLine(roomWith([held('hello')], 'group'), 'hello'), false) assert.equal(isHeldLine(roomWith([held('hello', { kind: 'outbound_message' })]), 'hello'), false) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 87a00cca7..8e0fbbacf 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -59,7 +59,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Room remote bubbles unwrap the fence - Group `RemoteMessage` (`cells.js`) shows `remoteMessageText(reply.text)`, the same unwrap the 1:1 friend chat already uses. Stored room lines stay fenced for the model; the bubble shows the inner words, not the `` wrapper (#98). -- A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): `GroupMessage` (`cells.js`) asks `isHeldLine(room, text)`, which matches the line to a `face_room_message` entry of `room.pendingApprovals` (whitespace-collapsed, without a `To the room:` lead, allowing for the 800-character cap), and then draws only a `.bt-bubble-held` bubble reading **Waiting for approval** (`等待批准`) beside the Bot's avatar and name. Once the hold is decided the entry leaves `pendingApprovals` and the line shows in full. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged. +- A Bot line that Bot Social holds for approval in a Face-to-face room is drawn once, in its approval card (#306): `GroupMessage` (`cells.js`) asks `isHeldLine(room, text, bot)`, which matches the line to a `face_room_message` entry of `room.pendingApprovals` sent by that same Bot (the card's `from` equals the bubble's Bot name, ignoring case and spacing; a card with no `from` matches by text alone, and another Bot's identical words stay a full bubble) (whitespace-collapsed, without a `To the room:` lead, allowing for the 800-character cap), and then draws only a `.bt-bubble-held` bubble reading **Waiting for approval** (`等待批准`) beside the Bot's avatar and name. Once the hold is decided the entry leaves `pendingApprovals` and the line shows in full. Other room kinds, lines the owner typed (approved at once, never listed) and every other Bot line are unchanged. ### Remote Bot display diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 359389f80..e97506ce3 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -220,7 +220,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js } function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { -@@ -170,16 +170,123 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { +@@ -170,16 +170,128 @@ function EventLine({ prefix, bots, joiner = t('and'), onOpen, whole = false }) { return h('div', { className: 'bt-event' }, parts) } @@ -228,12 +228,17 @@ diff --git a/src/client/cells.js b/src/client/cells.js +// bubble keeps only the state, so the text shows once (#306). The host's pending list (`pendingApprovals`) +// carries the held text whitespace-collapsed and capped at 800 characters (799 once trimmed), without a "To the room:" lead. +const heldForm = text => String(text ?? '').replace(/\s+/g, ' ').trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, '').trim() -+export function isHeldLine(room, text) { ++// The card names the sending Bot in `from`; a bubble is held only when its own Bot sent the held text, so ++// another Bot's identical words are not turned into "Waiting for approval" (#306). A card that names no sender ++// matches by text alone, as before. ++const sameName = (a, b) => String(a ?? '').replace(/\s+/g, ' ').trim().toLowerCase() === String(b ?? '').replace(/\s+/g, ' ').trim().toLowerCase() ++export function isHeldLine(room, text, bot) { + if (room?.origin !== 'face-to-face') return false + const line = heldForm(text) + if (!line) return false + return (room.pendingApprovals ?? []).some((item) => { + if (item?.kind !== 'face_room_message') return false ++ if (bot !== undefined && String(item.from ?? '').trim() !== '' && !sameName(item.from, bot.name)) return false + const held = heldForm(item.text) + return held !== '' && (held === line || (held.length >= 799 && line.startsWith(held))) + }) @@ -248,7 +253,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js - room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null), + const name = h('span', { className: 'bt-group-name', style: { color: inkText(colorOf(bot)) } }, bot.name, + room?.admin === bot.id ? h('span', { className: 'bt-group-role' }, t('Admin')) : null) -+ if (isHeldLine(room, text)) { ++ if (isHeldLine(room, text, bot)) { + return h('div', { className: 'bt-group-msg' }, name, + h('div', { className: 'bt-astack', 'data-lead': '' }, + h('div', { className: 'bt-msg' }, diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 1a09d5e87..d57508a69 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2845,7 +2845,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,196 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,198 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -2902,12 +2902,14 @@ diff --git a/client.js b/client.js + return (0, import_react7.createElement)("div", { className: "bt-event" }, parts); +} +var heldForm = (text) => String(text ?? "").replace(/\s+/g, " ").trim().replace(/^(to (the )?face[ -]?room|to the room)\s*:\s*/i, "").trim(); -+function isHeldLine(room2, text) { ++var sameName = (a, b) => String(a ?? "").replace(/\s+/g, " ").trim().toLowerCase() === String(b ?? "").replace(/\s+/g, " ").trim().toLowerCase(); ++function isHeldLine(room2, text, bot) { + if (room2?.origin !== "face-to-face") return false; + const line = heldForm(text); + if (!line) return false; + return (room2.pendingApprovals ?? []).some((item) => { + if (item?.kind !== "face_room_message") return false; ++ if (bot !== void 0 && String(item.from ?? "").trim() !== "" && !sameName(item.from, bot.name)) return false; + const held = heldForm(item.text); + return held !== "" && (held === line || held.length >= 799 && line.startsWith(held)); + }); @@ -2927,7 +2929,7 @@ diff --git a/client.js b/client.js + bot.name, + room2?.admin === bot.id ? (0, import_react7.createElement)("span", { className: "bt-group-role" }, t("Admin")) : null + ); -+ if (isHeldLine(room2, text)) { ++ if (isHeldLine(room2, text, bot)) { + return (0, import_react7.createElement)( + "div", + { className: "bt-group-msg" }, diff --git a/patches/ds-bot/files/test/held-line.test.js b/patches/ds-bot/files/test/held-line.test.js index f3fccd663..1e2c58d3f 100644 --- a/patches/ds-bot/files/test/held-line.test.js +++ b/patches/ds-bot/files/test/held-line.test.js @@ -90,6 +90,25 @@ test('the held text matches however the card cleaned it: spacing, the "To the ro assert.equal(isHeldLine(roomWith([held('')]), ''), false) }) +test('the held line waits only for the Bot that sent it: another Bot saying the same words stays a full bubble', () => { + const writer = { id: 'writer', name: 'Writer', color: 'blue' } + const room = { id: 'g1', origin: 'face-to-face', members: ['chief', 'writer'], participants: [], pendingApprovals: [held('Draft: ship it on Friday')] } + const roster = { bots: [chief, writer], byId: { chief, writer }, roomsById: { g1: room } } + const content = [{ type: 'text', text: JSON.stringify({ replies: [ + { botId: 'writer', name: 'Writer', text: 'Draft: ship it on Friday' }, + { botId: 'chief', name: 'Chief', text: 'Draft: ship it on Friday' }, + ] }) }] + const drawn = expand(GroupReplies({ sessionId: 'g1', callId: 'c1', done: true, content, roster, actions: {}, useUi: () => null }), ['GroupMessage', 'Bubbles']) + assert.equal(find(drawn, node => node.props?.['data-flybot'] === 'held-line').length, 1, 'only Chief\'s line waits') + assert.deepEqual(shownText(drawn), ['Draft: ship it on Friday'], 'Writer\'s identical line is drawn') + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', writer), false) + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', chief), true) + assert.equal(isHeldLine(room, 'Draft: ship it on Friday', { id: 'chief', name: ' chief ' }), true, 'names compare without case or spacing') + // A card that names no sender still matches by text. + const unnamed = { ...room, pendingApprovals: [held('Draft: ship it on Friday', { from: '' })] } + assert.equal(isHeldLine(unnamed, 'Draft: ship it on Friday', writer), true) +}) + test('only a Face-to-face room hides a held line, and only for a held room line', () => { assert.equal(isHeldLine(roomWith([held('hello')], 'group'), 'hello'), false) assert.equal(isHeldLine(roomWith([held('hello', { kind: 'outbound_message' })]), 'hello'), false)