fix(frontend): #1944 capture triage states why a blocked action is off and what a decision did - #1952
Conversation
Names where a capture stands from the user's point of view, separate from the server status label: undecided / sending / inReview / applied / rejected / failed. An out-of-contract status maps to 'unknown' rather than falling back to 'undecided', so a decided row can never be reported as one still awaiting a decision. Refs #1944 Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
"Accept on board" was already disabled with no board selected, but .pbtn carries no disabled styling in paper-tokens.css: the button kept its ember fill, its pointer cursor and its hover, so it read as a live primary action that silently swallowed the click and issued no request. - one boardPickBlock source of truth drives the disabled binding, the handler guard and a visible reason (no board / view-only / no boards at all), wired to the button via aria-describedby - disabled hairline buttons are visually off inside the triage table - each row carries data-row-state and, once decided, a line naming what happened and where the work went; a row in flight says so immediately rather than waiting for the next status poll - source tags are marked, dashed and titled so TYPED no longer reads as a fourth state beside NEW / READY FOR REVIEW / APPLIED TO BOARD Refs #1944 Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
Refs #1944 Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 523bef8381
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (status === undefined) return 'unknown' | ||
| if (status === 0 || status === 'New') return 'undecided' | ||
| if (status === 1 || status === 'Triaging') return 'sending' | ||
| if (status === 2 || status === 'Triaged') return 'inReview' |
There was a problem hiding this comment.
Distinguish empty triage from an item sent to Review
When triage succeeds but finds no actionable items, the backend deliberately returns Triaged without a linked proposal (CaptureStatusPolicy.MapFromQueueStatus and TranscriptTriageWorker document this terminal empty result). Mapping that status to inReview makes the row claim “Sent to Review — decide there,” directing the user to a Review item that does not exist; give Triaged its own no-action state and message instead.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
| * visible consequence immediately rather than after the next poll. | ||
| */ | ||
| function rowState(item: CaptureItemSummary): CaptureRowState { | ||
| if (props.actionBusyItemId === item.id) return 'sending' |
There was a problem hiding this comment.
Do not label a pending rejection as sent to Review
When the user clicks Reject, PaperInboxView.onTriageReject calls captureStore.ignoreItem, which sets the same actionBusyItemId used by triage while the ignore request and detail reload are pending. This unconditional override therefore shows “Sending to Review…” immediately after a rejection—and can retain that contradictory feedback until a failed request settles; track the pending action kind or optimistically map rejection to a distinct state.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
| * that silently did nothing. The single source of truth now drives the | ||
| * `disabled` binding, the guard inside the handler, AND the visible reason: | ||
| * they cannot drift apart into "off for a reason nobody stated". | ||
| * |
There was a problem hiding this comment.
Distinguish board-load failures from an empty account
When the board request is still loading or fails while the store's board array is empty, this branch classifies the state as noBoards and tells the user to create a board. Both onMounted and onAccept catch the rejected fetchBoards() call, while boardStore already exposes loading and error, so a slow or unavailable API produces a false empty-account instruction instead of an inline loading/error state and retry path.
AGENTS.md reference: frontend/AGENTS.md:L4-L6
Useful? React with 👍 / 👎.
|
Fresh-context adversarial review done (no CRITICAL; two MEDIUMs that are this PR's own thesis failing on itself — both being FIXED this round): (M1) status Triaged (=triage completed with NOTHING to propose, per TranscriptTriageWorker.cs:266) maps to 'inReview' and renders 'Sent to Review — decide there' — a permanent false instruction; gets its own row state + honest copy in en/it/es. (M2) actionBusyItemId is action-agnostic, so REJECTING a capture renders 'Sending to Review…' until the refresh lands; the in-flight intent will be carried so reject narrates as rejecting. Both currently pinned by new specs in the wrong direction — specs updated with the fix. Tracked, not fixed here: (L3) the handler guard has zero real coverage (test-utils skips clicks on disabled buttons — a vm-level test would close it); (L4) aria-describedby on a disabled button is unreachable for AT (aria-disabled would keep it focusable); (L5) Failed rows state the failure twice; (L6) it/es strings name the English 'Accept on board' label verbatim; (L7) statusTone() substring-matching coexists with captureRowState() and they already disagree on Triaged — deriving tone from row state is the durable fix, good candidate to fold into #1953/#1949 work. |
`Triaged` (2) is the server's "triage completed, nothing to propose" verdict: CaptureStatusPolicy maps a completed triage to ProposalCreated only when a proposal was linked, and to Triaged when none was. Mapping it to `inReview` made the row read "Sent to Review - decide there." forever - Accept and Reject are both disabled on a Triaged row and polling has stopped, so the user cannot act on the instruction or walk it back. Give it its own `nothingToPropose` state with copy that says what actually happened, in en/it/es. ProposalCreated (3) still maps to `inReview`. Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
`actionBusyItemId` mirrors captureStore's single busy slot and is action- agnostic: ignoreItem (Reject) sets it exactly the way triageItem (Accept) does. Narrating every busy row as "Sending to Review..." therefore told a row the user had just REJECTED that it was on its way to Review, until the detail refresh landed. Record which action this table started for which row at the click, and consult it only while that row is still the busy one. A reject in flight now narrates a rejection; an accept keeps the existing copy. With no recorded intent - a busy flag another surface set, a row mounted mid-flight - the server status answers instead of an invented claim. Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
|
Codex round triage (reviewed old head 523bef8): P2-1 (Triaged mapped to in-Review) and P2-2 (pending rejection labelled as sending to Review) — both independently found by the fresh-context adversarial review and FIXED at 146b1c2 (Triaged now maps to its own nothingToPropose state with honest copy; reject-in-flight narrates as rejecting via a local intent record; both mutation-checked red/green). P2-3 (empty-vs-failed board fetch classified as noBoards) — confirmed real, out of this PR's fix-round scope: tracked as #1961. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 146b1c255c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }, | ||
| }, | ||
| decision: { | ||
| sending: 'Sending to Review…', |
There was a problem hiding this comment.
Avoid promising Review before triage creates a proposal
While a capture is Triaging, CaptureStatusPolicy allows it to finish as Triaged with no proposal, ProposalCreated, or Failed, so this copy tells users the item is already being sent to Review before that outcome is known. The terminal Triaged mapping is now separate, but this newly added in-progress message still makes the same false promise during every empty or failing triage; use neutral processing language until the status is actually ProposalCreated.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
| nothingToPropose: 'Triage found nothing to propose — nothing was sent to Review.', | ||
| inReview: 'Sent to Review — decide there.', | ||
| applied: 'Applied to the board. Nothing left to do here.', | ||
| rejected: 'Rejected. This capture will not reach Review.', |
There was a problem hiding this comment.
Avoid calling every ignored capture rejected
For an Ignored row this copy assumes the user chose Reject, but the legacy Inbox exposes a distinct Cancel action, and both CaptureService.IgnoreAsync and CancelAsync call the same CancelInternalAsync, producing the identical Ignored status. A capture canceled as “submitted in error” can therefore later render in Paper Inbox as “Rejected,” contradicting the decision the user actually made; use neutral copy for this status or carry the action provenance needed to distinguish cancellation from rejection.
AGENTS.md reference: AGENTS.md:L88-L89
Useful? React with 👍 / 👎.
Codex review on PR #1980 found the golden journey is not literally replayable. This commit fixes the defects that need no source check: - Record the tested revision as an explicit gap. The sidebar string is the subject of #1948, so it identifies nothing; bound the run between PR #1952 and PR #1957 by inference from its own observations and require replays to stamp git rev-parse HEAD. - Add Step 7b, the On-Call board. The scenario promises three boards and cleanup deletes three, but only two were ever created. - Name the board in Step 14b. Three boards are offered and Steps 17-19 all assume C1 landed on Payments. - Tag the three Payments proposals P-T/P-C1/P-C2 and give Step 17 a prescribed action order, so 'the remaining Payments proposal' in Step 19 and the seventh card are unambiguous. The run record does not preserve which duplicate took the reject; that is said plainly. - Snapshot and restore the tomorrow-note. Step 21 overwrites a field that has no [HZN] prefix to protect it, and cleanup left it empty. - Stop claiming teardown removes anything. DELETE /api/boards/{id} is a soft archive, so the snapshot compare passes while every row persists and replays accumulate. Signed-off-by: Chris0Jeky <jeky.tck@gmail.com>
The defect
From the 2026-08-22 verification comment on #1944, reproduced against v0.7.2 at
127.0.0.1:5000:clicking Accept on board in the Paper Inbox triage row while the dropdown still read
"Select a board…" fired zero network requests (
/api/request log empty, verified twice) andchanged nothing on screen. The maintainer's single largest source of confusion — "I keep pressing
Accept and don't understand if it does something".
Root cause (measured, not assumed)
The button was already
disabledin that state —PaperTriageTable.vuebound:disabled="… || !pickedBoardId || !pickedBoardIsWritable", andconfirmBoardAndAccept()returnedearly. What was missing is that
.pbtninsrc/paper-tokens.csshas no:disabledrule at all:a disabled ember button keeps its full ember fill, its
cursor: pointerand its:hoverbackgroundchange (CSS
:hoverstill matches a disabled button). So it rendered as a live primary action,absorbed the click, and said nothing. Same for Accept/Reject once a row was decided.
That also explains why the row felt like a state machine to reverse-engineer: nothing on the row
said what a decision had done or where the work went.
What changed
frontend/taskdeck-web/src/views/paper/inbox/PaperTriageTable.vue(plus a pure helper and copy):boardPickBlockcomputed (noBoards/noBoard/viewOnly) is the single source of truth for thedisabledbinding, the handler guard, and avisible reason line (
data-testid="board-pick-reason"), wired to the button witharia-describedby. They cannot drift into "off for a reason nobody stated".cursor: not-allowed,hover suppressed. Scoped to this component via
:deep()rather than edited into the sharedpaper-tokens.css, which every Paper surface consumes.data-row-stateand, once decided, a status linenaming the next step: Sending to Review… / Sent to Review — decide there. / Applied to the board.
/ Rejected. / Triage failed, so nothing reached Review. A row whose own mutation is in flight
reads as
sendingimmediately, not after the next status poll.TYPEDsat in the same visual style asNEW/READY FOR REVIEW/APPLIED TO BOARD, so it read as a fourth state. Source tags now carrydata-tag-kind="source", a dashed hairline, and atitlenaming the kind ("Source: Typed. Howthis capture arrived — not a state."); state tags get the matching "State: …" title.
captureRowState()incomponents/inbox/inboxUtils.tsmaps a capture status to what the usersees. An out-of-contract status maps to
unknown, never toundecided— claiming an unknown rowis still awaiting a decision would be the same class of lie this issue is about.
All new user-visible strings go through the i18n layer (
inbox.triage.*, en/it/es).Deferred, deliberately
AC 2 (editable capture body before triage) is NOT in this PR — filed as #1951. It is a
Paper-skin porting gap, not a small addition: the API (
PUT /capture/items/{id}/suggestion) and theLegacy affordance (
InboxDetailPanel.vue,suggestion-edit-btn) both already exist, but Paper hasno detail surface at all to hang an editor on (
PaperInboxView.onTriageOpen()is a documentedno-op, and the row only holds
textExcerpt). Building that surface is its own slice.Also not done: a reason string on Accept/Reject disabled by a transient busy state. A
titleon adisabled button is unreliable (Chrome does not fire hover events on disabled controls), and the
decision line already carries the durable reason.
Tests
Per the revised AC (a): asserting "no request is issued" would pin the broken behaviour, so the
specs assert the guard — the button is off, the row says why, and the button points at the
reason. Per (b): the post-decision state is pinned for every status, including the invariant that a
decided row cannot render identically to an undecided one.
Added to
src/tests/views/paper/inbox/PaperTriageTable.spec.ts(21 → 35 tests) andsrc/tests/components/inbox/inboxUtils.spec.ts(21 → 26 tests).Mutation-checked, not just green: with the reason line and the decision line stubbed out
(
v-if="false"), 10 of the new tests fail; restored, all 35 pass. The new assertions genuinelyexercise the fix.
Verified on this branch
npx vitest --run --maxWorkers=2 src/tests/views/paper/inbox/PaperTriageTable.spec.tsnpx vitest --run --maxWorkers=2 src/tests/views/paper/inbox src/tests/views/paper/PaperInboxView.spec.ts src/tests/views/InboxView.spec.ts src/tests/views/InboxView.paperMode.spec.ts src/tests/components/inbox src/tests/i18n/catalogs.spec.tsnpm run typecheck(vue-tsc -b)npm run buildINEFFECTIVE_DYNAMIC_IMPORTwarning)NOT verified
disabled-button treatment, the dashed source tag) is CSS and is therefore unproven by unit tests;
it needs one look in the app.
tests/e2e/{capture-loop,first-run,review-proposals,smoke}.spec.tstouch.paper-triage__row.Read, not run: they select rows by
hasTextand clickAccepton captures that already have aboard, so the picker path they exercise is unchanged — but that is analysis, not a green run.
Residual risk
.pbtnstill has no disabled treatment outside the triage table — every other Paper surfacekeeps the enabled-looking disabled button. Fixing it in
paper-tokens.cssis the real repair butis a cross-surface visual change, outside this slice's ownership. Worth its own issue.
titletooltips on tags are a minimum answer to "tags are unexplained"; a proper legend orglossary would serve a first-time user better.
Closes #1944