feat(ui): animated top-right toast stack - #820
Conversation
The cockpit's toasts rendered bottom-centre with no transition, landing on top of the thread's action row (the reported case covered the "Run again" button) and popping in and out in a way that read as a rendering glitch. - Store: dismissal is now two-phase. At TOAST_MS the item is re-published with exiting: true instead of being dropped, and a second timer removes it after the exit animation window — the old single-phase drop unmounted the node before any exit transition could run. Pending timers are tracked so resetToasts() cancels them and one test's toasts cannot leak into the next. - Renderer: the stack anchors to the top-right (top/right safe-area insets, items-end), each item caps its width so long messages wrap, and the wrapper moves to z-[60] so a toast fired from inside a dialog is not painted over by the Radix portal at z-50. - Animation uses the tw-animate-css utilities already imported by styles/index.css, gated on motion-safe: so prefers-reduced-motion keeps the instant appear. The data-slot, data-tone and role="status" hooks that three e2e specs query are unchanged. Refs #818
📦 npm preview published —
|
|
🤖 Updated after the
|
|
🤖 📸 UI evidence — captured against the real cockpit, booted from a production build by 1. The stack now sits top-right. Compare with the screenshot on #818, where the same primitive rendered bottom-centre on top of the thread's action row. 2. A toast fired under an open dialog stays visible. The command palette is open and dimming the page behind it; the toast is not dimmed and not covered. This is the 3. Measured in the live page, not asserted in jsdom. Settled geometry and computed style of the mounted toast:
The exit sample is the point of the store change: before this PR the node was gone the instant the 5s timer fired, so there was nothing left to animate. Reduced motion: every animation utility here is emitted inside Validation gate: ✅ |
|
🤖 Claiming this PR — starting |
🔍 Code Review🤖
🎯 SummaryThe PR reworks the cockpit's single toast primitive so the stack renders in the top-right corner with a real slide-and-fade transition, replacing a bottom-centre stack that had no transition at all. The interesting part is not the CSS: the old store deleted a toast from the module queue the moment its 5s timer fired, so the node unmounted before an exit animation could ever run. The PR splits dismissal into two phases — mark the item Scope discipline is good — 47 changed lines in the primitive, no caller touched, One real problem survives, and it is the same class of problem the PR was written to fix. VerdictRequest changes — one major finding. There are no blockers, the validation gate is green, and no contract surface is touched, so the fix is a small one; the autofix loop takes it from here. 🧪 Validation Gate
CI on the head commit is green as well: Findings🔴 Major1. On the mobile breakpoint the new anchor puts the toast on top of the app shell's own header — the exact failure mode this PR exists to fix. — The wrapper anchors unconditionally at Why major rather than minor: it is a realistic path (the cockpit is used on a phone — there is an Fix: keep the 16px anchor from 🟡 Minor2.
🔵 Nit3. An exiting toast holds its slot in the column for the whole exit window. — The item slides right and fades, but its box keeps its height in the 4. Each toast keeps For five seconds the toast swallows clicks in the top-right corner of the viewport, and there is nothing to click on it — #818 puts manual dismiss buttons explicitly out of scope, and the issue's implementation notes name this exact class, so leaving it is the right call for this PR. Flagging it only so the manual QA pass checks that nothing important sits under the stack's footprint on the views that fire toasts most. 💥 Breaking-Changes Checklist
🧪 Test CoverageCoverage for the changed behavior is present and, unusually, proven: the suite grew from 3 tests to 5, and the four new or rewritten ones were confirmed red against the pre-fix Gaps worth closing, both small: the NextAutofix is eligible on this run, so findings 1 and 2 are being fixed now, with the full validation gate re-run and a re-review posted before the PR leaves |
Review of #820 found the top-right anchor reproducing the very problem it fixes on the mobile breakpoint: below `md` the app shell renders its own 52px header (app-shell.tsx) whose right end carries the run status dot and kebab, and a toast anchored 16px under the safe-area inset covers exactly those controls for the full five seconds. - Anchor the stack below that header on phones and keep the 16px anchor from `md` up, where the header no longer renders. - Pin the breakpoint pair in the anchor test so a future edit cannot drop it. - Pin `motion-safe:duration-200` in the animation test: it and the store's EXIT_MS remove the node together, and until now only a comment said so. Refs #818
🔍 Code Review — re-review (post-autofix)🤖 🎯 SummaryThe two actionable findings from the first pass are fixed in one commit, and both previous blockers-in-waiting are now pinned by tests rather than by comments. The major finding — the top-right anchor covering the app shell's mobile header, which would have relocated #818's complaint rather than fixed it — is resolved with a breakpoint pair: the stack sits below the header on phones and keeps the 16px anchor from Both fixes are verified in the real cockpit, not just in jsdom: at a 390×844 viewport the toast's top edge measures VerdictApprove. No blockers, no majors, no un-waivered findings remain. The two nits from the first pass (an exiting toast holding its layout slot for the 200ms exit window, and 🧪 Validation Gate
Gate honesty — one intermittent failure, investigated rather than waved off. Across five full Findings✅ Resolved from the previous pass1. (was Major) Mobile header overlap — fixed in 2. (was Minor) No new findings were introduced by the fix commit: it changes two class strings and adds two assertions, touches no other file, and adds no logic. 💥 Breaking-Changes ChecklistUnchanged from the first pass and re-verified against the fix commit — the fix is presentational and additive. 🧪 Test Coverage
NextApproved, so the PR moves to |
|
🤖 📸 The review's major finding was that the new top-right anchor covered the app shell's own mobile header. Re-captured at a 390×844 viewport against the real cockpit after the fix: Measured in the live page: the mobile header's bottom edge is at Both anchor rules are present in the built stylesheet — |
🧪 Manual QA instructions (
|
|
🤖 |
…d-top-right-toasts
🤖
|
|
🤖 The last commit on this branch ( |
|
🤖 ✅ All checks on the final head ( Worth noting for the flake filed as #823: the E2E-inclusive CI job passed on this branch, so the The PR is ready for review and sits in |
|
🤖 |
📸 UI QA evidence — FAILVerdict: ❌ FAIL — the desktop half of this change is exactly what the issue asked for: the stack is anchored top-right, it slides in and fades, and it dismisses in two phases so the exit animation actually plays. On a phone-width viewport, though, the new anchor lands squarely on the task thread's own header controls: while a toast is up it covers and swallows taps on the "Run actions" kebab and the "Rename task" button for the toast's full five seconds. That is the same class of problem #818 filed — a toast parked on top of the controls underneath it — moved from the bottom action row to the top-right one. Review gate: this PR reads Scenario (P1 — cockpit toast stack)Where to click: any cockpit action that fires a toast. This pass used the task thread's "take over interactively" copy-command button ("Command copied to clipboard.") and
ScreenshotsThe defect, precisely
Two shapes of fix suggest themselves, and the choice is the author's: raise the phone anchor below the route's own header row rather than below the shell header, or make the toast item non-interactive ( Notes for QA
|
🧪 Follow-up: add a UI/integration testThe change ships unit coverage that asserts the anchor class string, which is what let the phone-width overlap through: the classes are exactly what the post-review fix intended, but on the task-thread route the geometry they produce still lands on the header controls. A browser-level test measures the thing that actually matters. Run Scenario (derived from the manual run above):
|
|
🤖 |
|
🏷️ Applying the failure label at the maintainer's explicit request, because the QA pass above found a reproducible defect that this PR itself introduces. Why: at 390×844 on a task thread, a toast occupies y 61–101 px at right 16 px and lands on the route's own header row — it covers the "Run actions" kebab (y 65–95) and "Rename task" (y 69–95), and because the toast item is What this label means here: the desktop half of the change is good — top-right anchor, preserved
|






Closes #818
Tracking plan: .ai/runs/2026-08-09-animated-top-right-toasts.md
Status: complete
🎯 Goal
What Changed
packages/web/src/components/ui/toaster.tsx— the store now dismisses in two phases. The oldtoast()dropped its item from the module store the instant the 5s timer fired, so the node unmounted before any exit animation could run; an exit transition was therefore impossible with CSS alone.ToastItemgains anexitingflag: atTOAST_MSthe item is re-published as exiting, and a second timer removes itEXIT_MS(200ms) later. Every pending handle is tracked in a module set soresetToasts()cancels timers as well as clearing the queue — otherwise one test's orphaned timer would publish into the next test's store.packages/web/src/components/ui/toaster.tsx— the stack is anchored top-right and animated. The wrapper moves frominset-x-0 bottom-[…] items-centertotop-[calc(16px+env(safe-area-inset-top))] right-[calc(16px+env(safe-area-inset-right))] items-end, each item caps its width (max-w-[min(360px,calc(100vw-32px))]) so a long message wraps instead of spanning the viewport, and the wrapper moves fromz-50toz-[60]. That last part matters: dialogs do fire toasts, Radix portals its overlay to the end of<body>, and at an equal z-index the dialog paints over the toast — rare with a bottom-centre stack, common with a top-right one. Each toast emitsdata-state={open|closed}and carriestw-animate-cssenter/exit classes (animate-in/fade-in-0/slide-in-from-right-4and theirdata-[state=closed]counterparts) gated onmotion-safe:, soprefers-reduced-motion: reducekeeps the instant appear/disappear it had before. No new dependency and no new@keyframes— the utilities come from thetw-animate-cssimport already inpackages/web/src/styles/index.css.<Toaster />is still mounted once at the app root andtoast()keeps its exact signature, so every call site is untouched.🔍 Review
om-auto-review-prran in autofix mode and requested changes on one major finding: below themdbreakpoint the new top-right anchor covered the app shell's own 52px mobile header, whose right end carries the run status dot and kebab — Implement: animated top-right toast stack in the cockpit #818's complaint relocated rather than fixed. Fixed in69f27427with a breakpoint pair (top-[calc(61px+…)] md:top-[calc(16px+…)]), measured in a real 390×844 browser at an 8px gap below the header. A minor finding (theEXIT_MS↔duration-200coupling documented only in a comment) is now pinned by a test assertion. The re-review approved; the two remaining nits are documented and deliberately unchanged.🧪 Tests
npm run typecheck— ✅ clean across api-client, server and web.npm test— ✅ 5630 passed / 308 files (includespackages/web/src/design-guardian.test.ts).npm run test:unit— ✅ 35 passed, 1 skipped.npm run build— ✅ tsc + vite +check:pack(462 files, 88 underweb/dist).npm run test:package— ✅ 12 passed.npm testruns, inpackages/cezar/src/server/open-in-app.test.ts— a file byte-identical tomain, unrelated to this browser-only diff, and green 10/10 in isolation. Filed as Flaky: open-in-app.test.ts JetBrains stub assertion races under full-suite load #823 rather than absorbed silently; four of five full runs and CI on this head are green.packages/web/src/components/ui/toaster.test.tsxgrew from 3 tests to 5 and now locks in: the two-phase lifecycle (at 5s the node is still present withdata-state="closed", at 5.2s it is gone), the top-right anchor (asserting the new classes are present and that the bottom-centre ones are gone — the regression guard for the actual ask), thedata-stateopen→closed flip with themotion-safe:animation classes, and two toasts keeping independent clocks across the new exit timers.toaster.tsx(git stash pushon the source, run,git stash pop), so none of them passes against the bug.💥 Breaking Changes
packages/web), which ships in lockstep with the server that serves it —BACKWARD_COMPATIBILITY.mdcovers the CLI surface, the/api/v1routes and SSE vocabulary, and the.ai/cezar/state formats, none of which are touched. The only internal contracts at stake are thedata-slot="toaster"/data-slot="toast"/data-toneattributes and therole="status"live region queried bypackages/web/e2e/{new-task,settings-skills,workflows}.e2e.ts, and all four are preserved byte-for-byte.📋 Progress
See the Progress section in the tracking plan.