Skip to content

fix(inbox): keep and announce an unsaved triage correction when its row leaves the list - #2698

Merged
Chris0Jeky merged 3 commits into
mainfrom
issue-1999/draft-fate
Sep 5, 2026
Merged

fix(inbox): keep and announce an unsaved triage correction when its row leaves the list#2698
Chris0Jeky merged 3 commits into
mainfrom
issue-1999/draft-fate

Conversation

@Chris0Jeky

@Chris0Jeky Chris0Jeky commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Summary

Switching the Inbox board filter while a Paper row editor was open destroyed the unsaved
correction without a word. The draft survived the list replacement itself, and then the
items watcher in PaperTriageTable.vue found the edited row missing from the new list,
cleared editItemId, and the correction went with the unmounted editor. The table spec
pinned that as intended. It is the same silent loss the sibling-edit gate and the
decision block already refuse one row over, so the fate is now decided explicitly:
preserve, and announce.

The table reads the editor's unsaved correction at the moments the close is not the
reader's answer to it, and holds it, keyed by capture id, in component state. A
role="status" region above the list carries one line per capture, naming it by the
excerpt the row showed. A held correction comes back only through the reader's own
Edit capture on the row — never by an editor opening itself — and the region says it is
there to come back to. It is dropped only when the capture settles in a state the server
itself would refuse the edit in, with a receipt naming that status.

Nothing is persisted and nothing is sent. The held corrections live in the component for
as long as the table does, and a restored editor's Save is the same explicit write it
always was.

The contract chosen is a getter, readDraft(), exposed by the editor and read by the
table, rather than an emit on unmount. close is emitted from four places with two
different meanings — Cancel and a landed Save from a loaded editor, against Cancel on the
load-error panel and Close on the refused panel, where the reader never saw a draft at
all — so an unmount emit would make the table infer intent from a flag rather than ask.
The getter answers { state: 'ready', draft } or { state: 'unavailable' }, and that
distinction is load-bearing: an editor that never showed a textarea is not evidence that
there is nothing unsaved.

Refs #1999 (item 3; item 2, the Triaged-row edit gate, stays behind the OUTSTANDING_TASKS.md
D-13 ruling and is not touched here).
Refs #1995, #2426, #2668.

Changes

feat(inbox): let the row editor hand out and take back an unsaved draft

  • PaperTriageRowEdit.vue exposes readDraft() and accepts a restoredDraft prop that it
    applies once, inside load, on the editable path only and after the forced re-read, then
    emits restored so the announcement is made after the restore is true.
  • isDirty is extracted as the one definition read by saveBlock's unchanged, so
    "nothing to save" and "nothing worth keeping" cannot disagree. No behaviour change to
    saveBlock.
  • The types are exported from a plain <script> block alongside <script setup>, which
    cannot export, so the table can name what it is holding.

fix(inbox): keep and announce an unsaved triage correction when its row leaves the list

  • PaperTriageTable.vue holds corrections in a component-local map, renders the
    role="status" region above the list, and takes the correction on both involuntary
    closes: the row leaving the list, and the read-only history switch, which closes the
    editor through its own watcher.
  • Copy in inbox.triage.draft for en, es and it.

fix(inbox): make the held-correction mechanism answer honestly in every path
(review round 2)

  • HIGH readDraft conflated "nothing unsaved" with "I cannot tell", returning null
    for both. Two paths then destroyed a held correction silently: keepOpenDraft released
    it when a restore's detail read was still pending or had failed and the list changed
    again, and closeEdit released it on a close from the load-error or refused panel,
    where the reader had never seen the correction. The report is now three-way and the
    table releases only on a ready report — with an empty draft, or from a ready editor's
    close. An unavailable report changes nothing held.
  • Design the automatic restore is gone. Restoring is the reader's explicit Edit, the
    action this surface requires everywhere else. That removes an editor opening by itself
    with text nobody asked for, a list arriving and cancelling a board pick in progress on
    another row, and a silent non-restore when the returning row was action-disabled for
    some other reason. In its place, a standing line per capture derived from items,
    keptDrafts and the open editor rather than from a previous-versus-current diff — always
    true, restated for every capture that qualifies, and unable to miss a second one.
  • MEDIUM one line per capture, merging rather than replacing: a restored line cannot
    wipe another capture's discarded receipt. Standing lines vanish when they stop being
    true; receipts stand until that capture's next receipt, until the edit they describe ends
    from a loaded editor, or until dismissed.
  • MEDIUM the discard sweep skips a capture whose editor is open, and keys on the
    server's rule, not this list's action gate. Only ProposalCreated, Converted and
    Ignored drop a correction — the settled states CaptureService.IsSuggestionEditableStatus
    would refuse. Triaged keeps it and says this list does not edit that status (the D-13
    gate is a product policy a ruling can move, and dropping is irreversible); Triaging
    keeps it as a transient; an unrecognised status keeps it.
  • MEDIUM the live region is mounted and silent from the start (fix(review): name an unavailable pinned proposal in Legacy and hold the loading announcement #2593/fix(review): localize the degraded-queue warning, announce recovery, keep it visible #2630), so a
    notice is a mutation of a region that was already there. It is never display: none,
    which would take it back out of the accessibility tree.
  • MEDIUM per-field dirty tracking. The held draft carries only the fields the reader
    changed, and the editor lays only those over the values it re-reads, so a text correction
    can no longer revert a due date or a label that moved on the server while it was held.
    The template comment claiming a restore is never a stale write is replaced by the truth:
    Save is last-write-wins, exactly as from an editor opened fresh.
  • LOW dismiss returns focus to the region that survives it; the kept sentence says
    the correction is held while the reader stays on this Inbox list, which is what the map's
    lifetime actually promises.

Test plan

Verified, from frontend/taskdeck-web at the PR head:

  • npx vitest --run --maxWorkers=2 src/tests/views/paper/inbox/PaperTriageTable.spec.ts src/tests/views/paper/inbox/PaperTriageRowEdit.spec.ts src/tests/views/paper/inbox/PaperTriageTable.degraded.spec.ts src/tests/views/paper/PaperInboxView.spec.ts src/tests/i18n/catalogs.spec.ts src/tests/guards/primaryActionGuards.spec.ts src/tests/guards/nativeBrowserDialogs.spec.ts src/tests/composables/useInboxOrchestrator.spec.ts — 8 files, 300 tests passed.
    PaperTriageTable.spec.ts alone is 86, PaperTriageRowEdit.spec.ts 25.
  • npm run typecheck — clean.
  • npm run build — clean.
  • npx eslint on the seven changed files — clean.
  • git diff --check — clean.

Red-first, round 2. Against the round-1 components the rewritten and added cases failed
18 of 26 (19 table cases, 7 editor cases; the eight that already passed cover behaviour
round 1 had right — the discard on a terminal status, a correction surviving repeated list
changes, the archived-history switch, and an untouched editor announcing nothing):

PaperTriageTable.spec.ts

  • mounts the notices region before it has anything to say — the region only existed once
    there was a notice.
  • keeps an unsaved correction and names the capture when its row leaves the list — the
    kept sentence did not yet scope its promise to this list.
  • offers a held correction back on the returning row, and restores it on Edit — the editor
    opened by itself and there was no held line.
  • holds the correction while another editor is open, and says that is why — the blocked
    line was event-pushed on return, not derived.
  • keeps a correction whose editor could not load before the list changed again — HIGH:
    the correction was destroyed.
  • keeps a correction when the reader closes an editor that failed to load — HIGH: the
    error panel's Cancel released it.
  • keeps a correction when the reader closes an editor the server refused — HIGH: the
    refused panel's Close released it.
  • keeps a correction whose capture comes back Triaged / Triaging, and says why (2 cases) —
    both were discarded on the Paper action gate.
  • never announces a discard for a capture the reader has open.
  • carries one line per capture, so one correction never speaks over another — the run's
    notices replaced the list wholesale.
  • lets the reader dismiss the receipts and leaves focus on the region — the region was
    removed and focus went to the document.
  • forgets the correction when the user cancels a loaded edit — the assertion moved to the
    region's line set.

PaperTriageRowEdit.spec.ts

  • reports only the fields the user changed, and an empty draft when none are — the draft
    was a whole snapshot.
  • reports that it cannot answer while the capture text has not loaded / when the load
    failed / when the server refuses the edit (3 cases) — all three answered null, the same
    answer as "nothing unsaved".
  • lays only the changed fields over the values it re-reads, so a Save cannot revert the
    rest — the restore overwrote the freshly loaded metadata with the old load's values.

Coverage claim, stated exactly: 3 of the 19 table cases assert explicitly that
captureStore.updateSuggestion was not called, and one of those also asserts that no
accept / keep / reject left the table. No case in the block clicks Save, so none
reaches a write — but that is an absence, not an assertion, and the earlier claim that
every case asserted it was wrong.

NOT verified:

  • No browser or screen-reader run. The region is mounted and silent from the start, which
    is the condition a live region needs, but the announcement itself is asserted
    structurally (role, aria-live, text, data-notice kind), not observed in assistive
    tech. The focus move after dismiss is asserted against document.activeElement in an
    attached-DOM test, not observed.
  • The Spanish and Italian wording is not native-reviewed (OUTSTANDING_TASKS.md Seed an i18n translation layer (vue-i18n) with Italian and Spanish locales #1770). The
    catalog guard proves structural parity and placeholder membership only.
  • No Playwright run; no full frontend suite (it OOMs on this box).
  • The optional picker E2E leg from the 2026-09-05 audit's LOW is NOT included. It is not a
    test-only addition to tests/e2e/paper-responsive.spec.ts: a board-less capture needs a
    new helper in tests/e2e/support/captureFlow.ts (the existing createCaptureItem
    requires a boardId), and the assertion would then be red until
    .paper-triage__board-pick gets a narrow-width rule, which is a second behaviour change.
    It stays listed on [Frontend] Paper triage-edit residuals from PR #1995's Codex round: mobile action stacking, Triaged-row edit, filter-change draft, stalled-detail Cancel #1999.

Boundaries and risks

Owned and touched: PaperTriageTable.vue, PaperTriageRowEdit.vue, the three
src/locales/*/inbox.ts catalogs, and the two triage specs. PaperInboxView.vue,
useInboxOrchestrator.ts, captureStore.ts, PaperCaptureComposer.vue and the review
files are untouched; the relay was not needed, because the board-filter change reaches this
table as a plain items replacement. The board-pick copy, the retry-board-load control and
the #2696 catalog keys are untouched.

Risks:

  • The held map is unbounded. It only grows when the reader opens an editor, changes
    something, and has that row leave the list, and it dies with the table, so it is bounded
    by deliberate action rather than by a cap. A cap would mean silently dropping a
    correction, which is the behaviour this PR exists to remove.
  • A correction for a capture deleted elsewhere, or one stuck in Triaging, stays held until
    the table unmounts. That is the stated rule: only a settled server refusal drops one, and
    dropping on a guess would be the silent loss again.
  • Save from a restored editor is last-write-wins on the fields the reader changed, as it is
    from any editor. Holding the correction adds no staleness, because untouched fields carry
    the values the restore re-read.
  • The template ref for the open editor sits inside v-for, so Vue maintains it as a list;
    only one editor is ever mounted, and the editor is read while it is still mounted — from
    a pre-flush watcher, or from inside the close handler it just fired.

Worktree: .worktrees/codex-1999-draft-fate, ready for plain git worktree remove after
this PR. Its only ignored content is frontend/taskdeck-web/dist/ and node_modules/,
both regenerable build output; nothing was copied out.

The Paper triage row editor now exposes `readDraft()`, which answers with the
unsaved correction only while the capture text has loaded and something
actually differs from it, and accepts a `restoredDraft` prop that it applies
once over the freshly re-read server values before announcing `restored`.

A getter rather than an emit on unmount: `close` fires on Cancel and on a
completed Save as well as on an involuntary close, so an unmount emit would
force the table to infer which kind of close it was. The table already knows
the one moment the close is involuntary and can read the draft there.

Refs #1999
…ow leaves the list

Switching the Inbox board filter while a Paper row editor was open let the
draft survive the replacement and then, once the new list landed without the
edited row, the items watcher cleared editItemId and the correction went with
the unmounted editor. Nothing said so. That was the silent loss the sibling
edit gate and the decision block already refuse one row over.

The table now reads the editor's unsaved draft at the one moment the close is
involuntary and holds it, keyed by capture id, in component state. A
role="status" receipt above the list names the capture by the excerpt the row
showed and says the correction is kept and comes back with it. When the
capture returns the editor reopens with the draft laid over freshly re-read
server text, unless another editor is open, in which case the correction stays
held and the receipt says so. It is dropped only when the capture comes back
in a state this surface cannot edit, again with a receipt that names the
status. Cancel and Save are unchanged and both release the held copy.

The read-only history switch closes the editor through its own watcher, so it
takes the correction the same way rather than leaving a second silent door.

Nothing is persisted and nothing reaches the server: a restored editor's Save
is the same explicit write it always was. Copy in inbox.triage.draft for en,
es and it.

Refs #1999
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review record (alpha product-trust lane, review-and-ship round 1 at head 98ea17fb2).

Reviewer: one fresh-context independent reviewer subagent (read-only; Codex credits exhausted, SC-9), given the worktree at the head and the merge-base diff. Verdict: FIX-FIRST on two HIGHs; the premise holds (the table stays mounted across a board-filter change and a same-scope refresh, so the component-local kept-draft map survives; no new path calls the server; the getter covers every field Save submits; catalog parity clean).

Findings, triaged once; the one fix round takes the HIGHs and the MEDIUMs that are holes in the new mechanism itself, since shipping a "never silently" promise with them would be worse than the silent destruction it replaces:

  • HIGH, fix-now: the table releases a held copy whenever the editor's getter returns null, which it also does while loading, errored or blocked, so a kept draft dies silently after a failed or pending restore followed by another filter change.
  • HIGH, fix-now: a close emitted from the editor's error or blocked panel deletes the kept entry although the user never saw the correction.
  • MEDIUM, fix-now by design change: the automatic restore is dropped (restore only through the user's explicit Edit), which removes the auto-restore cancelling an in-progress board pick on another row, the silent no-restore when the row is action-disabled for another reason (and the returning check that never re-fired), and the editor opening by itself seeded with stale text; a held line per present kept-draft row replaces it, recomputed on every items run.
  • MEDIUM, fix-now: notices merge rather than replace, so a restored line cannot wipe a discarded receipt; the discard sweep skips a row with an open editor and keys editability on the server's rule (New, Failed, Triaged) with transient Triaging never dropping a draft, so nothing irreversible is keyed on the Paper list gate (the D-13 relation is stated in the notice); the live region is always mounted; per-field dirty tracking so a restore and its Save cannot revert server-side metadata the user never touched, and the template comment claiming a restore is never a stale write is replaced by the truth.
  • LOW, taken if trivial: dismiss focus, the held sentence's lifetime honesty, the PR body's coverage claim.
  • Informational: the dual script block for the exported draft type compiles and tree-shakes; a plain types module would avoid the precedent.

Round count: 2 after the fix push. Merge gate: CI green at the fix head, the three-minute age, and one read-only verification pass scoped to the fix diff (the fixes change component logic); a new HIGH from the fixes parks the PR.

…ry path

Review round 2 on PR #2698. Every item below is one change to the same
mechanism, so they land together: the editor's answer, the table's use of it,
and what the surface says are not separable into commits that build.

HIGH — readDraft conflated "nothing unsaved" with "I cannot tell". It returned
null both when the draft matched the server text and when the editor was still
loading, had failed to load, or had been refused. Two paths then destroyed a
held correction silently: keepOpenDraft released it when a restore's detail
read was still pending or had failed and the list changed again, and closeEdit
released it on a close emitted by the load-error panel's Cancel or the refused
panel's Close, where the reader had never seen the correction at all. The
editor now answers { state: 'ready', draft } or { state: 'unavailable' }; the
table releases a held copy only on a ready report with an empty draft, or on a
close from a ready editor. An unavailable report changes nothing held.

Design — the automatic restore is gone. A held correction comes back only
through the reader's own Edit capture, the explicit action this surface
requires everywhere else. That removes an editor opening by itself with text
nobody asked for, a list arriving and cancelling a board pick in progress on
another row, and a silent non-restore when the returning row happened to be
action-disabled. What replaces it is a standing line per capture, derived from
items, keptDrafts and the open editor rather than from a previous-versus-
current diff, so it is always true, states itself for every capture that
qualifies, and cannot miss a second one.

MEDIUM — notices are one line per capture and merge instead of replacing, so a
restored line cannot wipe another capture's discarded receipt. Standing lines
disappear when they stop being true; receipts stand until that capture's next
receipt, until the edit they describe ends from a loaded editor, or until
dismissed.

MEDIUM — the discard sweep skips a capture whose editor is open, and keys on
the server's own rule rather than this list's action gate: only
ProposalCreated, Converted and Ignored drop a correction, because those are the
settled states CaptureService would refuse the edit in. Triaged keeps it (the
Paper gate is a product policy the D-13 ruling can move), Triaging keeps it (a
transient), and an unrecognised status keeps it.

MEDIUM — the live region is mounted and silent from the start, so a notice is a
mutation of a region that was already there rather than an insertion.

MEDIUM — per-field dirty tracking. The held draft carries only the fields the
reader changed, and the editor lays only those over the values it re-reads, so
a text correction can no longer revert a due date or a label that moved on the
server while it was held. The template comment claiming a restore is never a
stale write is replaced by the truth: Save is last-write-wins, as it is from an
editor opened fresh.

LOW — dismiss returns focus to the region that survives it; the kept sentence
says the correction is held while the reader stays on this Inbox list, which is
what the Map's lifetime actually promises.

Refs #1999
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 record (alpha product-trust lane; fix head 94859670a, one commit on top of 98ea17fb2 because the editor's answer, the table's use of it, the notices model and the copy are one mechanism whose intermediate splits do not typecheck; main unmoved at c574b2f19).

The mechanism is rewritten to be honest by construction:

  • The editor's readDraft() answers { state: 'ready', draft } or { state: 'unavailable' }; the table releases a held copy only on a ready answer with an empty draft, or on a close that a still-mounted ready editor confirms (Cancel or a landed Save); a close from the error or blocked panel keeps the entry with a receipt. Both HIGHs from round 1 are closed and driven red-first (a return whose load fails followed by another list change; Cancel on the error panel; Close on the blocked panel).
  • No automatic restore: nothing opens an editor but the user's Edit, which lays only the touched fields over the freshly loaded values (per-field dirty tracking in the editor; Save's metadata payload built from touched fields only, so a restore cannot revert metadata the user never touched; the "never a stale write" comment is replaced by the truth). The watcher does the departure keep and the discard sweep only, so a list replacement cannot cancel another row's board pick.
  • Standing lines derived on every render from the present rows, the held drafts and the open editor (one line per held capture, blocked while another editor is open), receipts in a per-capture map that merge rather than replace, a standing line outranking a receipt for the same capture; the dismiss control renders only when a receipt shows and moves focus to the region.
  • The discard sweep drops only on ProposalCreated, Converted or Ignored (settled states the server refuses), skips a row whose editor is open, and keeps Triaged and Triaging with a held line that says this list does not edit them (the D-13 gate); an unrecognised status keeps.
  • The live region is always mounted (role="status" aria-live="polite" aria-atomic="false" tabindex="-1", chrome via a class, never hidden).
  • Copy: the kept sentence says the correction is held while you stay on this Inbox list; en, es and it.

Red-first on the round-1 components: 18 failed, 93 passed of 111 across the two component specs (the HIGH paths, the standing lines, one line per capture, the Triaged and Triaging keeps, the never-announce-over-an-open-editor rule, dismiss focus, forget-on-cancel; the editor's unavailable states and the touched-fields contract); eight of the 26 new or rewritten cases are guards that passed before. Verified at the head: the eight-suite command 8 files, 300 passed; typecheck, build, eslint and diff-check clean. Coverage claim corrected in the PR body: three table cases assert updateSuggestion was not called, one also asserts no accept, keep or reject left the table; no case clicks Save.

Not verified: no browser or screen-reader run (announcement asserted structurally, focus in an attached-DOM test); es/it wording not native-reviewed; the picker E2E leg stays out (not test-only).

Merge gate remaining: CI green at 94859670a, the three-minute age, and the read-only verification pass over the whole mechanism (the fix diff is effectively the PR), running now; a new HIGH parks the PR. Round count: 2.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round-2 verification record (the whole rewritten mechanism at 94859670a; read-only pass, fresh context).

Verdict: SHIP; both round-1 HIGHs closed and spec-driven, no new HIGH or CRITICAL.

What it confirmed: the held copy is released only on a ready empty answer or a ready-confirmed close, and a close from the error or blocked panel re-states the kept receipt (the exact sequences are driven: a return whose load rejects then a second list change; Cancel on the error panel; Close on the blocked panel; Cancel and a landed Save from a ready editor release); no path but the user's Edit writes the open-editor slot, and a list replacement can no longer cancel another row's board pick; the standing lines are a computed over the present rows and the held drafts, one per capture, shadowing that capture's receipt, with no code path deleting another capture's receipt; the discard set is exactly the settled statuses the server refuses minus the transient Triaging, Archived is a disposition kind, unknown statuses keep, the sweep skips an open editor; the restore applies only touched fields and Save gates metadata on a real change; the live region is always mounted and the store's list assignment and loading flag settle in the same synchronous continuation so lines never insert under aria-busy; scope is exactly the seven files and catalog parity holds.

Findings, triaged once at the two-round ceiling:

Round count: 2. Merge gate remaining: CI green at 94859670a and the three-minute age.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

CI at 94859670a was red on Frontend Unit (windows-latest) at the step "Run source launcher regression suite": scripts/ci/dev-up.test.mjs:1777 (Bash: Vite fallback leaves the foreign frontend-port owner alive) timed out at 30 s on a 404 s suite run, the slow-runner class tracked as #2378 (the same case hit PR #2694 today). This PR changes the Paper Inbox triage table, its row editor, the inbox locales and specs only and cannot reach that seam. Occurrence recorded on #2378; the failed job was re-run once (run 33979248778, attempt 2). Merge waits for that attempt's conclusion at the same head.

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.

1 participant