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/2422-approval-reason-visible.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
15 changes: 14 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -4644,16 +4652,18 @@ 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",
{ 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 ? (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,
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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" },
Expand Down
7 changes: 6 additions & 1 deletion core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions core/src/client/held-reply.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'))),
Expand Down
20 changes: 20 additions & 0 deletions core/src/client/reason-line.js
Original file line number Diff line number Diff line change
@@ -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 }
}
3 changes: 3 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading