Skip to content

fix(frontend): #1944 capture triage states why a blocked action is off and what a decision did - #1952

Merged
Chris0Jeky merged 6 commits into
mainfrom
issue-1944/triage-accept-feedback
Aug 22, 2026
Merged

fix(frontend): #1944 capture triage states why a blocked action is off and what a decision did#1952
Chris0Jeky merged 6 commits into
mainfrom
issue-1944/triage-accept-feedback

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

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) and
changed 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 disabled in that state — PaperTriageTable.vue bound
:disabled="… || !pickedBoardId || !pickedBoardIsWritable", and confirmBoardAndAccept() returned
early. What was missing is that .pbtn in src/paper-tokens.css has no :disabled rule at all:
a disabled ember button keeps its full ember fill, its cursor: pointer and its :hover background
change (CSS :hover still 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):

  1. A blocked action states its reason. One boardPickBlock computed (noBoards / noBoard /
    viewOnly) is the single source of truth for the disabled binding, the handler guard, and a
    visible reason line (data-testid="board-pick-reason"), wired to the button with
    aria-describedby. They cannot drift into "off for a reason nobody stated".
  2. Disabled means visibly disabled inside the triage table — neutral fill, cursor: not-allowed,
    hover suppressed. Scoped to this component via :deep() rather than edited into the shared
    paper-tokens.css, which every Paper surface consumes.
  3. Decisions are legible. Each row carries data-row-state and, once decided, a status line
    naming 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 sending immediately, not after the next status poll.
  4. Source tags are not state tags. TYPED sat in the same visual style as NEW /
    READY FOR REVIEW / APPLIED TO BOARD, so it read as a fourth state. Source tags now carry
    data-tag-kind="source", a dashed hairline, and a title naming the kind ("Source: Typed. How
    this capture arrived — not a state."); state tags get the matching "State: …" title.
  5. captureRowState() in components/inbox/inboxUtils.ts maps a capture status to what the user
    sees. An out-of-contract status maps to unknown, never to undecided — claiming an unknown row
    is 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 the
Legacy affordance (InboxDetailPanel.vue, suggestion-edit-btn) both already exist, but Paper has
no detail surface at all to hang an editor on (PaperInboxView.onTriageOpen() is a documented
no-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 title on a
disabled 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) and
src/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 genuinely
exercise the fix.

Verified on this branch

Command Result
npx vitest --run --maxWorkers=2 src/tests/views/paper/inbox/PaperTriageTable.spec.ts 35 passed
npx 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.ts 8 files, 197 passed
npm run typecheck (vue-tsc -b) clean
npm run build built in 2.41s (only the pre-existing INEFFECTIVE_DYNAMIC_IMPORT warning)

NOT verified

  • No browser/E2E run — this worktree has no running stack. The visual half of the fix (the
    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.ts touch .paper-triage__row.
    Read, not run: they select rows by hasText and click Accept on captures that already have a
    board, so the picker path they exercise is unchanged — but that is analysis, not a green run.
  • Full vitest suite not run (OOMs on this box); targeted specs only, per the repo's proving-check table.

Residual risk

  • .pbtn still has no disabled treatment outside the triage table — every other Paper surface
    keeps the enabled-looking disabled button. Fixing it in paper-tokens.css is the real repair but
    is a cross-surface visual change, outside this slice's ownership. Worth its own issue.
  • The title tooltips on tags are a minimum answer to "tags are unexplained"; a proper legend or
    glossary would serve a first-time user better.

Closes #1944

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>
en/it/es keys for the three legibility gaps in #1944: why the board-pick
confirm is off, what a decision did and what happens next, and the
difference between a capture's source tag and its state tag.

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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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".
*

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

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>
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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…',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Chris0Jeky
Chris0Jeky merged commit eab59fd into main Aug 22, 2026
33 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Aug 22, 2026
Chris0Jeky added a commit that referenced this pull request Aug 22, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Frontend][UX] Capture triage: no pre-triage edit, and Accept/Reject give no legible state feedback

1 participant