Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/306-held-line-once.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
48 changes: 42 additions & 6 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -5013,21 +5014,56 @@ 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();
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));
});
}
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, bot)) {
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 })
);
}
Expand Down
33 changes: 30 additions & 3 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,40 @@ 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()
// 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)))
Comment on lines +207 to +211

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Tie the waiting state to one message instance

When the room history contains an earlier reply with the same normalized text as a newly held line, this predicate marks both replies as held because every GroupMessage checks the same pending list by text alone. The same problem affects identical replies from different Bots because item.from is ignored, so one approval can temporarily replace unrelated or already-approved transcript lines with “Waiting for approval”; match the sender and consume or otherwise identify a single message occurrence instead.

Useful? React with 👍 / 👎.

})
}

// 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, bot)) {
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 }))
}

Expand Down
1 change: 1 addition & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
142 changes: 142 additions & 0 deletions core/test/held-line.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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('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)
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)
})
1 change: 1 addition & 0 deletions docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<untrusted-message>` 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, 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

Expand Down
Loading
Loading