Save a card as you leave each field, and Undo instead of confirmations #242

Merged
Grandiras merged 4 commits from ui-v2-live into main 2026-10-03 02:08:36 +00:00
Owner

Two pieces of the UI language v2: saving as you leave a field, and Undo in place of "are you sure?".

Save on leave. An existing card's dialog saves each part when it is finished: title and description on change (blur), tags and assignees on the pick, the date on the pick or Clear. Save and Cancel are gone; the footer is Delete, a quiet "Saved" status and Done. A new card keeps Cancel + Create. Each save writes only its part (CardModal.CardFields), so somebody else's card still sends only the date and assignees, and saves queue so quick edits land in order. Escape blurs the focused field first (modal-escape.js), so every way out saves, and switching cards in the inspector loses nothing.

Undo. Deleting a card, a comment or a board is one click, with a toast carrying Undo for six seconds (the toast's only clickable part; the word takes a light board accent). Cards and comments are hidden at once and deleted when the toast has gone (UndoService), because they cascade to tags, people, threads and files and could not be rebuilt; a delete that fails in the window brings the thing back with a toast. Boards are soft-deleted already, so they go at once and Undo restores them (settings page and the super-admin record).

Docs: ui.md (card dialog, the inspector, somebody else's card, a new Undo section), boards-and-permissions.md (a save writes only its part), frontend.md (uploads gate Create/Done; the v2 region's status), orientation.md (UndoService), testing.md. One changelog line, both locales.

Verified: tools/verify.sh (584 unit tests); driven in a dev instance (title saved by clicking another card, "Saved" after a date pick, Escape saves, delete hides at once, Undo restores, the row is gone after the window; light and dark); full e2e on an isolated stack: 158/158 passed (specs updated in this PR: board, permissions, calendar-all, share, moderation, helpers/boards).

🤖 Generated with Claude Code

Two pieces of the UI language v2: saving as you leave a field, and Undo in place of "are you sure?". **Save on leave.** An existing card's dialog saves each part when it is finished: title and description on change (blur), tags and assignees on the pick, the date on the pick or Clear. Save and Cancel are gone; the footer is Delete, a quiet "Saved" status and Done. A new card keeps Cancel + Create. Each save writes only its part (`CardModal.CardFields`), so somebody else's card still sends only the date and assignees, and saves queue so quick edits land in order. Escape blurs the focused field first (`modal-escape.js`), so every way out saves, and switching cards in the inspector loses nothing. **Undo.** Deleting a card, a comment or a board is one click, with a toast carrying **Undo** for six seconds (the toast's only clickable part; the word takes a light board accent). Cards and comments are hidden at once and deleted when the toast has gone (`UndoService`), because they cascade to tags, people, threads and files and could not be rebuilt; a delete that fails in the window brings the thing back with a toast. Boards are soft-deleted already, so they go at once and Undo restores them (settings page and the super-admin record). Docs: `ui.md` (card dialog, the inspector, somebody else's card, a new *Undo* section), `boards-and-permissions.md` (a save writes only its part), `frontend.md` (uploads gate Create/Done; the v2 region's status), `orientation.md` (`UndoService`), `testing.md`. One changelog line, both locales. Verified: `tools/verify.sh` (584 unit tests); driven in a dev instance (title saved by clicking another card, "Saved" after a date pick, Escape saves, delete hides at once, Undo restores, the row is gone after the window; light and dark); full e2e on an isolated stack: 158/158 passed (specs updated in this PR: board, permissions, calendar-all, share, moderation, helpers/boards). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
An existing card's dialog saves each part when it is finished (a field on
change, a picker or the date on the pick) and has Done in place of Save and
Cancel; a new card keeps Create. Escape blurs the field first, so leaving
always saves.

Deleting a card, a comment or a board is one click. Cards and comments hide
at once and are deleted when the Undo toast has gone (UndoService); boards
are soft-deleted already, so Undo restores them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card test now saves each field as it is left (no Save button: the title
renamed and kept through Done, a description kept through Escape) and deletes
with Undo: hidden at once, back on Undo, and the row gone only once the toast
has. A comment delete is one click with Undo; a board's settings delete and a
record's removal are one click. The confirmations they stepped through are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop a comment the old Save path left in CommitAsync
All checks were successful
CI / build (pull_request) Successful in 41s
8efea2eebb
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-242/run-2026-10-03T01-31-51-406Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/675); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-242/run-675.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

💬 Verdict: comment — notes only, nothing blocking.

The design is coherent: each save writes only its part, saves queue so they land in order, and delete is deferred until the Undo window closes. The docs and both locales are updated. I found two real issues and did not run anything.

1. "Saved" can show for a failed save, and a throw stops later saves (inline on CardModal.razor). BoardPage.SaveCard and Calendar.SaveCard catch their own errors and return normally, so _saved = true still runs after a refused or failed write. If OnSave ever does throw, the faulted _saving task makes every later save in that dialog rethrow.

2. A late Undo click is silently ignored (inline on UndoService.cs). The toast is shown a render after the commit timer starts, so Undo can still be on screen when the delete runs.

Smaller notes, not blocking

  • modal-escape.js blurs every .modal input except type=search. If the assignee picker's text field handles Escape itself to close its menu, blurring it first may change that. I couldn't tell without reading the picker.
  • BoardRecordPage passes its instance RestoreAsync to the toast, so it may run on a disposed page. BoardSettings correctly makes its restore touch services only.

🧠 claude-sonnet-5-5 · ⏱ 0m 40s · 2 turns · tokens: 43.9k in / 4.7k out (+12.2k cache-read)
📜 Full session transcript

💬 **Verdict: comment** — notes only, nothing blocking. The design is coherent: each save writes only its part, saves queue so they land in order, and delete is deferred until the Undo window closes. The docs and both locales are updated. I found two real issues and did not run anything. **1. "Saved" can show for a failed save, and a throw stops later saves** (inline on `CardModal.razor`). `BoardPage.SaveCard` and `Calendar.SaveCard` catch their own errors and return normally, so `_saved = true` still runs after a refused or failed write. If `OnSave` ever does throw, the faulted `_saving` task makes every later save in that dialog rethrow. **2. A late Undo click is silently ignored** (inline on `UndoService.cs`). The toast is shown a render after the commit timer starts, so Undo can still be on screen when the delete runs. **Smaller notes, not blocking** - `modal-escape.js` blurs every `.modal input` except `type=search`. If the assignee picker's text field handles Escape itself to close its menu, blurring it first may change that. I couldn't tell without reading the picker. - `BoardRecordPage` passes its instance `RestoreAsync` to the toast, so it may run on a disposed page. `BoardSettings` correctly makes its restore touch services only. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 40s · 2 turns · tokens: 43.9k in / 4.7k out (+12.2k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-242/run-675.md) <!-- claude-verdict:comment --> <!-- claude-reviewed-head:8efea2eebb2ed68de914e7556c0149cff352151c -->
@ -382,0 +424,4 @@
{
await previous;
await OnSave.InvokeAsync(args);
_saved = true;
Collaborator

_saved = true is set whenever OnSave returns, but BoardPage.SaveCard and Calendar.SaveCard catch their own errors and return normally. A refused or failed write therefore still shows "Saved". Also, if OnSave ever throws, _saving stays faulted, and every later await previous rethrows, so no later save in that dialog can succeed. Wrap await previous in try/catch, or chain with a continuation that ignores the earlier fault. Have OnSave report success (a bool or an exception) before showing Saved.

`_saved = true` is set whenever `OnSave` returns, but `BoardPage.SaveCard` and `Calendar.SaveCard` catch their own errors and return normally. A refused or failed write therefore still shows "Saved". Also, if `OnSave` ever throws, `_saving` stays faulted, and every later `await previous` rethrows, so no later save in that dialog can succeed. Wrap `await previous` in try/catch, or chain with a continuation that ignores the earlier fault. Have `OnSave` report success (a bool or an exception) before showing Saved.
@ -0,0 +37,4 @@
lock (_lock) _pending.Add(key);
Changed?.Invoke();
toasts.Show(message, ToastKind.Success, new ToastAction(undoLabel, () =>
Collaborator

Race: the commit timer starts here, but the toast's 6 s timer starts only after InvokeAsync renders it. The toast can therefore still be on screen for a short time after the delete has run. A click on Undo in that gap cancels a spent token, calls Settle, and shows nothing, while the thing stays deleted. Consider a committed flag so Undo is refused or disabled once the delete has started, or make the toast's lifetime end before the commit by starting the delay from the toast.

Race: the commit timer starts here, but the toast's 6 s timer starts only after `InvokeAsync` renders it. The toast can therefore still be on screen for a short time after the delete has run. A click on Undo in that gap cancels a spent token, calls `Settle`, and shows nothing, while the thing stays deleted. Consider a committed flag so Undo is refused or disabled once the delete has started, or make the toast's lifetime end before the commit by starting the delay from the toast.
opencode-reviewer approved these changes 2026-10-03 01:58:05 +00:00
Dismissed
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

This is a careful, well-tested change and I found nothing that blocks merging.

What holds up under scrutiny

  • Permission split survives the per-part writes. The CardFields flags are produced only from controls the dialog actually renders (CardModal.razor): title/description inputs exist only when CanEdit, the TagPicker only when CanEdit, and the reader's card offers only the date and the AssigneePicker.OnlyDid. So the Text branch of BoardPage.SaveCard/Calendar.SaveCard is unreachable on somebody else's card, and the reader's commits send exactly SetDueDateAsync and/or SetCardAssigneesAsync — same writes as before, now split. The server re-checks every call (CardService.DeleteCardAsync → RequireCardDelete, etc.), so nothing new rides on client trust.
  • The save queue is right. CommitAsync snapshots into a fresh CardModalSaveArgs before chaining _saving = SaveAfter(_saving, args), so a later edit cannot change what an earlier save writes, and two quick edits land in order. It also means an in-flight save survives the inspector switching to another card, which is exactly what the docs claim. SaveAfter only sets _saved = true after OnSave completes; the failure paths in both pages close the dialog before _saved can be shown, so the "Saved" status doesn't lie about a refused write.
  • Threading of UndoService is handled. Settle raises Changed from a thread-pool thread, and every subscriber (BoardPage.Reload, Calendar.Reload, CommentList.Refresh) marshals through InvokeAsync; ToastContainer.Show was moved to build the item off-list (Interlocked.Increment, add inside InvokeAsync) precisely because failure toasts now arrive from Task.Run. The _toasts.Contains(toast) guard also prevents the removal timer from racing an Undo click that already ran the action. Disposal unsubscribes Changed in all three components.
  • Escapes and closes save first. modal-escape.js blurs in the capture phase before the dialog's own Escape handler, and the exclude of [type=search] correctly leaves the assignee menu's own Escape handling (AssigneePicker) untouched.
  • Deferred-delete failure path is coherent: a commit that fails (rights revoked, item already gone) settles the key and reloads, so the item returns with the danger toast; a silent no-op delete (already gone) settles quietly. The stale BoardViewer captured at delete time is still re-checked server-side, so a mid-window revocation is caught, not bypassed.
  • Docs (ui.md, boards-and-permissions.md, frontend.md, orientation.md, testing.md), the changelog line in both locales, and the e2e updates (including the DB-level "row is gone after the window" check via psql) all match the implementation; the removed locale keys (board.card.saved, the confirm strings) have no remaining references.

Minor notes, none blocking

  1. CommitAsync(CardFields.Text) builds its args with DueDate = _dueDate, and UpdateCardAsync writes title, description and due date. So a text-only save can revert a due date that another member changed while the dialog was open — the same clobber window the old single-write Save had, so it is not a regression, but the docs' claim "a save writes only the part just changed" is slightly optimistic for text saves.
  2. The pending-hide covers the board, the calendar and the comment list, but other surfaces of the same circuit (search results, Home tiles and "For you" cards, comment-count badges) can still show a pending-deleted card or comment for the ~6 s window. Worth one sentence in ui.md's Undo section someday.
  3. In UndoService, if the 6 s delay completes in the same instant the Undo button is clicked, the cancel can lose the race and the delete still commits (button is disabled only at ~the same boundary). A if (undone.IsCancellationRequested) return; after the delay would close the sliver; genuinely rare, so fine to leave.
  4. After a circuit dies inside the window, the failure toast (toasts.Show inside the Task.Run catch) can throw out of the catch into an unobserved task fault — harmless noise today, but wrapping that one Show in a try/catch would make the remark "the safe way to fail" literally quiet.

⏱ 26m 14s · 30 steps · $0.1072 · tokens: 243.7k in / 3.4k out (+1062.0k cache-read) · Go: 5h 4% · wk 9% · mo 87%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. This is a careful, well-tested change and I found nothing that blocks merging. **What holds up under scrutiny** - *Permission split survives the per-part writes.* The `CardFields` flags are produced only from controls the dialog actually renders (`CardModal.razor`): title/description inputs exist only when `CanEdit`, the `TagPicker` only when `CanEdit`, and the reader's card offers only the date and the `AssigneePicker.OnlyDid`. So the `Text` branch of `BoardPage.SaveCard`/`Calendar.SaveCard` is unreachable on somebody else's card, and the reader's commits send exactly `SetDueDateAsync` and/or `SetCardAssigneesAsync` — same writes as before, now split. The server re-checks every call (`CardService.DeleteCardAsync` → `RequireCardDelete`, etc.), so nothing new rides on client trust. - *The save queue is right.* `CommitAsync` snapshots into a fresh `CardModalSaveArgs` before chaining `_saving = SaveAfter(_saving, args)`, so a later edit cannot change what an earlier save writes, and two quick edits land in order. It also means an in-flight save survives the inspector switching to another card, which is exactly what the docs claim. `SaveAfter` only sets `_saved = true` after `OnSave` completes; the failure paths in both pages close the dialog before `_saved` can be shown, so the "Saved" status doesn't lie about a refused write. - *Threading of `UndoService` is handled.* `Settle` raises `Changed` from a thread-pool thread, and every subscriber (`BoardPage.Reload`, `Calendar.Reload`, `CommentList.Refresh`) marshals through `InvokeAsync`; `ToastContainer.Show` was moved to build the item off-list (`Interlocked.Increment`, add inside `InvokeAsync`) precisely because failure toasts now arrive from `Task.Run`. The `_toasts.Contains(toast)` guard also prevents the removal timer from racing an Undo click that already ran the action. Disposal unsubscribes `Changed` in all three components. - *Escapes and closes save first.* `modal-escape.js` blurs in the capture phase before the dialog's own Escape handler, and the exclude of `[type=search]` correctly leaves the assignee menu's own Escape handling (`AssigneePicker`) untouched. - *Deferred-delete failure path* is coherent: a commit that fails (rights revoked, item already gone) settles the key and reloads, so the item returns with the danger toast; a silent no-op delete (already gone) settles quietly. The stale `BoardViewer` captured at delete time is still re-checked server-side, so a mid-window revocation is caught, not bypassed. - Docs (`ui.md`, `boards-and-permissions.md`, `frontend.md`, `orientation.md`, `testing.md`), the changelog line in both locales, and the e2e updates (including the DB-level "row is gone after the window" check via `psql`) all match the implementation; the removed locale keys (`board.card.saved`, the confirm strings) have no remaining references. **Minor notes, none blocking** 1. `CommitAsync(CardFields.Text)` builds its args with `DueDate = _dueDate`, and `UpdateCardAsync` writes title, description *and* due date. So a text-only save can revert a due date that another member changed while the dialog was open — the same clobber window the old single-write Save had, so it is not a regression, but the docs' claim "a save writes only the part just changed" is slightly optimistic for text saves. 2. The pending-hide covers the board, the calendar and the comment list, but other surfaces of the same circuit (search results, Home tiles and "For you" cards, comment-count badges) can still show a pending-deleted card or comment for the ~6 s window. Worth one sentence in `ui.md`'s Undo section someday. 3. In `UndoService`, if the 6 s delay completes in the same instant the Undo button is clicked, the cancel can lose the race and the delete still commits (button is disabled only at ~the same boundary). A `if (undone.IsCancellationRequested) return;` after the delay would close the sliver; genuinely rare, so fine to leave. 4. After a circuit dies inside the window, the failure toast (`toasts.Show` inside the `Task.Run` catch) can throw out of the catch into an unobserved task fault — harmless noise today, but wrapping that one `Show` in a try/catch would make the remark "the safe way to fail" literally quiet. --- ⏱ 26m 14s · 30 steps · $0.1072 · tokens: 243.7k in / 3.4k out (+1062.0k cache-read) · Go: 5h 4% · wk 9% · mo 87% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-242/run-2026-10-03T01-31-51-406Z.md) <!-- opencode-reviewed-head:8efea2eebb2ed68de914e7556c0149cff352151c --> <!-- opencode-verdict:approve -->
Close the Undo race, keep a text save off the due date, and fix review notes
All checks were successful
CI / build (pull_request) Successful in 41s
2b3ab52d8e
- The delete waits a second past the toast, whose clock starts a render later,
  and Undo and the delete each claim the item once; a click that still loses
  says "Already deleted" instead of nothing.
- UpdateCardAsync becomes UpdateTextAsync and no longer writes the due date, so
  a text save cannot undo somebody else's date change.
- A failed save no longer blocks the dialog's later ones (Task.WhenAny).
- The board record's Undo restores the board it removed, not whichever board
  the reused page shows by then.
- docs/calendar.md described the old save split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Addressed in 2b3ab52:

  • Undo race (both reviews): the delete now waits ActionLifetime + 1 s, so the toast (whose clock starts a render later, plus its 200 ms exit) is gone first, and Undo and the delete each claim the item once via Interlocked.Exchange. A click that still loses (only with over a second of lag) shows "Already deleted" rather than nothing.
  • Text save clobbering the date (opencode 1): UpdateCardAsync is now UpdateTextAsync and writes only title and description; the date has SetDueDateAsync, so the doc claim holds. Move calls both.
  • A faulted save blocking later ones (claude 1): SaveAfter waits with Task.WhenAny(previous). "Saved" after a failed save cannot show, because both pages close the dialog on failure (now said in a comment).
  • BoardRecordPage's Undo (claude): it now restores the board id captured at removal. It wasn't only a disposed-page question: the router reuses that page across boards, so _record could name another board by the time Undo was clicked.
  • Other surfaces (opencode 2): one sentence in ui.md's Undo section.
  • Also fixed docs/calendar.md, which still described the old save split.

Not changed:

  • modal-escape.js and the assignee picker: its field is type=search, which the selector excludes, so its own Escape (closing the menu) is untouched.
  • The failure toast after the circuit dies (opencode 4): ToastContainer unsubscribes on dispose, so Show then has no handler and cannot throw.
Addressed in 2b3ab52: - **Undo race** (both reviews): the delete now waits `ActionLifetime` + 1 s, so the toast (whose clock starts a render later, plus its 200 ms exit) is gone first, and Undo and the delete each claim the item once via `Interlocked.Exchange`. A click that still loses (only with over a second of lag) shows "Already deleted" rather than nothing. - **Text save clobbering the date** (opencode 1): `UpdateCardAsync` is now `UpdateTextAsync` and writes only title and description; the date has `SetDueDateAsync`, so the doc claim holds. Move calls both. - **A faulted save blocking later ones** (claude 1): `SaveAfter` waits with `Task.WhenAny(previous)`. "Saved" after a failed save cannot show, because both pages close the dialog on failure (now said in a comment). - **BoardRecordPage's Undo** (claude): it now restores the board id captured at removal. It wasn't only a disposed-page question: the router reuses that page across boards, so `_record` could name another board by the time Undo was clicked. - **Other surfaces** (opencode 2): one sentence in `ui.md`'s Undo section. - Also fixed `docs/calendar.md`, which still described the old save split. Not changed: - `modal-escape.js` and the assignee picker: its field is `type=search`, which the selector excludes, so its own Escape (closing the menu) is untouched. - The failure toast after the circuit dies (opencode 4): `ToastContainer` unsubscribes on dispose, so `Show` then has no handler and cannot throw.
Collaborator

🤖 Reviewing this PR — the review will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the review runs.

🤖 Reviewing this PR — the review will link the full session transcript. <!-- opencode-run:review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-242/run-2026-10-03T02-02-24-976Z.md) — updating every ~5 minutes while the review runs.
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/678); the reply will link the full session transcript. <!-- claude-run:pr_review --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-242/run-678.md) — updating every ~5 minutes while the task runs.
claude-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Both issues from my last pass are resolved.

  • Saved after a failed save: SaveAfter now uses Task.WhenAny(previous), so an earlier fault no longer blocks later saves. A failed write makes BoardPage.SaveCard and Calendar.SaveCard close the dialog and show a toast, so a "Saved" flash on a closing dialog is harmless. The code comment says this. A refused or failed write still hits _saved = true in the dialog, but the dialog is already gone by then.
  • Late Undo: UndoService now delays the commit by 1 s more than the toast. Undo and the commit also claim the item once through an Interlocked flag, and a click that loses shows "Already deleted".

I did not run the app or the tests.

Non-blocking notes:

  • modal-escape.js blurs any .modal input except type=search on Escape. I did not check whether the assignee picker's text input handles Escape itself to close its menu.
  • BoardRecordPage.RestoreAsync is an instance method and now captures the board id, but it still touches _busy and ReloadAsync on a page that may have been disposed. That would only matter if someone navigated away before pressing Undo, and the failure is minor.

🧠 claude-sonnet-5-5 · ⏱ 0m 10s · 3 turns · tokens: 49.4k in / 886 out (+72.6k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. Both issues from my last pass are resolved. - **Saved after a failed save:** `SaveAfter` now uses `Task.WhenAny(previous)`, so an earlier fault no longer blocks later saves. A failed write makes `BoardPage.SaveCard` and `Calendar.SaveCard` close the dialog and show a toast, so a "Saved" flash on a closing dialog is harmless. The code comment says this. A refused or failed write still hits `_saved = true` in the dialog, but the dialog is already gone by then. - **Late Undo:** `UndoService` now delays the commit by 1 s more than the toast. Undo and the commit also claim the item once through an `Interlocked` flag, and a click that loses shows "Already deleted". I did not run the app or the tests. Non-blocking notes: - `modal-escape.js` blurs any `.modal input` except `type=search` on Escape. I did not check whether the assignee picker's text input handles Escape itself to close its menu. - `BoardRecordPage.RestoreAsync` is an instance method and now captures the board id, but it still touches `_busy` and `ReloadAsync` on a page that may have been disposed. That would only matter if someone navigated away before pressing Undo, and the failure is minor. --- 🧠 claude-sonnet-5-5 · ⏱ 0m 10s · 3 turns · tokens: 49.4k in / 886 out (+72.6k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-242/run-678.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:2b3ab52d8ef8be15b5a8f17a7e0f077b19fb502a -->
opencode-reviewer left a comment

✅ Verdict: approve — no blocking issues found.

Re-review: both earlier blockers are resolved

1. "Saved" after a failed save, and a poisoned save queue — fixed. SaveAfter now chains with await Task.WhenAny(previous), which waits out the predecessor without inheriting its fault, so one throwing save no longer blocks every later save in the dialog. And _saved = true only runs when OnSave returned normally: both BoardPage.SaveCard and Calendar.SaveCard close the dialog and show the danger toast inside their PermissionDeniedException / general catches before returning, so a refused or failed write never renders the "Saved" status.

2. The late-Undo race — fixed on both ends. The commit timer now waits ActionLifetime + Margin (one second longer than the toast), and the toast's clock starts only a render later with a 200 ms exit, so Undo is off screen before the delete begins. The button is also disabled as soon as the toast starts exiting, and a click that still loses the race is no longer silent: the claimed flag routes it to the "Already deleted" danger toast rather than a no-op.

Everything else from the last pass still holds: the per-part writes send only what the dialog rendered (a reader's card offers only the date and the menu-less picker, so Text/Tags commits cannot happen there, and every service re-checks server-side); the CommitAsync snapshot keeps a later edit out of an earlier save and lets an in-flight save survive the inspector switching cards; UpdateTextAsync no longer touches DueDate, so the text save cannot revert a date; ToastContainer builds items off-list with Interlocked.Increment and guards removal with _toasts.Contains, because failure toasts now arrive from Task.Run; modal-escape.js blurs in the capture phase and excludes type=search, leaving AssigneePicker's own Escape intact; every UndoService.Changed subscriber unsubscribes on dispose and marshals through InvokeAsync; boards soft-delete and restore through BoardSettings / BoardRecordPage closures that touch services only or catch their own errors.

Docs and tests are consistent: ui.md's Undo section states plainly that search, Home and comment counts catch up when the delete lands, both locales carry every new string, no stale reference to UpdateCardAsync, board.card.saved or the confirm keys remains, and the e2e suite pins the whole loop including the DB-level check that the row survives the window and goes after it.

Minor, non-blocking: the toasts.Show(failedMessage, …) inside UndoService's catch is itself unwrapped, so if the circuit died inside the window the failure toast becomes an unobserved-task exception instead of silence — noise only, worth a try/catch someday.


⏱ 3m 17s · 15 steps · $0.0363 · tokens: 122.8k in / 2.4k out (+440.7k cache-read) · Go: 5h 4% · wk 9% · mo 87%
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. ## Re-review: both earlier blockers are resolved **1. "Saved" after a failed save, and a poisoned save queue — fixed.** `SaveAfter` now chains with `await Task.WhenAny(previous)`, which waits out the predecessor without inheriting its fault, so one throwing save no longer blocks every later save in the dialog. And `_saved = true` only runs when `OnSave` returned normally: both `BoardPage.SaveCard` and `Calendar.SaveCard` close the dialog and show the danger toast inside their `PermissionDeniedException` / general catches before returning, so a refused or failed write never renders the "Saved" status. **2. The late-Undo race — fixed on both ends.** The commit timer now waits `ActionLifetime + Margin` (one second longer than the toast), and the toast's clock starts only a render later with a 200 ms exit, so Undo is off screen before the delete begins. The button is also disabled as soon as the toast starts exiting, and a click that still loses the race is no longer silent: the `claimed` flag routes it to the "Already deleted" danger toast rather than a no-op. Everything else from the last pass still holds: the per-part writes send only what the dialog rendered (a reader's card offers only the date and the menu-less picker, so `Text`/`Tags` commits cannot happen there, and every service re-checks server-side); the `CommitAsync` snapshot keeps a later edit out of an earlier save and lets an in-flight save survive the inspector switching cards; `UpdateTextAsync` no longer touches `DueDate`, so the text save cannot revert a date; `ToastContainer` builds items off-list with `Interlocked.Increment` and guards removal with `_toasts.Contains`, because failure toasts now arrive from `Task.Run`; `modal-escape.js` blurs in the capture phase and excludes `type=search`, leaving `AssigneePicker`'s own Escape intact; every `UndoService.Changed` subscriber unsubscribes on dispose and marshals through `InvokeAsync`; boards soft-delete and restore through `BoardSettings` / `BoardRecordPage` closures that touch services only or catch their own errors. Docs and tests are consistent: `ui.md`'s Undo section states plainly that search, Home and comment counts catch up when the delete lands, both locales carry every new string, no stale reference to `UpdateCardAsync`, `board.card.saved` or the confirm keys remains, and the e2e suite pins the whole loop including the DB-level check that the row survives the window and goes after it. Minor, non-blocking: the `toasts.Show(failedMessage, …)` inside `UndoService`'s catch is itself unwrapped, so if the circuit died inside the window the failure toast becomes an unobserved-task exception instead of silence — noise only, worth a try/catch someday. --- ⏱ 3m 17s · 15 steps · $0.0363 · tokens: 122.8k in / 2.4k out (+440.7k cache-read) · Go: 5h 4% · wk 9% · mo 87% 📜 [Full session transcript](https://git.grandiras.net/Grandiras/opencode-bot/src/branch/opencode-logs/transcripts/Grandiras/Ponente/pr-242/run-2026-10-03T02-02-24-976Z.md) <!-- opencode-reviewed-head:2b3ab52d8ef8be15b5a8f17a7e0f077b19fb502a --> <!-- opencode-verdict:approve -->
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
5 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente!242
No description provided.