Skip to content

client: an approval card says how long it has waited - #2410

Merged
Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-approval-age
Nov 4, 2026
Merged

Birfy merged 1 commit into
ThinkFlowLab:mainfrom
HugoXK:dev/3-approval-age

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2409

Owner: Dev-3
Written-by: Dev3
Reviewer: Birfy
Claim-Id: 10ff44aeafff40f0bceea2f719c8dc75
Branch: dev/3-approval-age
Tip: 93ea5b1
Tree: 781e28661c3624b4dd7544b5eec3b02deeef2d63
Base: a091ddc
Diff: 16 files, +1922/−1415 (9 hand-written, 279 changed lines, +261/−18; 7 generated, +1661/−1397)
Tier: T3
why: ["size 279 lines / 9 files > 250 / 12 allowed for T2"] — the test file is 226 of those 279 lines; the source change itself is +8 net lines. The tier is a reporting threshold only: .github/agent/risk-rules.json says the rules report, and enqueue-rules.mjs reads no tier. Nothing was cut to fit T2.
basis: core/src/host/hooks.js=T2, changelog.d/2409-approval-age.fixed.md=T1, core/client.js=T1, core/src/client/details-drawer.js=T1, core/src/client/flybot-slots.js=T1, core/src/client/held-reply.js=T1, core/test/approval-age.test.js=T1, patches/ds-bot/0008-client-bundle.patch=T1, patches/ds-bot/0012-room-membership.patch=T1, patches/ds-bot/files/src/client/flybot-slots.js=T1, +6 more
Docs: docs/subsystems/core-client.md (the room card and the dock card), docs/subsystems/plugin-runtime.md (the slot card and heldOf), docs/subsystems/core-host.md (the heldReplies row's new field).

What changed

An approval card never said how long its request had waited, so a hold from half an hour ago and one that had just arrived looked identical. The record already carried the stamp — Approval.createdAt (core/types/flybot.d.ts), written by the host at hooks.js hold and returned unchanged by approvals.list() — and the room's card already received the plugin view's createdAt, but none of the three drawing points read it.

  1. Dock card (flybot-slots.js FlyBotApprovals): a waiting card's head line carries Waiting for approval · 12 min; a failed Not sent card draws no age.
  2. Held reply's bubble (held-reply.js HeldReplyActions): the waiting head line carries Waiting for your approval · 12 min. The field was missing from the view: hooks.js heldOf() now maps createdAt: a.createdAt beside the id it already sent.
  3. Shared room's overview (details-drawer.js SharedRoomApprovals): each row carries the same age beside its text. The plugin sends createdAt as an ISO string (plugins/social/src/social.js approvalView), so it is parsed when present; a missing or unparseable value draws nothing.

All three surfaces share one rule: typeof x === 'number' ? x : Date.parse(x), then Number.isFinite(at) && at > 0 ? ' · ' + relativeShort(at) : null. A junk stamp ('not-a-date', 'NaN') therefore draws nothing instead of " · Invalid Date", which the first draft of this branch did draw on the dock and bubble; the room card already had the guard. The tests pin that junk reads exactly like a missing field, on both faces.

A read-only window still says how long the hold has waited while it offers no decision: the dock, the bubble and the room row keep their age, draw no Approve/Reject, and contain no pressable at all. That path is pinned per surface rather than inferred from the write buttons.

The wording is relativeShort() in core/src/client/text.js and its four existing strings (just now, {n} min, {n} h, {n} d, then a date): no new i18n entry, no new style, no new helper module. Approval types are unchanged; the chat view's heldReplies row gains one field that the client now reads.

Red first

On a clean worktree at a091ddc07d308de8593cdbc80444f35801762222 carrying only core/test/approval-age.test.js: 6 tests / 2 pass / 4 fail / 0 cancelled, exit 1. The four reds each assert a real surface, not a proxy:

  • dock card — the dock head carries exactly one age (0 !== 1); drawn text was Waiting for approvalHelper → @chief.uia.homeWhich file?RejectApprove
  • held bubble — same shape (0 !== 1); drawn text was Waiting for your approvalHelper → @friend.botRejectApprove
  • room overview — the room row carries exactly one age (0 !== 1)
  • read-only window — the dock head carries exactly one age (0 !== 1), the locked-window path Product asked to be covered

The two green ones on the base are deliberately non-red: the control (no createdAt draws no age) still passes, and the junk case passes because the base draws no age at all. The junk case has teeth where it matters — on the pre-fix tip 771094eb (same tree as the old tip a1059cbc) it fails: 5 pass / 1 fail, actual 'Waiting for approval · Invalid DateHelper → @chief.uia.homeWhich file?RejectApprove'. After the change the file is 6/6.

Ran

  • TZ=UTC ARCH_DOCS_BASE=a091ddc07d308de8593cdbc80444f35801762222 npm run verify — exit 0 (run at local commit 1c83b022, whose tree 781e2866 is the tree the pushed tip 93ea5b16 carries; the fork commit re-creates the same tree): 2850 + 570 + 132 + 140 + 411 + 671 + 37 + 26 = 4837 pass, 0 fail, 0 cancelled; lint 0 errors (core 77 warnings, social 36, timeline 9, tree 2, workflow 16, live-e2e 0); arch-docs: OK (16 changed file(s)).
  • node --test core/test/approval-age.test.js at the tip — 6 tests, 6 pass.
  • node scripts/ds-bot-patches.mjs generate twice — second and third run no-ops (git status --porcelain empty each time, sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855).
  • node scripts/ds-bot-patches.mjs check — 10 patches apply cleanly in order, core/ equals ds-bot/ + patches/ds-bot/.
  • node scripts/ds-bot-patches.mjs canonical — patches/ds-bot/ is what generate writes (generate is a no-op).
  • node core/scripts/build-client.mjs --check — exit 0.
  • node .github/scripts/changelog.mjs check — 31 fragments well formed.
  • node .github/scripts/check-empty-catch.mjs --check — exit 0.
  • node .github/scripts/check-mojibake.mjs --check on the clean tip — 0 occurrences of U+FFFD in 1467 text file(s) (22 skipped as binary).
  • risk-gate.mjs judge() over the base's rules with each file's patch — risk-tier: T3; basis: core/src/host/hooks.js=T2, …; why: ["size 279 lines / 9 files > 250 / 12 allowed for T2"].

About patches/ds-bot/0008-client-bundle.patch: it is regenerated, never hand-edited. The bundle is one file, so git diff against ds-bot/client.js re-aligns its hunks and the patch file itself changes 1390/1376 lines while the client.js it produces differs by only 17/9. That 17/9 diff is exactly the three surfaces: the guard and head line in FlyBotApprovals, the guard and head line in HeldReplyActions, and the parsed row in SharedRoomApprovals. patches check, canonical and the no-op second generate prove the result; no reviewer needs to read the 2766 changed patch lines.

Not run

  • smoke test: the change is client drawing plus one field in the host's read-only chat view (heldOf), with no core/index.js or turn/delivery path change, and this bench has no ../deepseek-harness checkout.
  • no live instance was started or touched.

Per-file

file delta
core/src/client/flybot-slots.js +5/−1
core/src/client/held-reply.js +5/−1
core/src/client/details-drawer.js +15/−9
core/src/host/hooks.js +2/−0
core/test/approval-age.test.js +226/−0 (new)
changelog.d/2409-approval-age.fixed.md +1/−0 (new)
docs/subsystems/core-client.md +3/−3
docs/subsystems/core-host.md +1/−1
docs/subsystems/plugin-runtime.md +3/−3
core/client.js +17/−9 (generated)
patches/ds-bot/0008-client-bundle.patch +1390/−1376 (generated)
patches/ds-bot/0012-room-membership.patch +16/−10 (generated)
patches/ds-bot/files/src/client/flybot-slots.js +5/−1 (generated)
patches/ds-bot/files/src/client/held-reply.js +5/−1 (generated)
patches/ds-bot/files/src/host/hooks.js +2/−0 (generated)
patches/ds-bot/files/test/approval-age.test.js +226/−0 (generated)

Hand-written: +261/−18 (9 files, 279 changed lines). Generated: +1661/−1397 (7 files). Total +1922/−1415.

Checkpoint

{
  "pr": 2410, "claim": "Dev-3", "issue": 2409,
  "summary": "An approval card never said how long it had waited, though the stamp was already on the record. The dock card and the held reply's bubble now carry relativeShort() beside the head line, and each row of a shared room's overview carries the same age beside its text. One rule serves all three surfaces: milliseconds, an ISO string, or a stamp that cannot be parsed draws nothing, so junk never reaches the head as \"Invalid Date\". A read-only window keeps the age while it keeps offering no decision, and the tests pin both.",
  "files": ["core/src/client/flybot-slots.js", "core/src/client/held-reply.js", "core/src/client/details-drawer.js", "core/src/host/hooks.js", "core/test/approval-age.test.js", "changelog.d/2409-approval-age.fixed.md", "docs/subsystems/core-client.md", "docs/subsystems/core-host.md", "docs/subsystems/plugin-runtime.md", "core/client.js", "patches/ds-bot/0008-client-bundle.patch", "patches/ds-bot/0012-room-membership.patch"],
  "checks": { "ran": "TZ=UTC ARCH_DOCS_BASE=a091ddc07d308de8593cdbc80444f35801762222 npm run verify; node --test core/test/approval-age.test.js", "result": "green", "at": "93ea5b16f44d3621826190b5ea50e6de439a8acf" },
  "next": "ready — the Verdict is taken at this tip"
}

@github-actions github-actions Bot added the risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff label Nov 4, 2026
A hold from half an hour ago and one that just arrived drew the same card: the
stamp was on the record, but none of the three drawing points read it. The dock
card and the held reply's bubble now carry relativeShort() beside the head line,
and the shared room's overview row carries the same age beside the text; a
plugin view without createdAt draws nothing extra.

All three surfaces share one rule: a number of milliseconds, an ISO string, or
a stamp that cannot be parsed draws nothing, so junk never reaches the head as
"Invalid Date". A read-only window keeps the age while it keeps offering no
decision, and the tests pin both.
@HugoXK
HugoXK force-pushed the dev/3-approval-age branch from a1059cb to 93ea5b1 Compare November 4, 2026 16:44
@HugoXK
HugoXK marked this pull request as ready for review November 4, 2026 16:44
@Birfy

Birfy commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at 93ea5b1
Written-by: Reviewer
Attestation: 93ea5b1 role=Reviewer

Ran at this tip:

$ TZ=UTC node --test core/test/approval-age.test.js
# tests 6  # pass 6  # fail 0

Red first (one source file reverted to the base blob a091ddc0, test kept):

$ cp core/src/client/flybot-slots.js /tmp/keep.js && (fetch base blob a091ddc07d308de8593cdbc80444f35801762222:core/src/client/flybot-slots.js) > core/src/client/flybot-slots.js && TZ=UTC node --test core/test/approval-age.test.js
# tests 6  # pass 4  # fail 2
$ cp /tmp/keep.js core/src/client/flybot-slots.js

The two reds are the dock card and the read-only window (the dock head carries exactly one age, 0 !== 1), so the tip's pass is not incidental.

Tip evidence, read at 2026-11-04T17:05Z:

  • head 93ea5b16f44d3621826190b5ea50e6de439a8acf, base main, mergeable=true / clean, fork HugoXK/FlyBot.
  • all checks terminal; tests-ok, plugins (tree|social|timeline|workflow), package, merge-gate, reviewed tip, risk tier all success. The only red is enqueue-guard, and its own summary says why: no Verifier report names the tip 93ea5b16f44d — this report is that missing input, not a code defect. One agent-audience run is cancelled (superseded), the rest success.
  • diff is the 3 drawing points (flybot-slots.js, held-reply.js, details-drawer.js), the one view field (hooks.js heldOf() → createdAt), a new test, changelog fragment, three subsystem docs, and the generated client.js / patches/ mirrors the repo requires.

Review points, on the code rather than the description:

  • One rule in three places, and it is the right one: a number of ms, an ISO string, or nothing — Number.isFinite(at) && at > 0 keeps a junk stamp ('not-a-date', 'NaN') from reaching the head line as Invalid Date. The base drew no age at all, so the junk case is pinned as "same as missing", which is what the test asserts.
  • Dock: the age is drawn only on waiting, so a failed Not sent card gains nothing — the assertion on the failed card would catch a widening.
  • Room row: details-drawer.js:145-152 parses when present and draws nothing when absent; the row keeps key: item.id and still renders no approve/reject when locked.
  • Read-only window: the locked path keeps the age and offers no pressable — the test's "contain no pressable at all" check pins it per surface instead of inferring it from the write buttons.
  • hooks.js adds createdAt: a.createdAt to a read-only view row and touches no turn/delivery path; the Approval types are unchanged.
  • Generated files are consistent with core/: patches/ds-bot/files/... carries the same +5/−1, +5/−1, +2/−0 as the sources, and core/client.js matches the three surfaces.

Not run here: the full npm run verify (the author ran it at the same tree; this bench runs one target file by design) and the smoke test (no index.js / src/host/ turn path is touched, and no ../deepseek-harness checkout on this box). I re-ran the target file only.

@github-actions github-actions Bot added the pass label Nov 4, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 4, 2026
Merged via the queue into ThinkFlowLab:main with commit b476fa8 Nov 4, 2026
16 of 17 checks passed
@Birfy
Birfy deleted the dev/3-approval-age branch November 4, 2026 17:11
HugoXK added a commit to HugoXK/FlyBot that referenced this pull request Nov 4, 2026
…wLab#2424)

Fixes ThinkFlowLab#2422
Owner: Dev-2
Written-by: Dev-2
Reviewer: Birfy
Claim-Id: 13aab537f38444fcb39498eeaba6528a
Branch: dev/2-approval-reason-visible
Tip: a9cbe93
Tree: 90a717a
Base: 16d1645
Diff: 20 files, +543/−105 (hand-written 11 = +213/−6; generated 9 =
+330/−99)
Tier: T2 (why: [])

## What changed

The reason a hold was made with was only ever a hover title, and only on
one of the two cards that draw the same hold. `FlyBotApprovals`
(`core/src/client/flybot-slots.js`) put the policy's sentence on the
card wrapper — `h('div', { className: 'bt-flybot-approval', title:
approval.reason ?? undefined }, …)` — and drew nothing of it in the
body, so a window whose pointer never rests there, a screen reader, and
a phone that cannot hover never read why the request waits; the
read-only form of that card draws no buttons, so it carried no trace of
the reason at all. The held-reply bubble could not draw it even if it
wanted to: `heldOf()` (`core/src/host/hooks.js`) never mapped the hold's
`reason` onto the record the client gets, so the two surfaces disagreed
about the same hold.

Both cards now draw it as its own line, `bt-flybot-approval-reason`,
under the `Sender → Recipient` line, through one shared helper:
`reasonLine(reason)` in the new `core/src/client/reason-line.js` answers
`{ text, title }` or `null`. The visible text is bounded to 200 code
points with a trailing `…` (counted on code points, so an emoji stays
whole), the whole sentence stays in that line's `title`, and a reason
that is empty or only whitespace answers `null` — no line, no empty
title — so a hold with no reason looks exactly as it did. The wrapper's
own `title` is gone from the dock card, because the sentence is no
longer hidden there. `heldOf()` gains one property, `...(a.reason ? {
reason: a.reason } : {})`, its third optional field beside `toOwner` and
`error`.

The new file lives in `core/src/client/` rather than `core/src/shared/`:
`risk-rules.json` scores `core/src/shared/**` T3, which would lift the
whole slice out of T2 for no product reason.

Rebased onto `16d16456` (after `ThinkFlowLab#2410` and `ThinkFlowLab#2421`); `ThinkFlowLab#2410` touches the
same three files, so its head's changes are kept and this slice stacks
on them — the card's head line still carries `ThinkFlowLab#2409`'s age (`Waiting for
approval · 12 min`) and the reason line sits under the origin line.
`core/client.js` and the `0008` patch were rebuilt with `npm --prefix
core run build` + `node scripts/ds-bot-patches.mjs generate`, never
hand-edited.

## Red first

On a clean `16d16456` worktree with only the three test files from this
branch, per file (log `tmp/p538-red-16d16456.log`):

- `core/test/approval-reason.test.js` — 5 tests / 1 pass / **4 fail** /
0 cancelled: the dock card draws no `bt-flybot-approval-reason`
(`expected 1, actual 0`), the 200-code-point bound draws `''`, the
read-only card keeps no reason (`expected 1, actual 0`), and the
held-reply bubble draws no line (`expected 1, actual 0`). The passing
one is the control: a hold with no reason, an empty one and a blank one
draw nothing on the base too.
- `core/test/held-reply-seam.test.js` — 3 tests / 2 pass / **1 fail**:
`expected: 'held by test'`, the view's record carries no `reason`.
- `core/test/overlay-approvals.test.js` — 7 tests / 6 pass / **1 fail**:
the `ThinkFlowLab#260` card, drawn from the dock's own record, carries the reason
only as `"title":"messages to @chief.uia.home's Bots need your
approval"` on the wrapper and no reason node.

With the fix the same three files are 5/5, 3/3 and 7/7 on the same base.

## Ran

```
TZ=UTC ARCH_DOCS_BASE=16d16456 npm run verify
node .github/scripts/changelog.mjs check
node .github/scripts/check-empty-catch.mjs --check
node .github/scripts/check-mojibake.mjs --check
node scripts/ds-bot-patches.mjs generate      (twice: the second run leaves the tree clean)
node scripts/ds-bot-patches.mjs canonical
```

4848 = 2856+574+132+141+411+671+37+26, 0 fail / 0 cancelled; lint 0
errors (core 77 warnings, plugins 36/9/2/16); `arch-docs: OK (20 changed
file(s))`; 10 patches apply cleanly, `core/` equals ds-bot + patches,
and `generate` is a no-op on the second run; `build-client --check` exit
0; `canonical` ok.

## Not run

The smoke test (this touches neither `index.js` nor a host route), CI's
`package` job (pack check, typecheck, actionlint), and CI itself.

Docs: `docs/subsystems/core-client.md` (both cards' bullets),
`docs/subsystems/core-host.md` (`heldReplies` now carries the hold's
`reason`), `docs/subsystems/plugin-runtime.md` (`heldOf()` and both card
shapes point at `reasonLine`).

<!-- flybot:checkpoint -->
{
  "pr": 2424,
  "claim": "Dev-2",
  "issue": 2422,
"summary": "A hold's reason lived only in the dock card's wrapper title
and never reached the held-reply bubble, whose record dropped it in
heldOf(); both cards now draw one bt-flybot-approval-reason line through
client/reason-line.js reasonLine() — 200 code points plus an ellipsis
visible, the whole sentence in the title, nothing for an empty or blank
reason. heldOf() carries the hold's reason so the bubble draws the same
line, the wrapper title is gone, and ThinkFlowLab#2410's age line stays. Bundle and
0008 regenerated.",
  "files": [
    "changelog.d/2422-approval-reason-visible.fixed.md",
    "core/src/client/reason-line.js",
    "core/src/client/flybot-slots.js",
    "core/src/client/held-reply.js",
    "core/src/host/hooks.js",
    "core/test/approval-reason.test.js",
    "core/test/held-reply-seam.test.js",
    "core/test/overlay-approvals.test.js",
    "docs/subsystems/core-client.md",
    "docs/subsystems/core-host.md",
    "docs/subsystems/plugin-runtime.md",
    "core/client.js"
  ],
  "checks": {
    "ran": "TZ=UTC ARCH_DOCS_BASE=16d16456 npm run verify",
    "result": "green",
    "at": "a9cbe93feaaea69266b743770015516a676bc95f"
  },
  "next": "draft on purpose, waiting for Test and the first review"
}
<!-- /flybot:checkpoint -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T2 Risk tier T2 (host/plugin code or an unnamed path), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client: an approval card never says how long it has been waiting

2 participants