From a9cbe93feaaea69266b743770015516a676bc95f Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:31:40 +0800 Subject: [PATCH] client: a held message says why it waits, not only in a hover title The host's reason for holding a message reached the session dock's card as a `title` only, and a held reply's bubble in a chat had no copy of it at all: `heldOf()` carried id/by/status/session/turn/to/error and left `reason` behind. A title is invisible to a keyboard, a touch screen and a screen reader, and the waiting bubble is exactly where the person deciding the hold is looking. Both drawing points now show the sentence under the origin line, through one shared bound: 200 code points visible with an ellipsis, the whole sentence in `title`, and nothing at all for an empty or whitespace-only reason. --- .../2422-approval-reason-visible.fixed.md | 1 + core/client.js | 15 +- core/src/client/flybot-slots.js | 7 +- core/src/client/held-reply.js | 4 + core/src/client/reason-line.js | 20 ++ core/src/host/hooks.js | 3 + core/test/approval-reason.test.js | 171 +++++++++++++++ core/test/held-reply-seam.test.js | 3 + core/test/overlay-approvals.test.js | 2 +- docs/subsystems/core-client.md | 4 +- docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 204 +++++++++--------- .../ds-bot/files/src/client/flybot-slots.js | 7 +- patches/ds-bot/files/src/client/held-reply.js | 4 + .../ds-bot/files/src/client/reason-line.js | 20 ++ patches/ds-bot/files/src/host/hooks.js | 3 + .../ds-bot/files/test/approval-reason.test.js | 171 +++++++++++++++ .../ds-bot/files/test/held-reply-seam.test.js | 3 + .../files/test/overlay-approvals.test.js | 2 +- 20 files changed, 543 insertions(+), 105 deletions(-) create mode 100644 changelog.d/2422-approval-reason-visible.fixed.md create mode 100644 core/src/client/reason-line.js create mode 100644 core/test/approval-reason.test.js create mode 100644 patches/ds-bot/files/src/client/reason-line.js create mode 100644 patches/ds-bot/files/test/approval-reason.test.js diff --git a/changelog.d/2422-approval-reason-visible.fixed.md b/changelog.d/2422-approval-reason-visible.fixed.md new file mode 100644 index 000000000..5a3c8b0a4 --- /dev/null +++ b/changelog.d/2422-approval-reason-visible.fixed.md @@ -0,0 +1 @@ +A held message now says why it is waiting: the host's reason is drawn under the sender line on the session dock's approval card and on a held reply's own bubble in a chat, instead of living only in a hover title on the card and nowhere at all on the bubble. The visible line stops at 200 code points with an ellipsis and the whole sentence stays in the title, so a long reason is readable without hiding its end, and a reason that is empty or only whitespace draws no line at all ([#2422](https://github.com/ThinkFlowLab/FlyBot/issues/2422)). diff --git a/core/client.js b/core/client.js index 76c11b51c..93e045f7a 100644 --- a/core/client.js +++ b/core/client.js @@ -3240,6 +3240,14 @@ var canRetry = (record) => { return !TERMINAL.some((reason) => reason.test(said)); }; +// src/client/reason-line.js +var REASON_POINTS = 200; +var reasonLine = (reason) => { + if (typeof reason !== "string" || reason.trim() === "") return null; + const full2 = reason.toWellFormed(); + return { text: fits(full2, REASON_POINTS) ? full2 : [...full2].slice(0, REASON_POINTS).join("") + "…", title: full2 }; +}; + // src/shared/face-title.js var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; function isGenericFaceTitle(name) { @@ -4644,9 +4652,10 @@ function FlyBotApprovals({ useRoster, actions } = {}) { const waiting = isApprovalWaiting(approval.status); const at = typeof approval.createdAt === "number" ? approval.createdAt : Date.parse(approval.createdAt); const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; + const reason = reasonLine(approval.reason); return (0, import_react6.createElement)( "div", - { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, + { key: approval.id, className: "bt-flybot-approval" }, (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval"), waiting ? age : null), (0, import_react6.createElement)( "div", @@ -4654,6 +4663,7 @@ function FlyBotApprovals({ useRoster, actions } = {}) { t("{from} → {to}", { from: approval.senderName ?? t("A Bot"), to: recipientName(approval, roster) }), approval.request.hop > 0 ? ` · ${t("delegated")}` : "" ), + reason ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, failed && approval.error ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-error" }, approval.error) : null, (0, import_react6.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), approval.request.attachments?.length ? (0, import_react6.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react6.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, @@ -4699,6 +4709,7 @@ var FLYBOT_CSS = ` .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } .bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } +.bt-flybot-approval-reason { font-size: 12px; line-height: 1.4; margin: 0 0 6px; overflow-wrap: anywhere; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } @@ -6874,11 +6885,13 @@ function HeldReplyActions({ held, actions, roster }) { } const at = typeof held.createdAt === "number" ? held.createdAt : Date.parse(held.createdAt); const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; + const reason = reasonLine(held.reason); const plain = (0, import_react10.createElement)( "div", { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), + reason ? (0, import_react10.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, locked ? readOnlyLine() : (0, import_react10.createElement)( "div", { className: "bt-flybot-approval-actions" }, diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 7bc94ceea..e6be8c1bb 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -46,6 +46,7 @@ import { hostText, t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { reasonLine } from './reason-line.js' import { lockedWindow } from './schedules.js' import { relativeShort } from './text.js' import { isMainOf } from './sources.js' @@ -335,11 +336,14 @@ export function FlyBotApprovals({ useRoster, actions } = {}) { // #2409: one rule for all three surfaces — a number of ms, an ISO string, or nothing drawable. const at = typeof approval.createdAt === 'number' ? approval.createdAt : Date.parse(approval.createdAt) const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null - return h('div', { key: approval.id, className: 'bt-flybot-approval', title: approval.reason ?? undefined }, + // P-538: the host's reason is a sentence a person decides by, so it is drawn, not only titled. + const reason = reasonLine(approval.reason) + return h('div', { key: approval.id, className: 'bt-flybot-approval' }, h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval'), waiting ? age : null), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: approval.senderName ?? t('A Bot'), to: recipientName(approval, roster) }), approval.request.hop > 0 ? ` · ${t('delegated')}` : ''), + reason ? h('div', { className: 'bt-flybot-approval-reason', title: reason.title }, reason.text) : null, failed && approval.error ? h('div', { className: 'bt-flybot-approval-error' }, approval.error) : null, h('div', { className: 'bt-flybot-approval-text' }, approval.request.text), approval.request.attachments?.length @@ -386,6 +390,7 @@ export const FLYBOT_CSS = ` .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } .bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } +.bt-flybot-approval-reason { font-size: 12px; line-height: 1.4; margin: 0 0 6px; overflow-wrap: anywhere; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/core/src/client/held-reply.js b/core/src/client/held-reply.js index ad0a85068..ee7f25171 100644 --- a/core/src/client/held-reply.js +++ b/core/src/client/held-reply.js @@ -9,6 +9,7 @@ import { registry } from './flybot-registry.js' import { claimApprovals } from './approval-claims.js' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { reasonLine } from './reason-line.js' import { lockedWindow } from './schedules.js' import { hostText, t } from './i18n.js' import { relativeShort } from './text.js' @@ -65,9 +66,12 @@ export function HeldReplyActions({ held, actions, roster }) { // #2409: same rule as the dock and the room card; an absent or unparseable stamp draws nothing. const at = typeof held.createdAt === 'number' ? held.createdAt : Date.parse(held.createdAt) const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null + // P-538: the same sentence and the same bound the dock's card draws. + const reason = reasonLine(held.reason) const plain = h('div', { className: 'bt-held-card', role: 'region', 'aria-label': t('Waiting for your approval') }, h('div', { className: 'bt-held-head' }, t('Waiting for your approval'), age), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: held.from ?? t('A Bot'), to: held.toName })), + reason ? h('div', { className: 'bt-flybot-approval-reason', title: reason.title }, reason.text) : null, locked ? readOnlyLine() : h('div', { className: 'bt-flybot-approval-actions' }, h('button', { type: 'button', disabled: busy, onClick: () => decide('reject') }, t('Reject')), h('button', { type: 'button', className: 'bt-flybot-approve', disabled: busy, onClick: () => decide('approve') }, t('Approve'))), diff --git a/core/src/client/reason-line.js b/core/src/client/reason-line.js new file mode 100644 index 000000000..5ac596e2e --- /dev/null +++ b/core/src/client/reason-line.js @@ -0,0 +1,20 @@ +// Where a hold says why it waits (P-538). The host's sentence was reachable only as a `title` on +// the session dock's card — invisible to a keyboard, a touch screen and a screen reader — and the +// waiting bubble in a chat had no copy of it at all. Both drawing points read it here, so the two +// shapes cannot drift apart. +// +// The visible line is bounded to 200 code points with an ellipsis, and the whole sentence stays in +// `title`: a long reason is cut on screen without hiding its end from the person deciding the hold. +// The bound counts code points (`fits`) and falls between them, so an emoji is never split into two +// surrogates. A reason that is empty or only whitespace is no reason: the caller draws nothing, +// because an empty line would announce a hold with nothing to say. +import { fits } from '../shared/text-boundary.js' + +export const REASON_POINTS = 200 + +// `{ text, title }`, or `null` when the host gave no reason to show. +export const reasonLine = (reason) => { + if (typeof reason !== 'string' || reason.trim() === '') return null + const full = reason.toWellFormed() + return { text: fits(full, REASON_POINTS) ? full : [...full].slice(0, REASON_POINTS).join('') + '…', title: full } +} diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index ee1e51044..92f448ca0 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -568,6 +568,9 @@ export function install(rt) { turn: a.request.meta.replyIn.turn, to: toRemote(a.request.to)?.handle ?? null, ...(a.recipientName ? { toOwner: a.recipientName } : {}), + // P-538: the sentence the hold was made with travels with it, so the bubble in the chat can + // show why the reply waits instead of leaving it to the dock's hover title. + ...(a.reason ? { reason: a.reason } : {}), // #968: the view's answer is normalized here, where the record is read: `noRetry` and a terminal // sentence both become `retry: false`, so a record written before #954 gets the same answer as a new // one, and no renderer has to match a reason string. diff --git a/core/test/approval-reason.test.js b/core/test/approval-reason.test.js new file mode 100644 index 000000000..7f2b04d29 --- /dev/null +++ b/core/test/approval-reason.test.js @@ -0,0 +1,171 @@ +// P-538: the host's reason for holding a message was reachable only as a hover `title` on the +// session dock's approval card, and on a held reply's own bubble it was not there at all — `heldOf()` +// copied id/by/status/sessionId/turn/to/error and left `reason` behind, so the waiting card had +// nothing to say about why it waited. A `title` is invisible to a keyboard, a touch screen and a +// screen reader, and the waiting bubble is exactly where a person is looking. +// +// Both drawing points now show the same line under the origin: the host's sentence, one node, and +// the whole sentence again in `title` because the visible text is bounded to 200 code points with an +// ellipsis. A reason that is empty or only whitespace is no reason: neither surface draws an empty +// line, and neither draws a `title` for one. The bound is counted in code points (a character +// outside the BMP costs one) and never splits a surrogate pair, so `'😀'.repeat(201)` loses its last +// emoji whole rather than leaving half of one behind. +// +// React and the host call are replaced by the same tiny stubs the other client tests use. +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +for (const name of ['useRef', 'useMemo', 'useCallback', 'useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +globalThis.__hostCall = () => Promise.resolve([]) +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +function render(Component, inst) { + live.inst = inst + live.idx = 0 + inst.effects = [] + return Component(inst.props ?? {}) +} +const nodes = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) nodes(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + nodes(node.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const reasonNodes = tree => nodes(tree).filter(node => hasClass(node, 'bt-flybot-approval-reason')) +const labels = tree => nodes(tree).filter(node => node.type === 'button').map(node => text(node.children)) +const DECISIONS = ['Approve', 'Reject', 'Retry'] +const decisionLabels = tree => labels(tree).filter(label => DECISIONS.includes(label)).sort() +const pressables = tree => nodes(tree).filter(node => typeof node.props?.onClick === 'function') + +const PENDING = 6 +const host = { id: 'b1', name: 'Helper', ownerId: 'm1', sessionId: 's1' } +const writable = { self: { memberId: 'm1' }, bots: [host], byId: { b1: host }, rooms: [], roomsById: {}, readOnly: '' } +const locked = { ...writable, readOnly: 'Another FlyBot window has this Bot team open, so this window is read-only.' } +const REASON = 'The room is locked: one of the members has not opened this chat yet.' +const approval = (extra = {}) => ({ + id: 'a1', status: 'pending', by: 'tree', senderName: 'Helper', targetName: '@chief.uia.home', reason: REASON, + request: { from: 'bot-helper', to: { handle: '@chief.uia.home' }, text: 'Which file?', hop: 0 }, + ...extra, +}) +const dock = (roster, list) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { actions: {}, useRoster: pick => pick(roster) } } + render(FlyBotApprovals, inst) + inst.hooks[PENDING].value = list + return render(FlyBotApprovals, inst) +} +const bubble = (roster, held = { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason: REASON }) => + render(HeldReplyActions, { hooks: {}, effects: [], rendered: false, props: { held, actions: {}, roster } }) + +let FlyBotApprovals +let HeldReplyActions +test.before(async () => { + ;({ FlyBotApprovals } = await import('../src/client/flybot-slots.js')) + ;({ HeldReplyActions } = await import('../src/client/held-reply.js')) +}) + +test('P-538: the dock card shows the hold reason once, with the whole sentence in the title', () => { + const card = dock(writable, [approval()]) + const shown = reasonNodes(card) + assert.equal(shown.length, 1, 'the card draws the reason as its own line') + assert.equal(text(shown[0]), REASON, 'the visible line is the host sentence, verbatim') + assert.equal(shown[0].props.title, REASON, 'the whole sentence stays reachable as a title') + // It is a sentence, not a control: the decision row is unchanged and nothing inside the line is pressable. + assert.deepEqual(decisionLabels(card), ['Approve', 'Reject'], 'the reason does not replace the decisions') + assert.deepEqual(pressables(shown[0]), [], 'the reason line carries no control') +}) + +test('P-538: 200 code points show whole, 201 lose one codepoint to an ellipsis, the title keeps it all', () => { + const full200 = 'a'.repeat(200) + const full201 = 'b'.repeat(201) + const shortCard = dock(writable, [approval({ reason: full200 })]) + assert.equal(text(reasonNodes(shortCard)[0]), full200, 'a sentence of exactly 200 code points is not cut') + assert.equal(reasonNodes(shortCard)[0].props.title, full200) + + const longCard = dock(writable, [approval({ reason: full201 })]) + const long = reasonNodes(longCard)[0] + assert.equal(text(long), `${'b'.repeat(200)}…`, 'the 201st code point gives way to the ellipsis') + assert.equal([...text(long)].length, 201, 'the cut is counted in code points') + assert.equal(long.props.title, full201, 'the title carries the sentence that was cut') + + // A character outside the BMP costs one code point and must not be split in half. + const emoji = '😀'.repeat(201) + const emojiCard = dock(writable, [approval({ reason: emoji })]) + const cut = text(reasonNodes(emojiCard)[0]) + assert.equal(cut, `${'😀'.repeat(200)}…`, 'the cut falls between two emoji, never inside one') + assert.equal([...cut].length, 201) + assert.ok(cut.isWellFormed(), 'no lone surrogate survives the cut') + assert.equal(reasonNodes(emojiCard)[0].props.title, emoji, 'the title keeps all 201 emoji') +}) + +test('P-538: no reason, an empty reason and a blank reason draw no line and no empty title', () => { + for (const [name, reason] of [['absent', undefined], ['null', null], ['empty', ''], ['blank', ' \n\t ']]) { + const card = dock(writable, [approval({ reason })]) + assert.deepEqual(reasonNodes(card), [], `${name}: the card draws no reason line`) + assert.equal(card.props.title, undefined, `${name}: the card carries no empty title`) + const shown = text(card) + assert.match(shown, /Waiting for approval/, `${name}: the headline stays`) + assert.match(shown, /Helper → @chief\.uia\.home/, `${name}: the origin stays`) + assert.match(shown, /Which file\?/, `${name}: the message stays`) + assert.deepEqual(reasonNodes(bubble(writable, { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason })), [], `${name}: the waiting bubble draws no reason line`) + } +}) + +test('P-538: a read-only window keeps the reason and still draws no decision', () => { + const card = dock(locked, [approval()]) + const shown = reasonNodes(card) + assert.equal(shown.length, 1, 'the reason is not a write: a locked window still shows it') + assert.equal(text(shown[0]), REASON) + assert.deepEqual(decisionLabels(card), [], 'the decisions are still gone') + assert.deepEqual(pressables(card), [], 'nothing on the card is pressable') +}) + +test('P-538: the held reply bubble draws the same line, same bound, same title', () => { + const waiting = bubble(writable) + const shown = reasonNodes(waiting) + assert.equal(shown.length, 1, 'the waiting card draws the host reason') + assert.equal(text(shown[0]), REASON, 'the same sentence as the dock card') + assert.equal(shown[0].props.title, REASON, 'the same title') + assert.deepEqual(decisionLabels(waiting), ['Approve', 'Reject'], 'the decisions stay while waiting') + + const long = 'c'.repeat(201) + const longShown = reasonNodes(bubble(writable, { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason: long }))[0] + assert.equal(text(longShown), `${'c'.repeat(200)}…`, 'the bubble uses the same 200-code-point bound') + assert.equal(longShown.props.title, long, 'and keeps the whole sentence in its title') +}) diff --git a/core/test/held-reply-seam.test.js b/core/test/held-reply-seam.test.js index 0eaa879ce..74591935d 100644 --- a/core/test/held-reply-seam.test.js +++ b/core/test/held-reply-seam.test.js @@ -185,6 +185,9 @@ test('#986: a terminal failure reaches the view as retry:false, and its card dra assert.equal(byTurn[4].status, 'failed') assert.equal('retry' in byTurn[4], false, 'a retryable failure is not marked terminal') assert.equal(byTurn[4].error, 'HTTP 500') + // P-538: the sentence the hold was made with is on the view too, so the waiting card can say why + // the reply waits instead of offering a hover title on the dock alone. + assert.equal(byTurn[4].reason, 'held by test', 'the view carries the reason the hold was made') // Draw the very objects the view returned, on the turns they answer. const roster = rosterOf(view) diff --git a/core/test/overlay-approvals.test.js b/core/test/overlay-approvals.test.js index 278b7fc6c..2dc4ac6cf 100644 --- a/core/test/overlay-approvals.test.js +++ b/core/test/overlay-approvals.test.js @@ -90,7 +90,7 @@ test('#260: one card per approval: a dock-drawn one is not repeated, and reads l assert.match(text, /Waiting for approval/) assert.match(text, /Helper → @chief\.uia\.home/) assert.doesNotMatch(text, /Sent by/) - assert.doesNotMatch(text, /"children":\["[^"]*need your approval/, 'the long policy reason is a tooltip, not a headline') + assert.match(text, /"className":"bt-flybot-approval-reason","title":"messages to @chief\.uia\.home's Bots need your approval"/, 'P-538: the policy reason is drawn as its own line, not left to a hover title') assert.equal((text.match(/bt-flybot-approval"/g) ?? []).length, 1) // A chat that shows a2 claims it: nothing more to draw for it. inst.hooks[PENDING].value = [held({ id: 'a2' })] diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 158a1d628..73fb01482 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -95,7 +95,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). The face-room create prompt (*Start a new room with {code}*, and the hint under it, #1053) is in that same map, so a zh UI reads it in Chinese. So are the social picker's two face-room rows and the screen they open (#1021): **Join by number or link** (用号码或链接加入) and **Start a face-to-face room** (面对面建群), the start row's hint, and its own about line, all four used through `t()` — so both rows and both dialog titles read Chinese, and the single key #892 wrote for one combined row (*Join or create by number or link*) is gone. -The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send `canRetry()` allows, #968). Approve / Reject is drawn only for a status `isApprovalWaiting()` calls waiting (`pending`, `pending_approval`), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry only while `canRetry()` allows it (#968). Since #2310 a read-only window (`lockedWindow`, `schedules.js`) draws none of the three: the card keeps its two lines, the recipient and the text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`) in their place, because `decide` is not in the host's read path and the buttons could only earn `bot/read-only` after the click. The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). Since #2409 a waiting card's head line carries the age of the wait beside it (`Waiting for approval · 12 min`, `relativeShort()`), while a failed `Not sent` card draws none. The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). +The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`, then the policy's reason as its own line) and Approve / Reject (Retry for a failed send `canRetry()` allows, #968). Approve / Reject is drawn only for a status `isApprovalWaiting()` calls waiting (`pending`, `pending_approval`), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry only while `canRetry()` allows it (#968). Since #2310 a read-only window (`lockedWindow`, `schedules.js`) draws none of the three: the card keeps its two lines, the recipient and the text, and carries the host's own sentence in one `role="status"` line (`bt-hint bt-read-only`) in their place, because `decide` is not in the host's read path and the buttons could only earn `bot/read-only` after the click. The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). Since #2409 a waiting card's head line carries the age of the wait beside it (`Waiting for approval · 12 min`, `relativeShort()`), while a failed `Not sent` card draws none. The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). P-538: the hold's reason is drawn, not only titled — `client/reason-line.js` `reasonLine(reason)` answers the visible text and a `title`, the visible line stops at 200 code points with `…` and the whole sentence stays in the title, and a reason that is empty or only whitespace answers `null`, so neither surface draws an empty line (`bt-flybot-approval-reason`). Tests: `core/test/approval-reason.test.js`. ### Face-to-face room lead @@ -260,7 +260,7 @@ A group or face room shows only its own work (#553, owner decision 2026-10-20 23 - FlyBot `restrict()`s DSH's `ask_user_question` off Bot sessions (`hideBlockingQuestion`). DSH then appends a developer `tool-removal` context row, and `@deepseek-ai/dsh-client-ui-chat` renders it as "Tool removed: {name}". `ContextCell` shadows `conversation.chat.node` `context` and returns null for those notices (`HIDDEN_REMOVED_TOOLS`). A tool a person turns on, and an unrelated removal, still show; `tool-call` cells are unchanged. Empty shadowed nodes collapse via the existing `:empty` flow-item rule. Covered by `core/test/tool-cells.test.js`. - The product's own tools are the same kind of row (#1101). DSH appends a developer message when a session's request header gains or loses a tool. `ContextCell` does not print `Tool added` / `Tool removed` for the team-tool names (`TEAM_TOOLS` in `src/host/constants.js`; the client keeps the same list, so a new team tool is added there in the same pull request — `archive_bot` (#638) is the newest) or for `track_links`, the notice the other home of that report showed on a plain math turn. A call the Bot actually makes stays a tool cell. `web_search` and `bash` still show. Covered by `core/test/hidden-tool-removal.test.js`. -- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send that `canRetry()` allows; a terminal failure draws no button, #968); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the person who gets the reply (#418, #498): the host's `toOwner` (from the hold's `recipientName`, stored, so the same live and after a reload), else the owner of the friend's chat (`friendBotOwner`), so the card reads `Chief → SelfB1233`. The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. In a read-only window the same lock (`lockedWindow`) replaces that bubble's Approve / Reject, and a failed send's Retry, with the host's own sentence on one line, while the bubble itself is drawn as before (#2310, `core/test/approvals-readonly.test.js`). Since #2409 the waiting card's head line also carries how long the reply has waited (`Waiting for your approval · 12 min`), from the `createdAt` the host now keeps on `heldReplies`. +- A Bot's held reply to a friend is drawn on its own turn (#418): `AssistantCell` (`cells.js`) finds it in the Bot's `heldReplies` (`text.js` `heldReplyOf`, by session and turn) and wraps the bubbles in `HeldReply` (`bt-held-reply`, `data-state` pending / approved / rejected / failed; pending and not-sent bubbles are dashed and muted). The turn's closing text carries `HeldReplyActions` (`held-reply.js`): while it waits, the holding policy's inline `flybot.approvals` entry (`inline: true`, props `{ held, decide, call, actions }`) or plain Approve / Reject, above them the hold's reason in the same shape the dock's card uses (P-538: one `bt-flybot-approval-reason` line, 200 code points + `…`, the whole sentence in `title`, nothing for an empty or blank reason), and it claims the id so no overlay card or pointer repeats it; after the decision a quiet `Sent to {to}` / `Not sent` line (Retry for a failed send that `canRetry()` allows; a terminal failure draws no button, #968); a reply that went through Auto-send (`auto`, #417) is a plain bubble with `Auto-sent to {to}`. The recipient is the person who gets the reply (#418, #498): the host's `toOwner` (from the hold's `recipientName`, stored, so the same live and after a reload), else the owner of the friend's chat (`friendBotOwner`), so the card reads `Chief → SelfB1233`. The sidebar preview (`previewOf` `unsentTurns`) shows the friend's line for a turn whose reply did not go. The overlay ignores inline entries. In a read-only window the same lock (`lockedWindow`) replaces that bubble's Approve / Reject, and a failed send's Retry, with the host's own sentence on one line, while the bubble itself is drawn as before (#2310, `core/test/approvals-readonly.test.js`). Since #2409 the waiting card's head line also carries how long the reply has waited (`Waiting for your approval · 12 min`), from the `createdAt` the host now keeps on `heldReplies`. - The held-send overlay (`FlyBotApprovals`, `flybot-slots.js`) runs every hook before its `if (pending.length === 0) return null`: a hook after that return made React throw on the first held message and no card ever drew (#254). `test/overlay-approvals.test.js` renders it with a stand-in that enforces React's hook-count rule. ### Connectors page and the Composio row (#311) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b713c94d2..34144fdf6 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -417,7 +417,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Remote chats and mentions -- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`, and since #2409 the record's own `createdAt`, so the bubble can draw how long it has waited; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` answers from the flags (`retry: false`, `noRetry: true`) or, for a record written before them, from the stored `error`/`code` naming a terminal reason (`no such remote bot`, `is not shared with your Bots`, `is no longer shared with you`, `has not granted chat.public`, `bot_not_shared`, `bot_not_found`; the bare code `capability_not_granted` is not one, #988), so `heldOf()` maps a hold's `retry: false` through it and `approvals.list` marks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). The seam those two spellings cross is covered end to end by `core/test/held-reply-seam.test.js` (#986): a real hold whose dispatch fails `noRetry` lands in the `state` view as `retry: false` and the card drawn from that same object offers no Retry, a retryable failure keeps the button, and both suppressions (a friend's chat, a viewer who is not the owner) are read through the same view. Still not team mode. +- Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. A local team Bot gets the count of its own held sends when the provider names them (`fromId`, #371), plus its own waiting held replies to friends (#418); otherwise 0. Those replies are also listed on the Bot as `heldReplies` (`rt.heldRepliesOf`, hooks.js: id, policy, status pending / approved / rejected / failed, or `auto` for a reply a middleware let go on the owner's Auto-send (#417, `state.flybot.autoSent`, last 200), the chat `sessionId` and `turn` the reply answers, the friend's handle and `toOwner`, since #2409 the record's own `createdAt`, so the bubble can draw how long it has waited, and since P-538 the hold's own `reason` when it has one, so the card can show why it waits; no text), for the Bot's owner only under enforcement. `turns.js` passes the answered turn to `flybotReplyRemote` (`replyIn`), which puts it on the auto-reply's `meta.replyIn`; a hold record superseded by a later hold (`next`) is left out. A middleware's `hold` verdict may name its `recipient` (a person's name, at most 120 characters); the record keeps it as `recipientName` for the card. A failed dispatch keeps its error, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend's Bot no longer shared — is passed on as `retry: false` so the chat draws no Retry (#954); every other failure keeps the Retry it always had. Which failures those are is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` answers from the flags (`retry: false`, `noRetry: true`) or, for a record written before them, from the stored `error`/`code` naming a terminal reason (`no such remote bot`, `is not shared with your Bots`, `is no longer shared with you`, `has not granted chat.public`, `bot_not_shared`, `bot_not_found`; the bare code `capability_not_granted` is not one, #988), so `heldOf()` maps a hold's `retry: false` through it and `approvals.list` marks a failed terminal approval the same way — the two spellings cannot drift apart. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). The seam those two spellings cross is covered end to end by `core/test/held-reply-seam.test.js` (#986): a real hold whose dispatch fails `noRetry` lands in the `state` view as `retry: false` and the card drawn from that same object offers no Retry, a retryable failure keeps the button, and both suppressions (a friend's chat, a viewer who is not the owner) are read through the same view. Still not team mode. - Channel `live()` also sends `pluginTools`: the names of plugin tools a per-Bot mask may take away (`rt.pluginToolNames`, from `hidePluginTools`' gated set), so the client hides their `Tool added` / `Tool removed` rows (#419). A plugin reload changes them without a new revision, so they ride every `state` answer, an `unchanged` one too. - Channel `state` notes when a window on screen last read the team (#497): the client sends `watching: !document.hidden` with every poll (a visible window asks every 1.5 s, a hidden one every 10 s), and `watching: false` does not count; an older client that sends nothing counts as on screen. `rt.watched()` is true for 8 s after such a read and reaches plugins as `flybot.watched()` ([plugin-runtime](plugin-runtime.md)). Process-local, never saved. Covered by `core/test/flybot.test.js` #497, which also locks in that a friend's first line and its held reply move the revision, so a window that keeps polling reads `friendLineAt`, `pendingCount` and `heldReplies` with no reload. - A face-to-face room's presented Bot participants carry `label` = `remoteBotTitle`, ` ()`, for remote and local seats alike (`room-membership.js`, #384); `faceAliases` still answers to the older ` · 's Bot` / `'s Chief` labels. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 391819479..30f0fde79 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -34,7 +34,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots | File | Role | | --- | --- | -| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. Since #2409 the chat card's `heldOf()` map also carries the record's own `createdAt`, so `HeldReplyActions` can say how long the reply has waited. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). | +| `core/src/host/hooks.js` | Host hook points: send middleware, transports, grants, the owner-bound held-send approvals (#52), task hold/decide/delete seams (#54), and `bot.update({ name?, hidden? })` for plugin rename/hide (#452). A hold's waiting status is the shared rule `src/shared/approval-waiting.js` `isApprovalWaiting()`: `decide()`, `heldOf()` (the chat card; since P-538 it also carries the record's `reason`, so the bubble can say why it waits), `PENDING_MAX`, the in-flight filter and the `APPROVALS_KEPT` cleanup all ask it instead of comparing to `pending`, so a `pending_approval` hold is decidable, drawn as waiting, counted and never cleaned up mid-wait (#754). It also exposes `flybot.approvals.stats()` — per-Bot, per-status counts of held sends, read-only (#396/#57 direction). A dispatch that fails keeps its error on the record, and an error the sender marks `noRetry: true` — a refusal nothing can change, such as a friend’s Bot no longer shared — reaches the chat view as `retry: false` so the card draws no Retry (#954); every other failure stays retryable. Whether a failure can still be retried is one rule in `src/shared/held-retry.js` (#968): `canRetry(record)` reads the flags or, for an older record, the stored sentence, and `approvals.list` marks a failed terminal approval `retry: false` so a dock card and the chat card answer the same question the same way. Since #2409 the chat card's `heldOf()` map also carries the record's own `createdAt`, so `HeldReplyActions` can say how long the reply has waited. `rooms.list()` copies `sharedFrom.remoteRoomId`, `ticket` and `inviterHandle` when a room has them, so a mention of an inbound seat can name the host's room id inside the share line (#1126). | | `core/src/host/flybot-feed.js` | Activity feed: folds a Bot Session's `session/event`s into `flybot.on('turn.start' \| 'turn.end' \| 'tool.start' \| 'tool.end' \| 'exchange.delivered' \| 'decision')` (#241). `turn.end` also carries the turn's token counts (summed from its `assistant/message` usage, floored and capped, counts only) under `tokens` (#357). `decision` is fired by `decisions.js` (#396), not folded from a Session event. Since #1912 `cleanLabel` bounds its text through `cut` in `text.js`, so a cleaned tool name or remote handle never ends on half of a surrogate pair. | | `core/src/client/flybot-slots.js` | Client slots, including the approval card queue (`flybot.approvals`, `flybot/decide`) and `flybot.sidebar.structure` (`parentOf`, optional `include`, `menuItems({ bot, readOnly })`, `nodeActions`); `readOnly` is true only in a window that holds the team read-only, the rule `workflows.js` applies to `roster.readOnly`, so a structure entry hides a write the host would refuse there (#2238). Every `flybot.sidebar.section` entry is wrapped in `div.bt-flybot-section`, and a wrapper a section leaves empty (its component renders null) draws no separator: the `:empty` rule zeroes that margin, border and padding, keyed on emptiness rather than on a section id, so the next null-rendering section is covered too (#818; `core/test/flybot-section-empty.test.js` renders the host entry and pins both rules). Since #2409 a waiting card's head line carries how long the hold has waited beside it (`Waiting for approval · 12 min`, `relativeShort()` from `client/text.js`); a failed `Not sent` card and a record without `createdAt` draw no age. Its Approve / Reject and the held-reply card both ask `approval-waiting.js` `isApprovalWaiting()` (`pending` and `pending_approval` wait), so a hold carrying the spelling Bot Social's own outbound hold uses stays answerable (#734). Its dock card draws **Retry** only while `src/shared/held-retry.js` `canRetry(approval)` allows it — a terminal failure asks one shared predicate instead of `status === 'failed'` (#968) — and it publishes `globalThis.__flybot__.canRetry` so a classic-script plugin's own dock asks the same rule rather than keeping a second copy. | | `core/src/client/flybot-registry.js` | Registry. A friend's Bot row in the @ menu carries `remoteMentionLabel` — the one name its bubble and the sidebar preview wear — so the menu inserts the name the user reads, while the row keeps the handle as its value and the send still routes by handle (#967). | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 43d4c116e..c08220570 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -2191,7 +2191,7 @@ diff --git a/client.js b/client.js var formatTokens = (count) => count >= 1e6 ? `${(count / 1e6).toFixed(1)}M` : count >= 1e3 ? `${(count / 1e3).toFixed(1)}K` : String(count); function usageOf(list, ids) { let input = 0; -@@ -1624,1039 +1624,3126 @@ function usageOf(list, ids) { +@@ -1624,1039 +1624,3137 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -3132,6 +3132,35 @@ diff --git a/client.js b/client.js -.bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} -.bt-send:disabled{opacity:.35;cursor:default} -.bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} ++// src/client/reason-line.js ++var REASON_POINTS = 200; ++var reasonLine = (reason) => { ++ if (typeof reason !== "string" || reason.trim() === "") return null; ++ const full2 = reason.toWellFormed(); ++ return { text: fits(full2, REASON_POINTS) ? full2 : [...full2].slice(0, REASON_POINTS).join("") + "…", title: full2 }; ++}; + +-.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} +-.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} +-.bt-dialog .bt-head{height:62px} +-.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} +-.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} +-.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} +-.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} +-@keyframes bt-sheet-in{from{transform:translateY(24px)}} +-@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} +-.bt-exchange .bt-dialog{position:relative} +-.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} +-.bt-exchange .bt-tools{display:none} +-.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} +-.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} +-.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} +-@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} +-@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} +-@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} +-.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} +-.bt-soft:hover{background:var(--bt-active)} +-.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} +// src/shared/face-title.js +var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +function isGenericFaceTitle(name) { @@ -3198,98 +3227,6 @@ diff --git a/client.js b/client.js + return bits.join(" · "); +} --.bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} --.bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} --.bt-dialog .bt-head{height:62px} --.bt-dialog-log{flex:1;overflow-y:auto;padding:8px 16px 20px;width:100%;max-width:var(--dsh-chat-content-width,748px);margin:0 auto;box-sizing:border-box} --.bt-dialog-foot{display:flex;justify-content:center;padding:10px 10px 16px} --.bt-exchange{animation:bt-sheet-in ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing} both,bt-veil-in .24s ease-out both} --.bt-exchange[data-leaving]{animation:bt-sheet-out .22s cubic-bezier(.4,0,1,1) forwards;pointer-events:none} --@keyframes bt-sheet-in{from{transform:translateY(24px)}} --@keyframes bt-sheet-out{to{opacity:0;transform:translateY(16px);filter:blur(4px)}} --.bt-exchange .bt-dialog{position:relative} --.bt-exchange .bt-dialog-log{max-width:none;margin:0;padding:39px max(16px,calc((100% - 960px)/2)) 76px;scrollbar-width:thin} --.bt-exchange .bt-tools{display:none} --.bt-exchange .bt-dialog-foot{position:absolute;left:0;right:0;bottom:0;height:76px;box-sizing:border-box;align-items:flex-end;padding:0 0 28px;background:var(--bt-main);z-index:3;animation:bt-foot-in .3s cubic-bezier(.22,1,.36,1) .3s both} --.bt-exchange .bt-dialog-foot::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:40px;background:linear-gradient(to top,var(--bt-main),transparent);pointer-events:none} --.bt-exchange[data-leaving] .bt-dialog-foot{animation:bt-foot-out .22s cubic-bezier(.4,0,1,1) forwards} --@keyframes bt-foot-in{from{opacity:0;transform:translateY(6px)}} --@keyframes bt-foot-out{to{opacity:0;transform:translateY(6px)}} --@media (prefers-reduced-motion:reduce){.bt-exchange,.bt-exchange .bt-dialog-foot{animation-duration:.01s}} --.bt-soft{height:36px;border-radius:999px;border:0;background:var(--bt-hover);color:var(--bt-ink);padding:0 16px;font-size:14px;cursor:pointer} --.bt-soft:hover{background:var(--bt-active)} --.bt-time{display:block;text-align:center;font-size:12px;line-height:16px;color:var(--bt-ink-2);padding:6px 0} -+// src/client/brand.js -+var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; -+var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; -+var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; -+function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { -+ return ``; -+} -+function brandTitle(title) { -+ const text2 = String(title ?? ""); -+ if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot"; -+ if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; -+ if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot"); -+ return text2; -+} -+function sessionTabLabel(roster, sessionId) { -+ if (!roster || !sessionId) return ""; -+ const bot = roster.byId?.[sessionId]; -+ const room2 = roster.roomsById?.[sessionId]; -+ if (bot?.remote) { -+ return friendBotTitle(bot); -+ } -+ if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; -+ if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); -+ if (room2) return roomTitle(room2); -+ return bot?.name || ""; -+} -+function installTabTitle(sessions, roster, doc = globalThis.document) { -+ if (!doc || !sessions || !roster) return () => { -+ }; -+ const sync = () => { -+ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); -+ const label = sessionTabLabel(roster.getSnapshot(), row?.id); -+ if (!label) return; -+ const next = brandTitle(`${label} — FlyBot`); -+ if (doc.title !== next) doc.title = next; -+ }; -+ sync(); -+ const offSessions = sessions.subscribe(sync); -+ const offRoster = roster.subscribe(sync); -+ return () => { -+ offSessions?.(); -+ offRoster?.(); -+ }; -+} -+function installBrandChrome() { -+ if (typeof document === "undefined") return () => { -+ }; -+ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; -+ let link = document.getElementById("flybot-favicon"); -+ if (!link) { -+ link = document.createElement("link"); -+ link.id = "flybot-favicon"; -+ link.rel = "icon"; -+ link.type = "image/svg+xml"; -+ document.head.appendChild(link); -+ } -+ link.href = href; -+ const retitle = () => { -+ const next = brandTitle(document.title); -+ if (next !== document.title) document.title = next; -+ }; -+ retitle(); -+ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; -+ const node = document.querySelector("title") ?? document.head; -+ observer?.observe(node, { childList: true, characterData: true, subtree: true }); -+ return () => { -+ observer?.disconnect(); -+ link.remove(); -+ }; -+} - -.bt-panel{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;width:var(--bt-panel-w,320px);pointer-events:auto;z-index:40;background:var(--bt-main);border-left:.5px solid var(--bt-line-2);display:flex;flex-direction:column;color:var(--bt-ink);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;box-sizing:border-box;animation:bt-pane-in .3s cubic-bezier(.2,0,0,1);transition:width ${SPRING_SHAPE.duration}ms ${SPRING_SHAPE.easing}} -.bt-panel[data-leaving]{transform:translateX(100%);transition:transform .3s cubic-bezier(.2,0,0,1);pointer-events:none} -.bt-panel[data-instant]{transition:none} @@ -3450,7 +3387,77 @@ diff --git a/client.js b/client.js -.bt-connector-form-row{display:flex;align-items:center;gap:6px} -.bt-connector-link{flex:1;font-size:12px;color:var(--bt-ink-2)} -.bt-connector-error{padding-left:48px;font-size:12px;line-height:16px;color:#e02135} -- ++// src/client/brand.js ++var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; ++var FLYBOT_WING_LOW = "M150 318 C 104 300, 62 306, 54 322 C 48 338, 84 352, 150 352 Z"; ++var FLYBOT_BODY = "M196 132 H316 A80 80 0 0 1 396 212 V300 A80 80 0 0 1 316 380 H250 L188 432 V374 A80 80 0 0 1 116 300 V212 A80 80 0 0 1 196 132 Z"; ++function flybotMarkSvg({ wing = "#8FB2FF", brand = "#3B5BFD", face = "#FFFFFF" } = {}) { ++ return ``; ++} ++function brandTitle(title) { ++ const text2 = String(title ?? ""); ++ if (text2 === "DeepSeek Harness" || text2 === "DS Bot") return "FlyBot"; ++ if (text2.endsWith(" — DeepSeek Harness")) return `${text2.slice(0, -" — DeepSeek Harness".length)} — FlyBot`; ++ if (/\bDS Bot\b/.test(text2)) return text2.replace(/\bDS Bot\b/g, "FlyBot"); ++ return text2; ++} ++function sessionTabLabel(roster, sessionId) { ++ if (!roster || !sessionId) return ""; ++ const bot = roster.byId?.[sessionId]; ++ const room2 = roster.roomsById?.[sessionId]; ++ if (bot?.remote) { ++ return friendBotTitle(bot); ++ } ++ if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; ++ if (room2?.origin === "face-to-face") return faceRowTitle(roomTitle(room2)); ++ if (room2) return roomTitle(room2); ++ return bot?.name || ""; ++} ++function installTabTitle(sessions, roster, doc = globalThis.document) { ++ if (!doc || !sessions || !roster) return () => { ++ }; ++ const sync = () => { ++ const row = Object.values(sessions.getSnapshot().byId ?? {}).find((session) => (session?.retainedBy?.mainView ?? 0) > 0); ++ const label = sessionTabLabel(roster.getSnapshot(), row?.id); ++ if (!label) return; ++ const next = brandTitle(`${label} — FlyBot`); ++ if (doc.title !== next) doc.title = next; ++ }; ++ sync(); ++ const offSessions = sessions.subscribe(sync); ++ const offRoster = roster.subscribe(sync); ++ return () => { ++ offSessions?.(); ++ offRoster?.(); ++ }; ++} ++function installBrandChrome() { ++ if (typeof document === "undefined") return () => { ++ }; ++ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; ++ let link = document.getElementById("flybot-favicon"); ++ if (!link) { ++ link = document.createElement("link"); ++ link.id = "flybot-favicon"; ++ link.rel = "icon"; ++ link.type = "image/svg+xml"; ++ document.head.appendChild(link); ++ } ++ link.href = href; ++ const retitle = () => { ++ const next = brandTitle(document.title); ++ if (next !== document.title) document.title = next; ++ }; ++ retitle(); ++ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; ++ const node = document.querySelector("title") ?? document.head; ++ observer?.observe(node, { childList: true, characterData: true, subtree: true }); ++ return () => { ++ observer?.disconnect(); ++ link.remove(); ++ }; ++} + -/* Shell chrome reshaped for the team view. Selectors use stable data attributes where the - shell offers them and CSS-module name suffixes elsewhere. */ -[class*="_logoRow"],[class*="_newSession"],nav[class*="_panelList"]{display:none!important} @@ -5061,9 +5068,10 @@ diff --git a/client.js b/client.js + const waiting = isApprovalWaiting(approval.status); + const at = typeof approval.createdAt === "number" ? approval.createdAt : Date.parse(approval.createdAt); + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; ++ const reason = reasonLine(approval.reason); + return (0, import_react6.createElement)( + "div", -+ { key: approval.id, className: "bt-flybot-approval", title: approval.reason ?? void 0 }, ++ { key: approval.id, className: "bt-flybot-approval" }, + (0, import_react6.createElement)("div", { className: "bt-flybot-approval-head" }, failed ? t("Not sent") : t("Waiting for approval"), waiting ? age : null), + (0, import_react6.createElement)( + "div", @@ -5071,6 +5079,7 @@ diff --git a/client.js b/client.js + t("{from} → {to}", { from: approval.senderName ?? t("A Bot"), to: recipientName(approval, roster) }), + approval.request.hop > 0 ? ` · ${t("delegated")}` : "" + ), ++ reason ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, + failed && approval.error ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-error" }, approval.error) : null, + (0, import_react6.createElement)("div", { className: "bt-flybot-approval-text" }, approval.request.text), + approval.request.attachments?.length ? (0, import_react6.createElement)("ul", { className: "bt-flybot-approval-files", "aria-label": t("Attached files") }, approval.request.attachments.map((file, index) => (0, import_react6.createElement)("li", { key: index, title: file.path ?? file.name }, `${file.name} · ${fileSize(file.size)}`, file.path ? (0, import_react6.createElement)("div", { className: "bt-flybot-approval-path" }, file.path) : null))) : null, @@ -5116,6 +5125,7 @@ diff --git a/client.js b/client.js +.bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } +.bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } +.bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } ++.bt-flybot-approval-reason { font-size: 12px; line-height: 1.4; margin: 0 0 6px; overflow-wrap: anywhere; } +.bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } +.bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } +.bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } @@ -6326,7 +6336,7 @@ diff --git a/client.js b/client.js @keyframes bt-typing-in{from{opacity:0;transform:scale(.9)}} @keyframes bt-typing-out{from{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.92);filter:blur(3px)}} @keyframes bt-typing-label{from{opacity:0;filter:blur(5px);transform:translateY(3px)}} -@@ -2676,2104 +2676,4375 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} +@@ -2676,2104 +2676,4377 @@ header[class*="_header"] .bt-head{flex:1 1 auto;width:100%} [data-bt-enter="new"]{animation:none} [data-bt-enter="after-collapse"]{animation:bt-fade-in .12s ease-out} } @@ -7404,11 +7414,13 @@ diff --git a/client.js b/client.js - return names; + const at = typeof held.createdAt === "number" ? held.createdAt : Date.parse(held.createdAt); + const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null; ++ const reason = reasonLine(held.reason); + const plain = (0, import_react10.createElement)( + "div", + { className: "bt-held-card", role: "region", "aria-label": t("Waiting for your approval") }, + (0, import_react10.createElement)("div", { className: "bt-held-head" }, t("Waiting for your approval"), age), + (0, import_react10.createElement)("div", { className: "bt-flybot-approval-origin" }, t("{from} → {to}", { from: held.from ?? t("A Bot"), to: held.toName })), ++ reason ? (0, import_react10.createElement)("div", { className: "bt-flybot-approval-reason", title: reason.title }, reason.text) : null, + locked ? readOnlyLine() : (0, import_react10.createElement)( + "div", + { className: "bt-flybot-approval-actions" }, diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 7bc94ceea..e6be8c1bb 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -46,6 +46,7 @@ import { hostText, t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { reasonLine } from './reason-line.js' import { lockedWindow } from './schedules.js' import { relativeShort } from './text.js' import { isMainOf } from './sources.js' @@ -335,11 +336,14 @@ export function FlyBotApprovals({ useRoster, actions } = {}) { // #2409: one rule for all three surfaces — a number of ms, an ISO string, or nothing drawable. const at = typeof approval.createdAt === 'number' ? approval.createdAt : Date.parse(approval.createdAt) const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null - return h('div', { key: approval.id, className: 'bt-flybot-approval', title: approval.reason ?? undefined }, + // P-538: the host's reason is a sentence a person decides by, so it is drawn, not only titled. + const reason = reasonLine(approval.reason) + return h('div', { key: approval.id, className: 'bt-flybot-approval' }, h('div', { className: 'bt-flybot-approval-head' }, failed ? t('Not sent') : t('Waiting for approval'), waiting ? age : null), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: approval.senderName ?? t('A Bot'), to: recipientName(approval, roster) }), approval.request.hop > 0 ? ` · ${t('delegated')}` : ''), + reason ? h('div', { className: 'bt-flybot-approval-reason', title: reason.title }, reason.text) : null, failed && approval.error ? h('div', { className: 'bt-flybot-approval-error' }, approval.error) : null, h('div', { className: 'bt-flybot-approval-text' }, approval.request.text), approval.request.attachments?.length @@ -386,6 +390,7 @@ export const FLYBOT_CSS = ` .bt-flybot-approval { background: var(--bt-card, #fff); border: 1px solid var(--bt-line, rgba(0,0,0,.12)); border-radius: 10px; padding: 10px 12px; box-shadow: 0 4px 16px rgba(0,0,0,.12); font-size: 13px; } .bt-flybot-approval-head { font-weight: 600; margin-bottom: 4px; } .bt-flybot-approval-origin { font-size: 12px; opacity: .7; margin-bottom: 4px; } +.bt-flybot-approval-reason { font-size: 12px; line-height: 1.4; margin: 0 0 6px; overflow-wrap: anywhere; } .bt-flybot-approval-text { white-space: pre-wrap; max-height: 120px; overflow: auto; margin-bottom: 8px; } .bt-flybot-approval-path { font-family: ui-monospace, monospace; font-size: 11px; opacity: .7; overflow-wrap: anywhere; } .bt-flybot-approval-actions { display: flex; gap: 8px; justify-content: flex-end; } diff --git a/patches/ds-bot/files/src/client/held-reply.js b/patches/ds-bot/files/src/client/held-reply.js index ad0a85068..ee7f25171 100644 --- a/patches/ds-bot/files/src/client/held-reply.js +++ b/patches/ds-bot/files/src/client/held-reply.js @@ -9,6 +9,7 @@ import { registry } from './flybot-registry.js' import { claimApprovals } from './approval-claims.js' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { canRetry } from '../shared/held-retry.js' +import { reasonLine } from './reason-line.js' import { lockedWindow } from './schedules.js' import { hostText, t } from './i18n.js' import { relativeShort } from './text.js' @@ -65,9 +66,12 @@ export function HeldReplyActions({ held, actions, roster }) { // #2409: same rule as the dock and the room card; an absent or unparseable stamp draws nothing. const at = typeof held.createdAt === 'number' ? held.createdAt : Date.parse(held.createdAt) const age = Number.isFinite(at) && at > 0 ? ` · ${relativeShort(at)}` : null + // P-538: the same sentence and the same bound the dock's card draws. + const reason = reasonLine(held.reason) const plain = h('div', { className: 'bt-held-card', role: 'region', 'aria-label': t('Waiting for your approval') }, h('div', { className: 'bt-held-head' }, t('Waiting for your approval'), age), h('div', { className: 'bt-flybot-approval-origin' }, t('{from} → {to}', { from: held.from ?? t('A Bot'), to: held.toName })), + reason ? h('div', { className: 'bt-flybot-approval-reason', title: reason.title }, reason.text) : null, locked ? readOnlyLine() : h('div', { className: 'bt-flybot-approval-actions' }, h('button', { type: 'button', disabled: busy, onClick: () => decide('reject') }, t('Reject')), h('button', { type: 'button', className: 'bt-flybot-approve', disabled: busy, onClick: () => decide('approve') }, t('Approve'))), diff --git a/patches/ds-bot/files/src/client/reason-line.js b/patches/ds-bot/files/src/client/reason-line.js new file mode 100644 index 000000000..5ac596e2e --- /dev/null +++ b/patches/ds-bot/files/src/client/reason-line.js @@ -0,0 +1,20 @@ +// Where a hold says why it waits (P-538). The host's sentence was reachable only as a `title` on +// the session dock's card — invisible to a keyboard, a touch screen and a screen reader — and the +// waiting bubble in a chat had no copy of it at all. Both drawing points read it here, so the two +// shapes cannot drift apart. +// +// The visible line is bounded to 200 code points with an ellipsis, and the whole sentence stays in +// `title`: a long reason is cut on screen without hiding its end from the person deciding the hold. +// The bound counts code points (`fits`) and falls between them, so an emoji is never split into two +// surrogates. A reason that is empty or only whitespace is no reason: the caller draws nothing, +// because an empty line would announce a hold with nothing to say. +import { fits } from '../shared/text-boundary.js' + +export const REASON_POINTS = 200 + +// `{ text, title }`, or `null` when the host gave no reason to show. +export const reasonLine = (reason) => { + if (typeof reason !== 'string' || reason.trim() === '') return null + const full = reason.toWellFormed() + return { text: fits(full, REASON_POINTS) ? full : [...full].slice(0, REASON_POINTS).join('') + '…', title: full } +} diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index ee1e51044..92f448ca0 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -568,6 +568,9 @@ export function install(rt) { turn: a.request.meta.replyIn.turn, to: toRemote(a.request.to)?.handle ?? null, ...(a.recipientName ? { toOwner: a.recipientName } : {}), + // P-538: the sentence the hold was made with travels with it, so the bubble in the chat can + // show why the reply waits instead of leaving it to the dock's hover title. + ...(a.reason ? { reason: a.reason } : {}), // #968: the view's answer is normalized here, where the record is read: `noRetry` and a terminal // sentence both become `retry: false`, so a record written before #954 gets the same answer as a new // one, and no renderer has to match a reason string. diff --git a/patches/ds-bot/files/test/approval-reason.test.js b/patches/ds-bot/files/test/approval-reason.test.js new file mode 100644 index 000000000..7f2b04d29 --- /dev/null +++ b/patches/ds-bot/files/test/approval-reason.test.js @@ -0,0 +1,171 @@ +// P-538: the host's reason for holding a message was reachable only as a hover `title` on the +// session dock's approval card, and on a held reply's own bubble it was not there at all — `heldOf()` +// copied id/by/status/sessionId/turn/to/error and left `reason` behind, so the waiting card had +// nothing to say about why it waited. A `title` is invisible to a keyboard, a touch screen and a +// screen reader, and the waiting bubble is exactly where a person is looking. +// +// Both drawing points now show the same line under the origin: the host's sentence, one node, and +// the whole sentence again in `title` because the visible text is bounded to 200 code points with an +// ellipsis. A reason that is empty or only whitespace is no reason: neither surface draws an empty +// line, and neither draws a `title` for one. The bound is counted in code points (a character +// outside the BMP costs one) and never splits a surrogate pair, so `'😀'.repeat(201)` loses its last +// emoji whole rather than leaving half of one behind. +// +// React and the host call are replaced by the same tiny stubs the other client tests use. +import assert from 'node:assert/strict' +import { registerHooks } from 'node:module' +import { test } from 'node:test' + +const live = { inst: null, idx: 0 } +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Fragment: 'F', + Component: class {}, + useState(init) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } + const slot = inst.hooks[i] + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] + }, + useEffect(fn, deps) { + const { inst } = live + const i = live.idx++ + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } + inst.effects.push([i, fn, deps]) + }, +} +for (const name of ['useRef', 'useMemo', 'useCallback', 'useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value +globalThis.__reactStub = reactStub +globalThis.__hostCall = () => Promise.resolve([]) +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === 'react') return { url: 'data:text/javascript,const r=globalThis.__reactStub;export default r;export const {createElement,Fragment,Component,useState,useEffect,useRef,useMemo,useCallback,useLayoutEffect,useContext,useReducer,useId,useSyncExternalStore,useTransition,useDeferredValue,memo,forwardRef,createContext,createPortal,cloneElement,isValidElement,startTransition}=r', shortCircuit: true } + if (specifier === './api.js' && context.parentURL?.includes('/src/client/')) return { url: 'data:text/javascript,export const call = (endpoint, payload) => globalThis.__hostCall(endpoint, payload)', shortCircuit: true } + return nextResolve(specifier, context) + }, +}) +globalThis.window ??= globalThis +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } + +function render(Component, inst) { + live.inst = inst + live.idx = 0 + inst.effects = [] + return Component(inst.props ?? {}) +} +const nodes = (node, out = []) => { + if (Array.isArray(node)) { for (const child of node) nodes(child, out); return out } + if (!node || typeof node !== 'object') return out + out.push(node) + nodes(node.children, out) + return out +} +const text = (node) => { + if (node === null || node === undefined) return '' + if (Array.isArray(node)) return node.map(text).join('') + if (typeof node !== 'object') return String(node) + return text(node.children) +} +const hasClass = (node, name) => typeof node.props?.className === 'string' && node.props.className.split(' ').includes(name) +const reasonNodes = tree => nodes(tree).filter(node => hasClass(node, 'bt-flybot-approval-reason')) +const labels = tree => nodes(tree).filter(node => node.type === 'button').map(node => text(node.children)) +const DECISIONS = ['Approve', 'Reject', 'Retry'] +const decisionLabels = tree => labels(tree).filter(label => DECISIONS.includes(label)).sort() +const pressables = tree => nodes(tree).filter(node => typeof node.props?.onClick === 'function') + +const PENDING = 6 +const host = { id: 'b1', name: 'Helper', ownerId: 'm1', sessionId: 's1' } +const writable = { self: { memberId: 'm1' }, bots: [host], byId: { b1: host }, rooms: [], roomsById: {}, readOnly: '' } +const locked = { ...writable, readOnly: 'Another FlyBot window has this Bot team open, so this window is read-only.' } +const REASON = 'The room is locked: one of the members has not opened this chat yet.' +const approval = (extra = {}) => ({ + id: 'a1', status: 'pending', by: 'tree', senderName: 'Helper', targetName: '@chief.uia.home', reason: REASON, + request: { from: 'bot-helper', to: { handle: '@chief.uia.home' }, text: 'Which file?', hop: 0 }, + ...extra, +}) +const dock = (roster, list) => { + const inst = { hooks: {}, effects: [], rendered: false, props: { actions: {}, useRoster: pick => pick(roster) } } + render(FlyBotApprovals, inst) + inst.hooks[PENDING].value = list + return render(FlyBotApprovals, inst) +} +const bubble = (roster, held = { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason: REASON }) => + render(HeldReplyActions, { hooks: {}, effects: [], rendered: false, props: { held, actions: {}, roster } }) + +let FlyBotApprovals +let HeldReplyActions +test.before(async () => { + ;({ FlyBotApprovals } = await import('../src/client/flybot-slots.js')) + ;({ HeldReplyActions } = await import('../src/client/held-reply.js')) +}) + +test('P-538: the dock card shows the hold reason once, with the whole sentence in the title', () => { + const card = dock(writable, [approval()]) + const shown = reasonNodes(card) + assert.equal(shown.length, 1, 'the card draws the reason as its own line') + assert.equal(text(shown[0]), REASON, 'the visible line is the host sentence, verbatim') + assert.equal(shown[0].props.title, REASON, 'the whole sentence stays reachable as a title') + // It is a sentence, not a control: the decision row is unchanged and nothing inside the line is pressable. + assert.deepEqual(decisionLabels(card), ['Approve', 'Reject'], 'the reason does not replace the decisions') + assert.deepEqual(pressables(shown[0]), [], 'the reason line carries no control') +}) + +test('P-538: 200 code points show whole, 201 lose one codepoint to an ellipsis, the title keeps it all', () => { + const full200 = 'a'.repeat(200) + const full201 = 'b'.repeat(201) + const shortCard = dock(writable, [approval({ reason: full200 })]) + assert.equal(text(reasonNodes(shortCard)[0]), full200, 'a sentence of exactly 200 code points is not cut') + assert.equal(reasonNodes(shortCard)[0].props.title, full200) + + const longCard = dock(writable, [approval({ reason: full201 })]) + const long = reasonNodes(longCard)[0] + assert.equal(text(long), `${'b'.repeat(200)}…`, 'the 201st code point gives way to the ellipsis') + assert.equal([...text(long)].length, 201, 'the cut is counted in code points') + assert.equal(long.props.title, full201, 'the title carries the sentence that was cut') + + // A character outside the BMP costs one code point and must not be split in half. + const emoji = '😀'.repeat(201) + const emojiCard = dock(writable, [approval({ reason: emoji })]) + const cut = text(reasonNodes(emojiCard)[0]) + assert.equal(cut, `${'😀'.repeat(200)}…`, 'the cut falls between two emoji, never inside one') + assert.equal([...cut].length, 201) + assert.ok(cut.isWellFormed(), 'no lone surrogate survives the cut') + assert.equal(reasonNodes(emojiCard)[0].props.title, emoji, 'the title keeps all 201 emoji') +}) + +test('P-538: no reason, an empty reason and a blank reason draw no line and no empty title', () => { + for (const [name, reason] of [['absent', undefined], ['null', null], ['empty', ''], ['blank', ' \n\t ']]) { + const card = dock(writable, [approval({ reason })]) + assert.deepEqual(reasonNodes(card), [], `${name}: the card draws no reason line`) + assert.equal(card.props.title, undefined, `${name}: the card carries no empty title`) + const shown = text(card) + assert.match(shown, /Waiting for approval/, `${name}: the headline stays`) + assert.match(shown, /Helper → @chief\.uia\.home/, `${name}: the origin stays`) + assert.match(shown, /Which file\?/, `${name}: the message stays`) + assert.deepEqual(reasonNodes(bubble(writable, { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason })), [], `${name}: the waiting bubble draws no reason line`) + } +}) + +test('P-538: a read-only window keeps the reason and still draws no decision', () => { + const card = dock(locked, [approval()]) + const shown = reasonNodes(card) + assert.equal(shown.length, 1, 'the reason is not a write: a locked window still shows it') + assert.equal(text(shown[0]), REASON) + assert.deepEqual(decisionLabels(card), [], 'the decisions are still gone') + assert.deepEqual(pressables(card), [], 'nothing on the card is pressable') +}) + +test('P-538: the held reply bubble draws the same line, same bound, same title', () => { + const waiting = bubble(writable) + const shown = reasonNodes(waiting) + assert.equal(shown.length, 1, 'the waiting card draws the host reason') + assert.equal(text(shown[0]), REASON, 'the same sentence as the dock card') + assert.equal(shown[0].props.title, REASON, 'the same title') + assert.deepEqual(decisionLabels(waiting), ['Approve', 'Reject'], 'the decisions stay while waiting') + + const long = 'c'.repeat(201) + const longShown = reasonNodes(bubble(writable, { id: 'h1', status: 'pending', by: 'social', from: 'Helper', toName: '@friend.bot', reason: long }))[0] + assert.equal(text(longShown), `${'c'.repeat(200)}…`, 'the bubble uses the same 200-code-point bound') + assert.equal(longShown.props.title, long, 'and keeps the whole sentence in its title') +}) diff --git a/patches/ds-bot/files/test/held-reply-seam.test.js b/patches/ds-bot/files/test/held-reply-seam.test.js index 0eaa879ce..74591935d 100644 --- a/patches/ds-bot/files/test/held-reply-seam.test.js +++ b/patches/ds-bot/files/test/held-reply-seam.test.js @@ -185,6 +185,9 @@ test('#986: a terminal failure reaches the view as retry:false, and its card dra assert.equal(byTurn[4].status, 'failed') assert.equal('retry' in byTurn[4], false, 'a retryable failure is not marked terminal') assert.equal(byTurn[4].error, 'HTTP 500') + // P-538: the sentence the hold was made with is on the view too, so the waiting card can say why + // the reply waits instead of offering a hover title on the dock alone. + assert.equal(byTurn[4].reason, 'held by test', 'the view carries the reason the hold was made') // Draw the very objects the view returned, on the turns they answer. const roster = rosterOf(view) diff --git a/patches/ds-bot/files/test/overlay-approvals.test.js b/patches/ds-bot/files/test/overlay-approvals.test.js index 278b7fc6c..2dc4ac6cf 100644 --- a/patches/ds-bot/files/test/overlay-approvals.test.js +++ b/patches/ds-bot/files/test/overlay-approvals.test.js @@ -90,7 +90,7 @@ test('#260: one card per approval: a dock-drawn one is not repeated, and reads l assert.match(text, /Waiting for approval/) assert.match(text, /Helper → @chief\.uia\.home/) assert.doesNotMatch(text, /Sent by/) - assert.doesNotMatch(text, /"children":\["[^"]*need your approval/, 'the long policy reason is a tooltip, not a headline') + assert.match(text, /"className":"bt-flybot-approval-reason","title":"messages to @chief\.uia\.home's Bots need your approval"/, 'P-538: the policy reason is drawn as its own line, not left to a hover title') assert.equal((text.match(/bt-flybot-approval"/g) ?? []).length, 1) // A chat that shows a2 claims it: nothing more to draw for it. inst.hooks[PENDING].value = [held({ id: 'a2' })]