Repository navigation
tasks: a blocked card names its approvals but cannot show or decide them — the board never got #1002's fix #1891
Description
A paused board card says it is waiting on approvals. It cannot say what they are, and it cannot decide them.
taskApprovalBlock (frontend/src/lib/task-approvals.ts) already hands the card the full ApprovalSummary[] — payload, amount_usd, group, agent, expires_at_millis. BlockedRow (frontend/src/views/TaskCard.tsx) reads count and at_millis and drops the rest:
Fetch a web page
Waiting for your approval · 4m ago Review
The detail screen is the same shape one line long — AwaitingApprovalRow (frontend/src/views/TaskDetailView.tsx) prints Waiting on your approval — fetch a web page and a link.
This is the complaint #1002 already answered one surface over. The run drawer told an operator their run had parked and gave them nowhere to act, so deciding meant leaving the run, finding the row in a flat queue, and coming back. run-approvals.ts + an inline ApprovalRow fixed it there. The task board is the blocker surface that never got the same treatment, and it is the one an operator actually works from.
What is dropped
Three fields, and each is a distinct failure:
- The payload lead. Two cards blocked on
http_requestread identically, so the card cannot distinguish a GET of a docs page from a DELETE against production.payloadLeadLabelis the function chat uses for exactly this and is already imported one directory over. group. The consequence chip — spend / send / sign / publish — is what tells an operator whether this is worth interrupting themselves for. The card renders no signal at all, so apayment.sendand a file read are the same pixels.expires_at_millis. The card counts up from the park (waiting 4m) and never counts down to the deadline. An approval that default-denies on[policy].approval_ttl_hoursgives the board no warning before it does — the operator's first news of the expiry is the work not having happened. Every other approval surface paints this; the board does not.
Proposed fix
Do not re-implement deciding. ApprovalRow (frontend/src/views/chat/ApprovalRow.tsx) owns it: batch all-or-nothing over pending, per-id resolve so each approved call still mints its own revocable grant (#739), detached POST (#391), the busy / failed / partially-settled states, role redaction (#618), and the needsFullReview rule that pulls Approve when the payload lead is truncated. A hand-rolled row on the card would lose the last two silently.
- A third layout on
ApprovalRow, not a fork. A board column isw-65— roughly 220px of card content — soCompactApprovalRow's horizontal flex does not fit.variant="card"is the same content stacked: action + lead, then consequence chip and deadline, then Approve/Decline as a full-width pair. Plus adetailsHrefprop, since the two hardcoded#/approvalslinks inside it should address#/approvals/<taskId>from here. Defaults leave chat byte-identical. taskApprovalRows()inlib/task-approvals.ts, mirroringrunApprovals(): the live queue joined with the shell's witnesseddecidedmap, so a row settles in place across the gap between the resolve's answer and the feed's next poll rather than blinking out.- Thread the decide bundle the way
WorkflowsViewalready receives it inapp-shell.tsx—decidingApprovals/decidedApprovals/failedApprovals/onDecideApproval— throughLedgersView → BoardMode → TaskItem, and throughTaskDetailRoute → TaskDetailView(which already receivesparked). - The detail screen gets the full card, one
ApprovalRowper approval asRunApprovalsSectiondoes. There is room there for the payload preview and the grant-scope control.
Constraints this must not break
- Resume stays disabled while the card is blocked (console: a paused card never says which approvals block it, and Resume re-parks it #883). Unchanged, and still correct: since approvals: a turn that parks several calls never answers — each approval re-runs the turn independently, and the chat count never clears #469 the last decision continues the turn on its own, so re-dispatching only re-runs the work and parks it again.
- The detail screen's ownership rule survives.
AwaitingApprovalRowdeliberately treats the host-computedapprovalsas the truth for whether the card is waiting and the parked queue only for naming, becauseapproval_ownerhas an attempt-level key the console cannot see. So the decidable rows cover the id-matched subset and any residual keeps its own line — a decide surface must never imply it covers approvals it cannot see. - No new request and no new wire field. Both surfaces read the approvals feed the sidebar badge already polls, exactly as console: a paused card never says which approvals block it, and Resume re-parks it #883 established.
Tests
Extending frontend/test/unit/task-blocked-card.test.ts and task-approvals.test.ts: the card names the payload lead rather than the kind; the chip and the countdown render; Approve fires onDecide once per id; a truncated lead swaps Approve for the details link; a witnessed decision settles the row in place; the detail screen's residual line appears when the queue has not delivered every pending approval.
Activity
fix(console): scope witnessed verdicts to the live queue, and hold Re…
sanil-23 commentedon Aug 27, 2026
Follow-up left open by #1895, recorded here rather than in a review thread.
Resume on a paused card is disabled while the approvals queue still holds anything for it, which covers the sharp window: the resolve detaches (#391), so its answer comes back before the follow-up cycle the verdict released has run, and the queue still carries the decided row across that gap.
It does not cover the window after that. Once the host drops the rows, the continuation can still be running while the card reads
paused, and Resume is live again — pressing it re-dispatches from the top, duplicating work already in flight and parking the same effects again, which is the loop #883 exists to break.The console cannot close this on its own. The board reads
…/tasks, whose card projection carries no approvals — that is why #883 joined the approvals feed for the blocked row in the first place — so there is nothing on the card that distinguishes "paused, waiting for a person" from "paused, continuation in flight". More client-side inference would mean holding a per-card memory of "I decided something here recently" with no event to expire it against, which trades a real hazard for a stale one.What would close it is a host-side signal: the card knowing a continuation is running for it, the way
InflightRunalready tells the detail screen an attempt is live. Then Resume gates on that and the guesswork goes away.Not new with #1895 — an operator who approved on the Approvals page could always come back to an enabled Resume on a still-paused card. But that PR puts Approve on the card itself, so the operator is now standing right next to the button when the window opens, which is what makes it worth its own issue.
feat(console): a stacked ApprovalRow variant for a board card (tinyhu…
feat(console): a blocked board card shows and decides its approvals (t…
feat(console): the task detail decides its approvals, and says what i…
fix(console): scope witnessed verdicts to the live queue, and hold Re…
fix(console): bound the witnessed fold to open batches, and let the h…
fix(console): bound the witnessed fold to open batches, and let the h…
fix(console): a board card is not a batch — group its approvals per t…
fix(runtime): resolve approval ownership in the queue projection (tin…
fix(runtime): resolve ownership only for parks whose run id is an att…
fix(runtime): a failed owner lookup must not unlink a parked approval (…
Merge pull request #1895 from sanil-23/feat/1891-task-card-approvals
test(board): update bounce-chip test to the tinyhumansai#1891 TaskIte…
test(console): pin that the queue alone still blocks Retry