From eae8c7d22947369304f4a9d3a4fe410d67502508 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 19:43:16 -0700 Subject: [PATCH 1/8] fix: stop idle marching ants allocation --- DESIGN.md | 2 +- docs/specs/layout.md | 4 +- docs/specs/layout.rationale.md | 2 + lib/src/cfg.ts | 2 + .../components/wall/SelectionRing.test.tsx | 1 + lib/src/components/wall/SelectionRing.tsx | 23 ++++++----- .../wall/WorkspaceSelectionOverlay.test.tsx | 38 +++++++++++++++---- .../wall/WorkspaceSelectionOverlay.tsx | 9 ++++- 8 files changed, 60 insertions(+), 21 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index b660f7560..9f21127b1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -258,7 +258,7 @@ The most distinctive motion in the system. Implemented as `clip-path` reveals, n - **Reduced-motion:** all of the above are nulled. #### Marching Ants (Command Mode) -The selection ring around the focused pane in command mode is an SVG with `stroke-dasharray` and an infinite `marching-ants` keyframe that increments `stroke-dashoffset` by `var(--march-offset)`. Color: `var(--color-focus-ring)`. This is the system's only ongoing animation; it is meant to be the visual signature of "you are now in command mode." The ring stays crisp while travelling; motion reads instead from soft directional bands drawn behind each edge, sized by how fast that edge is moving across itself. +The selection ring around the focused pane in command mode is an SVG with `stroke-dasharray` and a `marching-ants` keyframe that increments `stroke-dashoffset` by `var(--march-offset)`. Color: `var(--color-focus-ring)`. It is the visual signature of "you are now in command mode," so it marches in a short burst on entry and on each selection change rather than forever — an idle wall runs no animation (timing in `docs/specs/layout.md` → Selection overlay). The ring stays crisp while travelling; motion reads instead from soft directional bands drawn behind each edge, sized by how fast that edge is moving across itself. #### Focus Ring Travel & Header Crossfade When selection moves between panes/doors, the focus ring **glides** to the new target over 220ms (`FOCUS_MOTION_MS`, half the pane-motion duration) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`, and the source/destination pane headers crossfade their active/inactive palette over the same 220ms (`HEADER_PALETTE_TRANSITION_CLASS` in `design.tsx`), so the two read as one gesture. The ring's rect is a per-frame JS tween (`rect-tween.ts`), not a CSS transition; same-identity re-measures (sash drag, window resize, animator frames) snap 1:1, and a pane↔door move lerps the corner radii so the shape never pops. Reduced motion nulls both: the ring snaps and the header palette swaps instantly. diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 2f4ae58ab..25a012a4c 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -183,7 +183,7 @@ A fixed-positioned element on top of the Lath host, covering the active element' - **Exactly one pane or door is active at a time**, drawn by one SVG renderer (`SelectionRing`, `variant: 'ants' | 'solid'`). - **Passthrough:** `variant='solid'` — a 1px solid SVG stroke, centerline `strokeWidth/2` inside the div edge for panes and doors alike, no glow (rationale). -- **Command:** `variant='ants'` — animated marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash / 40% gap, 0.4s cycle, 2px stroke), unchanged while the ring travels; the motion smear is a separate layer behind it ([Ring travel](#ring-travel)). The animation pauses while the window is unfocused, and the whole ring drops to `saturate(0.3)` then. +- **Command:** `variant='ants'` — marching-ants border (`cfg.marchingAnts`: 10px segment, 60% dash, four 0.4s cycles, 2px stroke). **Run the burst on command entry or identity change, then hold still** (test: `starts a finite burst on command entry and remounts the outline on a selection change` in `lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx`; rationale). Keep it unchanged during travel and draw the smear separately ([Ring travel](#ring-travel)). **While unfocused, pause it and apply `saturate(0.3)` to the ring.** - Border radius follows DESIGN.md's Concentric-Corners Rule: the pane ring's radius is the pane radius plus the inflate (`PANE_SELECTION_RING_RADIUS_PX`), with the marching-ants path inset so its stroke centerline sits on the same gutter midline; doors sit at zero offset and keep `0.5rem 0.5rem 0 0`. - Color is the resolved `--color-focus-ring`, **re-read whenever `document.body`'s class/style changes**, because the dynamic palette publishes it there (`useFocusRingColor`). - `z-index: 50`, `pointer-events: none`. @@ -192,7 +192,7 @@ A fixed-positioned element on top of the Lath host, covering the active element' The ring's rect (and its `{tl,tr,br,bl,inset}` shape) is driven **per-frame by a JS tween, never a CSS transition**; DESIGN.md's ban on animating layout properties does not reach it (rationale). Motion is `FOCUS_MOTION_MS` (220ms — half `LATH_MOTION_MS`) on the house curve `cubic-bezier(0.22, 1, 0.36, 1)`. -Per-frame writes are **imperative**: `SelectionRing` renders a stable shell once (per variant/color/focus change) and lifts its DOM nodes back to the overlay via refs; the rAF loop writes rect, path `d`, marching-ants dash, and every smear piece's `d`/width/opacity directly, then **re-applies once after any structural render, pre-paint**, so a freshly mounted ring never flashes. **Never reintroduce per-frame React state** — reconciling this subtree every frame competes with the travel for the frame budget (rationale). +Per-frame writes are **imperative**: `SelectionRing` gives the overlay refs to its stable shell; the rAF loop writes rect, path `d`, marching dash, and smear geometry, then **re-applies after structural renders, pre-paint**, so fresh nodes do not flash. **Never reintroduce per-frame React state** — reconciling this subtree competes with travel for the frame budget (rationale). - **Identity change → tween.** A measurement whose identity (`${selectedType}:${selectedId}`) differs from the one on screen glides from the current interpolated position to the new target, **clock restarted**, so arrow-key spam stays responsive. - **Same identity → snap 1:1.** A same-identity re-measure with no tween in flight (sash drag, window resize, a settled leaf's store commit) writes the new rect directly, tracking the geometry exactly instead of easing behind it. diff --git a/docs/specs/layout.rationale.md b/docs/specs/layout.rationale.md index c3e918825..bb0f43ae1 100644 --- a/docs/specs/layout.rationale.md +++ b/docs/specs/layout.rationale.md @@ -32,6 +32,8 @@ The passthrough `solid` variant replaced an original `border: 1px solid ${color} **The inflate arithmetic.** With `SELECTION_RING_INFLATE_PX` at 4, the 1px passthrough border spans [3px, 4px] from the pane edge — dead centre of the 7px gutter, on whole pixels because the gutter is odd. That is the whole reason `PANE_GUTTER_PX` is odd. +**Why marching is burst-bound.** An infinite SVG stroke animation kept Chrome's renderer active at 60 style recalculations per second while Dormouse was otherwise idle. Measured in Chrome for Testing 150 (2026-09): five focused minutes added 3.77 MB of reclaimable embedder heap and used 24.33 seconds of renderer CPU; pausing only that animation held embedder heap flat (-29 KB) and used 0.017 seconds across a three-minute control. Four cycles preserve the mode/selection cue without leaving a standing allocator after interaction stops. + ## Ring travel **Why the JS tween is not what DESIGN.md bans.** That rule bans CSS *transitions* on layout properties, which the compositor cannot run off the main thread; the overlay writes true interpolated values each rAF frame, inside the same pointer-events-none carve-out the Lath animator holds. diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index e6bbeb9be..dceaafb2c 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -8,6 +8,8 @@ export const cfg = { dashFraction: 0.6, /** Seconds for one full dash-gap cycle. */ cycleDuration: 0.4, + /** Cycles to run when command mode starts or the active selection changes. */ + cyclesPerSelection: 4, /** Stroke width in px. */ strokeWidth: 2, /** When true, animation is frozen at T=0 (for deterministic Chromatic snapshots). */ diff --git a/lib/src/components/wall/SelectionRing.test.tsx b/lib/src/components/wall/SelectionRing.test.tsx index 00f61f935..a260cb9ea 100644 --- a/lib/src/components/wall/SelectionRing.test.tsx +++ b/lib/src/components/wall/SelectionRing.test.tsx @@ -33,6 +33,7 @@ it('renders the smear transform origin without a React DOM-property warning', as await act(async () => root.render( ; @@ -78,6 +78,9 @@ export function SelectionRing({ ))} diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx index 536f93b65..4b4c2390b 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.test.tsx @@ -266,9 +266,30 @@ describe('SelectionRing settled render', () => { expect(path!.getAttribute('stroke-opacity')).toBeNull(); }); - // The path element is shared across variants and the dash is an imperative write - // React never reconciles away — a command→passthrough flip must clear it, or the - // 1px solid ring renders the ants' dash as a dotted line. + // The finite burst starts when command mode adds the animation, and a selection + // change restarts it by remounting only the keyed outline. + it('starts a finite burst on command entry and remounts the outline on a selection change', async () => { + const store = makeStore(); + const panes = twoPanes(); + await act(async () => root.render()); + + const passthroughPath = container.querySelector('[data-ring="outline"]') as SVGPathElement; + expect(passthroughPath.style.animation).toBe(''); + + await act(async () => root.render()); + + const path = container.querySelector('[data-ring="outline"]') as SVGPathElement; + expect(path).toBe(passthroughPath); + expect(path.style.animation).toBe( + `marching-ants ${cfg.marchingAnts.cycleDuration}s linear ${cfg.marchingAnts.cyclesPerSelection}`, + ); + + await act(async () => root.render()); + expect(container.querySelector('[data-ring="outline"]')).not.toBe(path); + }); + + // The dash is an imperative write React never reconciles away, so the reverse + // mode flip must clear it or the 1px solid ring stays dotted. it('clears the ants dash when flipping command → passthrough', async () => { const store = makeStore(); const panes = twoPanes(); @@ -410,10 +431,13 @@ describe('SelectionRing motion smear', () => { // Mid-travel the ring is a different size, so the dash resizes with it — but // the period must still be exactly one dash+gap or the keyframe jumps. - const [d2, g2] = dashOf(path); - expect(path.style.getPropertyValue('--march-offset')).toBe(`-${d2 + g2}px`); + // Re-queried, not reused: the selection change remounted the outline (see the + // burst-restart case above), and the geometry lands on the replacement node. + const movedPath = container.querySelector('[data-ring="outline"]')!; + const [d2, g2] = dashOf(movedPath); + expect(movedPath.style.getPropertyValue('--march-offset')).toBe(`-${d2 + g2}px`); expect(d2 / (d2 + g2)).toBeCloseTo(cfg.marchingAnts.dashFraction, 9); - expect(path.getAttribute('transform')).toBeNull(); - expect(path.getAttribute('stroke-opacity')).toBeNull(); + expect(movedPath.getAttribute('transform')).toBeNull(); + expect(movedPath.getAttribute('stroke-opacity')).toBeNull(); }); }); diff --git a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx index 4fd20fd0a..44acfba1f 100644 --- a/lib/src/components/wall/WorkspaceSelectionOverlay.tsx +++ b/lib/src/components/wall/WorkspaceSelectionOverlay.tsx @@ -75,6 +75,12 @@ function measureFrame(el: HTMLElement, isDoor: boolean): RingFrame { }; } +// The active selection's identity. One definition because two mechanisms key off +// it: the travel tween restarts when it changes, and so does the marching burst. +function ringIdentity(type: WallSelectionKind, id: string): string { + return `${type}:${id}`; +} + function framesEqual(a: RingFrame, b: RingFrame): boolean { return ( a.rect.top === b.rect.top && a.rect.left === b.rect.left @@ -344,7 +350,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele } const isDoor = selectedType === 'door'; - const identity = `${selectedType}:${selectedId}`; + const identity = ringIdentity(selectedType, selectedId); // Evaluated once per effect run, not per frame — the effect re-runs on every // Lath commit, which is plenty fresh for an OS-preference toggle. const instant = motionIsInstant(); @@ -428,6 +434,7 @@ export function WorkspaceSelectionOverlay({ lathStore, subscribeLathFrames, sele return ( Date: Thu, 3 Sep 2026 20:15:41 -0700 Subject: [PATCH 2/8] fix: bound alert bell animation --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 4 +++- lib/src/components/bell-icon-class.test.ts | 16 ++++++++++++++++ lib/src/components/bell-icon-class.ts | 2 +- lib/src/theme.css | 6 +++--- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 lib/src/components/bell-icon-class.test.ts diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 9ea7b9876..4cca4b662 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -333,7 +333,7 @@ Where it surfaces is host-specific: The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. -Bell visual state is a pure function of public status. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. +Bell visual state is a pure function of public status. **On entry to `ALERT_RINGING`, ring the bell for four 800ms cycles, then hold it at 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. Bell interactions — one transition table, in `dismissOrToggleAlert`: diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index d7ce4b145..76d967b75 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -84,7 +84,9 @@ ## Pane Header -**Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the Chromatic freeze that pins the bell: an infinite opacity cycle would otherwise snapshot at an arbitrary phase and diff against itself on every run. +**Why the bell rings only four times.** With four focused ringing bells, the former infinite animation added 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU over three minutes. Pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). Four cycles preserve the entry cue without leaving a per-Session animation running for the lifetime of an unattended alert. + +**Why `cfg.alert.ringingPaused` suppresses the burst.** It is the Chromatic freeze that pins the bell; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 3.2 seconds. ## Text And Security diff --git a/lib/src/components/bell-icon-class.test.ts b/lib/src/components/bell-icon-class.test.ts new file mode 100644 index 000000000..3b122dbc5 --- /dev/null +++ b/lib/src/components/bell-icon-class.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { expect, it } from 'vitest'; +import { bellIconClass } from './bell-icon-class'; + +it('runs a finite ringing burst and then holds the bell at 45 degrees', () => { + const classes = bellIconClass('ALERT_RINGING').split(' '); + expect(classes).toContain('motion-safe:animate-bell-ring'); + expect(classes).toContain('rotate-45'); + + const here = dirname(fileURLToPath(import.meta.url)); + const themeCss = readFileSync(resolve(here, '../theme.css'), 'utf8'); + expect(themeCss).toContain('--animate-bell-ring: bell-ring 800ms ease-in-out 4;'); + expect(themeCss).toMatch(/@keyframes bell-ring\s*{[^}]*rotate: 45deg;/); +}); diff --git a/lib/src/components/bell-icon-class.ts b/lib/src/components/bell-icon-class.ts index 833ff5be6..5c534daee 100644 --- a/lib/src/components/bell-icon-class.ts +++ b/lib/src/components/bell-icon-class.ts @@ -11,7 +11,7 @@ export function bellIconClass(status: SessionStatus): string { status === 'ALERT_RINGING' && ( cfg.alert.ringingPaused ? 'rotate-45' - : 'motion-safe:animate-bell-ring motion-reduce:rotate-45' + : 'rotate-45 motion-safe:animate-bell-ring' ), ].filter(Boolean).join(' '); } diff --git a/lib/src/theme.css b/lib/src/theme.css index 8faab32e0..3e175de5d 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -85,7 +85,7 @@ --color-input-border: var(--vscode-input-border); /* Animation */ - --animate-bell-ring: bell-ring 800ms ease-in-out infinite; + --animate-bell-ring: bell-ring 800ms ease-in-out 4; --animate-speech-alarm-pulse: speech-alarm-pulse 650ms ease-in-out infinite; --animate-shake-x: shake-x 400ms ease-out; } @@ -127,8 +127,8 @@ body { } @keyframes bell-ring { - 0%, 100% { transform: rotate(45deg); } - 50% { transform: rotate(-45deg); } + 0%, 100% { rotate: 45deg; } + 50% { rotate: -45deg; } } @keyframes speech-alarm-pulse { From 421b310a52fae4c80ef337a59e987fbc5c9d383d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 21:54:05 -0700 Subject: [PATCH 3/8] fix: replay bell for independent alert tracks --- docs/specs/alert.md | 4 +- docs/specs/alert.rationale.md | 4 ++ lib/src/components/AlertBell.test.tsx | 46 ++++++++++++++++++ lib/src/components/AlertBell.tsx | 34 ++++++++++++++ lib/src/components/Baseboard.tsx | 2 + lib/src/components/Door.tsx | 10 ++-- lib/src/components/MobileTerminalUi.tsx | 11 +++-- lib/src/components/MobileWall.tsx | 10 ++-- .../components/wall/TerminalPaneHeader.tsx | 9 +--- lib/src/lib/alert-manager.test.ts | 47 +++++++++++++++++++ lib/src/lib/alert-manager.ts | 23 +++++++++ lib/src/lib/platform/vscode-adapter.ts | 13 ++--- lib/src/lib/session-activity-store.ts | 13 +++-- lib/src/lib/session-save.test.ts | 5 +- lib/src/lib/session-save.ts | 6 +-- lib/src/lib/session-types.ts | 14 ++++++ lib/src/lib/terminal-lifecycle.ts | 1 + lib/src/lib/terminal-store.ts | 3 ++ lib/src/remote/pocket-app/wall-model.test.ts | 6 +-- lib/src/remote/pocket-app/wall-model.ts | 4 ++ scripts/spec-word-budgets.json | 2 +- vscode-ext/src/message-router.ts | 22 +-------- vscode-ext/src/message-types.ts | 16 ++----- 23 files changed, 225 insertions(+), 80 deletions(-) create mode 100644 lib/src/components/AlertBell.test.tsx create mode 100644 lib/src/components/AlertBell.tsx diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 4cca4b662..871aea62c 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -333,7 +333,7 @@ Where it surfaces is host-specific: The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. -Bell visual state is a pure function of public status. **On entry to `ALERT_RINGING`, ring the bell for four 800ms cycles, then hold it at 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. +Bell rotation is a pure function of public status; its motion is not. **Ring the bell for four 800ms cycles per latched track, then hold it at 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **A track latching behind an already-latched one replays the burst; a track already ringing does not** — that is enrichment of one summons, not a new one. Both are keyed on `AlertState.ringSeq`, a per-Session count of latches, read only for change and compared by `alertStatesEqual` (rationale; pinned by `counts a second track ringing behind an already-latched one` and `does not count a track that is already ringing` in `lib/src/lib/alert-manager.test.ts`, `replaces the icon when the ring counter advances` in `lib/src/components/AlertBell.test.tsx`). **Remote Clients have no counter** — `DirectoryEntry.ringing` is an edgeless boolean, so a Pocket bell rings on mount and then holds. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. Bell interactions — one transition table, in `dismissOrToggleAlert`: @@ -351,7 +351,7 @@ The TODO pill always displays `TODO`; remote notification text belongs in previe Spoken-alarm delivery is much louder than the bell: a pointer-transparent treatment spans the whole terminal Pane, labelled `SPEAKING` while the engine actually speaks and `SPOKEN` — quieter, and unbounded — until the ring resolves. **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The layers, their strengths, placement, and sizing belong to `docs/specs/layout.md` → Spoken-alarm overlay. -Source of truth: `bellIconClass` in `lib/src/components/bell-icon-class.ts`; `dismissOrToggleAlert` in `lib/src/lib/session-activity-store.ts`; `lib/src/components/TodoPillBody.tsx`; `lib/src/components/wall/AlertSpeechIndicator.tsx`. +Source of truth: `AlertBell` in `lib/src/components/AlertBell.tsx`; `bellIconClass` in `lib/src/components/bell-icon-class.ts`; `latchRing` in `lib/src/lib/alert-manager.ts`; `dismissOrToggleAlert` in `lib/src/lib/session-activity-store.ts`; `lib/src/components/TodoPillBody.tsx`; `lib/src/components/wall/AlertSpeechIndicator.tsx`. ### Door diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 76d967b75..11cff475b 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -86,6 +86,10 @@ **Why the bell rings only four times.** With four focused ringing bells, the former infinite animation added 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU over three minutes. Pausing only those animations in the same loaded document reduced that to 0.13 MB, two recalculations, and 0.025 seconds. After bounding the burst, two consecutive three-minute windows each had zero live animations, one recalculation, under 0.40 MB of non-cumulative embedder drift, and at most 0.024 seconds of renderer CPU (measured in Chrome 150, 2026-09). Four cycles preserve the entry cue without leaving a per-Session animation running for the lifetime of an unattended alert. +**Why a counter, not the status.** Bounding the burst turned a continuous cue into an edge-triggered one, and the public status has no such edge: `hasActiveRing` ORs three independently latching tracks, so a second alert behind a latched one leaves `ALERT_RINGING` in place. `notification` is no better — `applyCommandExitRinging` deliberately preserves a richer protocol notification. With both unchanged, `alertStatesEqual` also judged the two states equal and never emitted, so the renderer could not have reacted even had it wanted to. `ringSeq` is the smallest thing that changes exactly once per latch. + +**Why latches and not notifications.** Counting every ring rule instead would have made the counter unbounded: past the first latch `deferOrDeliverNotification` stops deferring, so a Session bell-ing in a loop emits one host→webview update per PTY chunk, each one restarting a 3.2s burst that then never finishes — the always-running animation the finite burst exists to remove. Counting latches bounds it at three per Session per dismissal and matches the model that function already states: an existing ring means enrichment, not a fresh summons. A timestamp floor would bound it too, but it would put the CSS duration in the manager. + **Why `cfg.alert.ringingPaused` suppresses the burst.** It is the Chromatic freeze that pins the bell; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 3.2 seconds. ## Text And Security diff --git a/lib/src/components/AlertBell.test.tsx b/lib/src/components/AlertBell.test.tsx new file mode 100644 index 000000000..72b87a644 --- /dev/null +++ b/lib/src/components/AlertBell.test.tsx @@ -0,0 +1,46 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { AlertBell } from './AlertBell'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +const bell = () => container.querySelector('svg'); + +// `docs/specs/alert.md` -> Pane Header. The class assertion is the premise: it +// is identical across rings, so only the remount can restart the burst. +it('replaces the icon when the ring counter advances', async () => { + await act(async () => root.render()); + const first = bell(); + expect(first?.getAttribute('class')).toContain('animate-bell-ring'); + + await act(async () => root.render()); + const second = bell(); + expect(second).not.toBe(first); + expect(second?.getAttribute('class')).toBe(first?.getAttribute('class')); +}); + +// Re-renders that are not a new latch must leave the animation alone, or a burst +// restarts on every unrelated store commit. +it('keeps the icon across a re-render at the same ring counter', async () => { + await act(async () => root.render()); + const first = bell(); + + await act(async () => root.render()); + expect(bell()).toBe(first); +}); diff --git a/lib/src/components/AlertBell.tsx b/lib/src/components/AlertBell.tsx new file mode 100644 index 000000000..3d18a9169 --- /dev/null +++ b/lib/src/components/AlertBell.tsx @@ -0,0 +1,34 @@ +import { BellIcon } from '@phosphor-icons/react'; +import { clsx } from 'clsx'; +import type { SessionStatus } from '../lib/terminal-registry'; +import { bellIconClass } from './bell-icon-class'; + +/** + * The status bell for one Session — the only place a `BellIcon` is drawn. + * + * Every ring must replay the finite ringing burst, and only a remount can start + * one, because the className is identical across two rings + * (`docs/specs/alert.md` -> Pane Header). Keying here rather than at each call + * site is what keeps that from being a rule call sites have to remember. + * + * One element, not a branch per status: two `BellIcon`s in the same position + * would remount on every crossing into `WATCHING_DISABLED` — i.e. every command + * boundary — which is churn with no burst to show for it. + */ +export function AlertBell({ status, ringSeq, size, className }: { + status: SessionStatus; + /** `ActivityState.ringSeq` — read only for change, never magnitude. */ + ringSeq: number; + size: number; + className?: string; +}) { + const watching = status !== 'WATCHING_DISABLED'; + return ( + + ); +} diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index 42896603c..26422c9b2 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -215,6 +215,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo key={item.id} title={title} status={activity.status} + ringSeq={activity.ringSeq} todo={activity.todo} speechState={speechStates.get(item.id)} /> @@ -250,6 +251,7 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo doorId={item.id} title={title} status={activity.status} + ringSeq={activity.ringSeq} todo={activity.todo} speechState={speechStates.get(item.id)} onClick={() => onReattach(item)} diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index f03a6af1c..a2ed9cb5d 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -1,9 +1,10 @@ import { type PointerEvent as ReactPointerEvent } from 'react'; import { clsx } from 'clsx'; -import { BellIcon, SpeakerHighIcon } from '@phosphor-icons/react'; +import { SpeakerHighIcon } from '@phosphor-icons/react'; import type { AlertSpeechState, SessionStatus, TodoState } from '../lib/terminal-registry'; import { useTodoPillContent } from './TodoPillBody'; -import { alertSpeakingAnimationClass, bellIconClass } from './bell-icon-class'; +import { alertSpeakingAnimationClass } from './bell-icon-class'; +import { AlertBell } from './AlertBell'; import { ALERT_SPEECH_TRACKING_CLASS, TERMINAL_TOP_RADIUS_CLASS, @@ -14,6 +15,8 @@ export interface DoorProps { doorId?: string; title: string; status?: SessionStatus; + /** `ActivityState.ringSeq`; a change replays the ringing burst. */ + ringSeq: number; todo?: TodoState; speechState?: AlertSpeechState; onClick?: () => void; @@ -28,6 +31,7 @@ export function Door({ doorId, title, status = 'WATCHING_DISABLED', + ringSeq, todo = false, speechState, onClick, @@ -89,7 +93,7 @@ export function Door({ )} {showBell && ( - + )} diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 8224698c2..8b5847b6a 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -11,7 +11,6 @@ import { } from 'react'; import { ArticleNyTimesIcon, - BellIcon, ClockCounterClockwiseIcon, CursorClickIcon, CursorTextIcon, @@ -20,7 +19,7 @@ import { TextTIcon, } from '@phosphor-icons/react'; import { clsx } from 'clsx'; -import { bellIconClass } from './bell-icon-class'; +import { AlertBell } from './AlertBell'; import { MobileGestureConfirmDialog, MobileGestureRadialMenu, @@ -53,6 +52,8 @@ export interface MobileTerminalSessionItem { secondary?: string | null; active?: boolean; status?: SessionStatus; + /** `ActivityState.ringSeq`; a change replays the ringing burst. */ + ringSeq: number; todo?: boolean; } @@ -355,13 +356,13 @@ function SessionsPane({ ) : null} {ringing ? ( - ) : null} diff --git a/lib/src/components/MobileWall.tsx b/lib/src/components/MobileWall.tsx index e47ade117..c131f3267 100644 --- a/lib/src/components/MobileWall.tsx +++ b/lib/src/components/MobileWall.tsx @@ -1,12 +1,11 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { ArrowLineDownIcon, - BellIcon, XIcon, } from '@phosphor-icons/react'; import { HeaderActionButton } from './HeaderActionButton'; import { TerminalPane } from './TerminalPane'; -import { bellIconClass } from './bell-icon-class'; +import { AlertBell } from './AlertBell'; import { TODO_PILL_TRACKING_CLASS } from './design'; import { useTodoPillContent } from './TodoPillBody'; import type { MobileTerminalSessionItem } from './MobileTerminalUi'; @@ -87,6 +86,7 @@ export function useMobileWallSessionItems( secondary: derivedHeader.secondary, active: session.id === activeSessionId, status: activity.status, + ringSeq: activity.ringSeq, todo: activity.todo, }; }), [activeSessionId, activityStates, appTitleForPane, sessions, terminalStates, visiblePaneStates]); @@ -204,11 +204,7 @@ function MobileWallHeader({ dataAlertButtonFor={session.id} > - {status === 'WATCHING_DISABLED' ? ( - - ) : ( - - )} + {session.secondary ? ( diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index aa8de6832..9607b9c62 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -5,7 +5,6 @@ import { ArrowLineDownIcon, ArrowsInIcon, ArrowsOutIcon, - BellIcon, CursorClickIcon, CursorTextIcon, SplitHorizontalIcon, @@ -15,7 +14,7 @@ import { import { HeaderActionButton } from '../HeaderActionButton'; import { TodoAlertDialog } from '../TodoAlertDialog'; import { HEADER_PALETTE_TRANSITION_CLASS, paneZoomButtonClass, POPUP_SURFACE_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; -import { bellIconClass } from '../bell-icon-class'; +import { AlertBell } from '../AlertBell'; import { useTodoPillContent } from '../TodoPillBody'; import type { PaneProps } from './pane-props'; import { IllegalRenameWarning, type RenameRejection } from './IllegalRenameWarning'; @@ -264,11 +263,7 @@ export function TerminalPaneHeader({ id, title }: PaneProps) { dataAlertButtonFor={id} > - {activity.status === 'WATCHING_DISABLED' ? ( - - ) : ( - - )} + {showTodoPill && ( diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 48cfbb562..8d5a86ab4 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -401,6 +401,53 @@ describe('AlertManager in isolation', () => { }); }); + // `docs/specs/alert.md` -> Pane Header. + it('counts a second track ringing behind an already-latched one', () => { + const id = 'ring-seq-cross-track'; + const seqs: number[] = []; + manager.onStateChange((_id, state) => { + if (_id === id) seqs.push(state.ringSeq); + }); + + manager.attend(id); + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'pnpm build' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + vi.advanceTimersByTime(15_000); + + applyTerminalProtocolEvents(manager, id, [ + { kind: 'notification', notification: { source: 'BEL', title: 'Terminal bell', body: null } }, + ]); + const rung = manager.getState(id); + expect(rung.status).toBe('ALERT_RINGING'); + + // The command-exit track latches behind the protocol one. Everything else the + // renderer could have keyed on is unchanged across this ring. + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + const again = manager.getState(id); + expect(again.status).toBe(rung.status); + expect(again.notification).toEqual(rung.notification); + expect(again.ringSeq).toBeGreaterThan(rung.ringSeq); + // And it has to reach subscribers: `alertStatesEqual` would otherwise call + // these two states equal and drop the update before it left the host. + expect(seqs).toContain(again.ringSeq); + }); + + // The counter is bounded by construction: a repeated notification on a track + // that is already ringing enriches the standing summons rather than raising a + // new one, so bell spam cannot restart the burst faster than it can play. + it('does not count a track that is already ringing', () => { + const id = 'ring-seq-same-track'; + const bell = { source: 'BEL', title: 'Terminal bell', body: null } as const; + + applyTerminalProtocolEvents(manager, id, [{ kind: 'notification', notification: bell }]); + const first = manager.getState(id).ringSeq; + applyTerminalProtocolEvents(manager, id, [{ kind: 'notification', notification: bell }]); + + expect(manager.getState(id).ringSeq).toBe(first); + }); + it('finishes an armed command-exit watch when the PTY exits without commandFinish', () => { const id = 'command-exit-pty-exit'; diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index c4f29643c..ebbfbf04f 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -159,6 +159,11 @@ export interface AlertState { attentionDismissedRing: boolean; /** At least one `dor await` is parked on this Session. Never persisted. */ awaited: boolean; + /** + * How many alarm tracks have latched on this Session, monotonic. Read only for + * change, never as a magnitude (`docs/specs/alert.md` -> Pane Header). + */ + ringSeq: number; } export const DEFAULT_ALERT_STATE: AlertState = { @@ -168,6 +173,7 @@ export const DEFAULT_ALERT_STATE: AlertState = { notification: null, attentionDismissedRing: false, awaited: false, + ringSeq: 0, }; /** Three independent alarm tracks plus an always-on, non-latching detector. @@ -185,6 +191,8 @@ interface AlertEntry { * about the interval since the ring, which is only observable here. */ outputSinceWatchingRing: boolean; + /** Source of `AlertState.ringSeq`; see the field's contract there. */ + ringSeq: number; protocolStatus: ProtocolStatus; progress: ActiveProtocolProgress | null; commandExitStatus: CommandExitStatus; @@ -404,6 +412,7 @@ export class AlertManager { // it right now. The originating command key latches here so the ring // outlives the command that raised it. if (!this.isWatching(entry) || this.hasAttention(id)) break; + this.latchRing(entry, entry.watchingRingingCommand !== null); entry.watchingRingingCommand = entry.commandExitWatch?.argv0 ?? null; entry.outputSinceWatchingRing = false; this.notify(id); @@ -702,6 +711,7 @@ export class AlertManager { } private applyProtocolRinging(entry: AlertEntry, notification: ActivityNotification): void { + this.latchRing(entry, entry.protocolStatus === 'ALERT_RINGING'); entry.notification = notification; entry.todo = true; entry.protocolStatus = 'ALERT_RINGING'; @@ -824,6 +834,7 @@ export class AlertManager { displayCommand: string, exitCode: number | undefined, ): void { + this.latchRing(entry, entry.commandExitStatus === 'ALERT_RINGING'); entry.commandExitStatus = 'ALERT_RINGING'; entry.todo = true; // A protocol ring carries richer text; never overwrite it with the generic one. @@ -926,6 +937,15 @@ export class AlertManager { || entry.watchingRingingCommand !== null; } + /** + * Count one track latching. The mirror of `releaseRing`: a track that is + * already ringing is enrichment of the same summons, not a fresh one, so it + * does not advance the counter — see `deferOrDeliverNotification`. + */ + private latchRing(entry: AlertEntry, wasRinging: boolean): void { + if (!wasRinging) entry.ringSeq++; + } + /** Release one track's latched ring. Returns whether it was ringing. */ private releaseRing(entry: AlertEntry, track: 'protocol' | 'commandExit' | 'watching'): boolean { switch (track) { @@ -1059,6 +1079,7 @@ export class AlertManager { notification: entry.notification, attentionDismissedRing: entry.attentionDismissedRing, awaited: (this.awaits.get(id)?.waiters.size ?? 0) > 0, + ringSeq: entry.ringSeq, }; } @@ -1173,6 +1194,7 @@ export class AlertManager { detector: this.createDetector(id), watchingRingingCommand: null, outputSinceWatchingRing: false, + ringSeq: 0, protocolStatus: 'IDLE', progress: null, commandExitStatus: 'IDLE', @@ -1211,6 +1233,7 @@ function alertStatesEqual(a: AlertState, b: AlertState): boolean { || a.todo !== b.todo || a.attentionDismissedRing !== b.attentionDismissedRing || a.awaited !== b.awaited + || a.ringSeq !== b.ringSeq ) return false; const an = a.notification; const bn = b.notification; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 28b912fed..9ad4214b7 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -138,16 +138,11 @@ export class VSCodeAdapter implements PlatformAdapter { handler({ requestId: msg.requestId }); } } else if (msg.type === 'alert:state') { + // The host posts the whole `AlertState`; forwarding it wholesale is what + // keeps a new alert field from needing an edit on this path alone. + const { type: _type, ...detail } = msg; for (const handler of this.alertStateHandlers) { - handler({ - id: msg.id, - status: msg.status, - watchingEnabled: msg.watchingEnabled, - todo: msg.todo, - notification: msg.notification ?? null, - attentionDismissedRing: msg.attentionDismissedRing, - awaited: msg.awaited, - }); + handler(detail); } } else if (msg.type === 'alert:watchedCommands') { for (const handler of this.watchedCommandHandlers) { diff --git a/lib/src/lib/session-activity-store.ts b/lib/src/lib/session-activity-store.ts index e3c914195..b735066a2 100644 --- a/lib/src/lib/session-activity-store.ts +++ b/lib/src/lib/session-activity-store.ts @@ -1,7 +1,7 @@ import type { SessionStatus } from './alert-manager'; import type { AlertStateDetail } from './platform/types'; import { applyAlertSettingsFromHost, publishAlertSettings } from './alert-settings'; -import type { PersistedAlertState, PersistedPane } from './session-types'; +import { toPersistedAlertState, type PersistedAlertState, type PersistedPane } from './session-types'; import { getPlatform } from './platform'; import { getRunningCommandArgv0 } from './terminal-state-store'; import { @@ -32,6 +32,7 @@ export const DEFAULT_ACTIVITY_STATE: ActivityState = { todo: false, notification: null, awaited: false, + ringSeq: 0, }; const activityListeners = new Set<() => void>(); @@ -88,6 +89,7 @@ function readLiveActivity(id: string): ActivityState | null { todo: entry.todo, notification: entry.notification, awaited: entry.awaited, + ringSeq: entry.ringSeq, }; } @@ -107,12 +109,7 @@ function readActivity(id: string): ActivityState | null { export function getLivePersistedAlertState(id: string): PersistedAlertState | null { const state = readLiveActivity(id); - if (!state) return null; - return { - status: state.status, - todo: state.todo, - notification: state.notification, - }; + return state && toPersistedAlertState(state); } export function primeActivity(id: string, state: Partial): void { @@ -180,6 +177,7 @@ function handleAlertState(detail: AlertStateDetail): void { const entry = getEntryByPtyId(detail.id); if (entry) { entry.alertStatus = detail.status; + entry.ringSeq = detail.ringSeq; entry.watchingEnabled = detail.watchingEnabled; entry.todo = detail.todo; entry.notification = detail.notification; @@ -190,6 +188,7 @@ function handleAlertState(detail: AlertStateDetail): void { } else { primeActivity(detail.id, { status: detail.status, + ringSeq: detail.ringSeq, watchingEnabled: detail.watchingEnabled, todo: detail.todo, notification: detail.notification, diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index 12d28d7e5..fa10b3e27 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -305,11 +305,15 @@ describe('saveSession', () => { it('persists local browser surface TODO state in the browser pane alert field', async () => { const platform = createPlatform(null); + // A full live ActivityState, so the assertion below shows the projection + // dropping the fields `docs/specs/alert.md` -> Public State forbids on disk. terminalRegistryMocks.getActivity.mockReturnValue({ status: 'WATCHING_DISABLED', watchingEnabled: false, todo: true, notification: null, + awaited: false, + ringSeq: 3, }); await saveSession(platform, [ @@ -319,7 +323,6 @@ describe('saveSession', () => { const saved = vi.mocked(platform.saveState).mock.calls[0]![0] as PersistedSession; expect(saved.panes.find((p) => p.id === 'pane-web')!.alert).toEqual({ status: 'WATCHING_DISABLED', - watchingEnabled: false, todo: true, notification: null, }); diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 5b45cc951..2f3151f69 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -1,5 +1,5 @@ import type { PlatformAdapter } from './platform/types'; -import { browserPersistedPane, readPersistedSession, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; +import { browserPersistedPane, readPersistedSession, toPersistedAlertState, type PersistedDoor, type PersistedPane, type PersistedSession, type PersistedSurfaceRefs, type PersistedSurfaceType } from './session-types'; import { getActivity, getLivePersistedAlertState, getTerminalPaneState, isUntouched, resolveTerminalSessionId } from './terminal-registry'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; @@ -49,9 +49,9 @@ export async function saveSession( const previousPane = previousPanes.get(pane.id); if (pane.surfaceType === 'browser') { // The activity store already holds this surface's TODO; persist it as the - // alert blob (ActivityState is assignable to PersistedAlertState). + // alert blob, projected to the persisted fields. const activity = getActivity(pane.id); - return browserPersistedPane(pane, activity.todo ? activity : null); + return browserPersistedPane(pane, activity.todo ? toPersistedAlertState(activity) : null); } const liveAlert = getLivePersistedAlertState(pane.id); diff --git a/lib/src/lib/session-types.ts b/lib/src/lib/session-types.ts index 12bd148ee..2fa09fff5 100644 --- a/lib/src/lib/session-types.ts +++ b/lib/src/lib/session-types.ts @@ -22,6 +22,20 @@ export interface PersistedPane { surfaceType?: PersistedSurfaceType; } +/** + * Narrow live Activity down to what may reach disk. An explicit projection, not + * a structurally-assignable pass-through: `ActivityState` is a superset, and + * `JSON.stringify` writes every extra field it grows + * (`docs/specs/alert.md` -> Public State, "Persist only"). + */ +export function toPersistedAlertState(state: PersistedAlertState): PersistedAlertState { + return { + status: state.status, + todo: state.todo, + notification: state.notification ?? null, + }; +} + /** Shared browser-pane projection for renderer saves and VS Code host refresh. */ export function browserPersistedPane( pane: { id: string; title: string }, diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 1cbea2998..35e8c3486 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -347,6 +347,7 @@ function setupTerminalEntry(id: string, options: { shell?: string; untouched?: b element, cleanup, alertStatus: 'WATCHING_DISABLED', + ringSeq: 0, watchingEnabled: false, todo: false, notification: null, diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 26b723bed..d5b589bee 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -10,6 +10,8 @@ export interface ActivityState { notification: ActivityNotification | null; /** A `dor await` is parked on this Session (`docs/specs/alert.md` -> Await). */ awaited: boolean; + /** Mirrored from the host (`AlertState.ringSeq`). */ + ringSeq: number; } export interface TerminalEntry { @@ -28,6 +30,7 @@ export interface TerminalEntry { notification: ActivityNotification | null; attentionDismissedRing: boolean; awaited: boolean; + ringSeq: number; isReplaying: boolean; untouched: boolean; /** diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts index ce852e0c2..8dd586916 100644 --- a/lib/src/remote/pocket-app/wall-model.test.ts +++ b/lib/src/remote/pocket-app/wall-model.test.ts @@ -72,8 +72,8 @@ describe('directorySessionItems', () => { 's2', ); expect(items).toEqual([ - { id: 's1', title: 'zsh', secondary: '/home/me', active: false, status: undefined, todo: false }, - { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, todo: false }, + { id: 's1', title: 'zsh', secondary: '/home/me', active: false, status: undefined, ringSeq: 0, todo: false }, + { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, ringSeq: 0, todo: false }, ]); }); @@ -104,7 +104,7 @@ describe('directorySessionItems', () => { 's1', ); expect(items).toEqual([ - { id: 's2', title: 'alive', secondary: null, active: false, status: undefined, todo: false }, + { id: 's2', title: 'alive', secondary: null, active: false, status: undefined, ringSeq: 0, todo: false }, ]); }); }); diff --git a/lib/src/remote/pocket-app/wall-model.ts b/lib/src/remote/pocket-app/wall-model.ts index da7ee3e19..2f35679e4 100644 --- a/lib/src/remote/pocket-app/wall-model.ts +++ b/lib/src/remote/pocket-app/wall-model.ts @@ -40,6 +40,10 @@ export function directorySessionItems( secondary: secondaryLine(entry), active: entry.surfaceId === activeSurfaceId, status: statusFor(entry), + // `DirectoryEntry.ringing` is a boolean union with no per-ring edge, so a + // remote bell rings once on mount and then holds (`docs/specs/alert.md` -> + // Pane Header). Carrying the count on the wire is what would fix it. + ringSeq: 0, todo: entry.hasTODO, })); } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5af80554f..1480b1886 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,7 +2,7 @@ "AGENTS.md": 2800, "SECURITY.md": 200, "SELF_HOST.md": 6000, - "docs/specs/alert.md": 6350, + "docs/specs/alert.md": 6450, "docs/specs/auto-update.md": 1000, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index a8ad141ba..a6e803865 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -451,16 +451,7 @@ export function attachRouter( const removeAlertListener = alertManager.onStateChange((id, state) => { if (!ownedPtyIds.has(id)) return; - post({ - type: 'alert:state', - id, - status: state.status, - watchingEnabled: state.watchingEnabled, - todo: state.todo, - notification: state.notification, - attentionDismissedRing: state.attentionDismissedRing, - awaited: state.awaited, - } satisfies ExtensionMessage); + post({ type: 'alert:state', id, ...state } satisfies ExtensionMessage); notifyUnion(); }); @@ -768,16 +759,7 @@ export function attachRouter( for (const [id] of reconnectable) { const alertState = alertManager.getState(id); log.info(`[alert-reconnect] ${id}: sending ${alertState.status} (todo=${alertState.todo})`); - post({ - type: 'alert:state', - id, - status: alertState.status, - watchingEnabled: alertState.watchingEnabled, - todo: alertState.todo, - notification: alertState.notification, - attentionDismissedRing: alertState.attentionDismissedRing, - awaited: alertState.awaited, - } satisfies ExtensionMessage); + post({ type: 'alert:state', id, ...alertState } satisfies ExtensionMessage); } break; } diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index a72548a08..b28499875 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -9,7 +9,7 @@ import type { AlertSettings } from '../../lib/src/lib/alert-settings'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { TerminalColors } from '../../lib/src/lib/terminal-protocol'; import type { DorControlCancelPayload, DorControlRequestPayload, DorControlResponsePayload } from '../../dor/src/protocol'; -import type { AgentBrowserStreamStatusResult, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; +import type { AgentBrowserStreamStatusResult, AlertStateDetail, IframeProxyResult, OpenPort } from '../../lib/src/lib/platform/types'; import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; import type { RemoteHostCommand, RemoteHostResult } from '../../lib/src/host/remote/service-protocol'; @@ -112,17 +112,9 @@ export type ExtensionMessage = | { type: 'dormouse:flushSessionSave'; requestId: string } | ({ type: 'dor:controlRequest' } & DorControlRequestPayload) | ({ type: 'dor:controlCancel' } & DorControlCancelPayload) - // Alert state updates - | { - type: 'alert:state'; - id: string; - status: SessionStatus; - watchingEnabled: boolean; - todo: TodoState; - notification: ActivityNotification | null; - attentionDismissedRing: boolean; - awaited: boolean; - } + // Alert state updates. The whole `AlertState` crosses as one piece, so a new + // alert field needs no edit here — the other three adapters already spread it. + | ({ type: 'alert:state' } & AlertStateDetail) | { type: 'alert:awaitResult'; requestId: string; outcome: AwaitOutcome } | { type: 'alert:watchedCommands'; names: string[] } | { type: 'alert:settings'; settings: AlertSettings }; From f194321cba7cc2aa1415d9dcc5384d2fcbf53c16 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 21:54:39 -0700 Subject: [PATCH 4/8] fix: preserve primed alert ring sequence --- lib/src/lib/terminal-lifecycle.ts | 1 + lib/src/lib/terminal-registry.alert.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 35e8c3486..0b09dbacc 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -360,6 +360,7 @@ function setupTerminalEntry(id: string, options: { shell?: string; untouched?: b const primed = consumePrimedActivity(id); if (primed) { if (primed.status !== undefined) entry.alertStatus = primed.status; + if (primed.ringSeq !== undefined) entry.ringSeq = primed.ringSeq; if (primed.watchingEnabled !== undefined) entry.watchingEnabled = primed.watchingEnabled; if (primed.todo !== undefined) entry.todo = primed.todo; if (primed.notification !== undefined) entry.notification = primed.notification; diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 47221a568..116602dd4 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -118,6 +118,7 @@ import { isUntouched, markSessionAttention, markSessionTodo, + primeActivity, resumeTerminal, restoreTerminal, setPendingShellOpts, @@ -310,6 +311,15 @@ describe('terminal-registry alert behavior', () => { expect(isUntouched(id)).toBe(true); }); + it('carries a primed ring counter into the terminal entry', () => { + const id = 'primed-ring-seq'; + primeActivity(id, { status: 'ALERT_RINGING', ringSeq: 7 }); + + createSession(id); + + expect(getActivity(id).ringSeq).toBe(7); + }); + /** * The receiver keeps no deregistration bookkeeping: every handler it installs * is a stable module-level function and adapters hold handlers in a `Set`, so From 90cd7accc91939eb96c49c59ef7f881d4c74e4e3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 21:56:02 -0700 Subject: [PATCH 5/8] docs: define alert bell remount replay --- docs/specs/alert.md | 2 +- docs/specs/alert.rationale.md | 2 ++ lib/src/components/AlertBell.test.tsx | 11 +++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 871aea62c..24604a8b6 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -333,7 +333,7 @@ Where it surfaces is host-specific: The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. -Bell rotation is a pure function of public status; its motion is not. **Ring the bell for four 800ms cycles per latched track, then hold it at 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **A track latching behind an already-latched one replays the burst; a track already ringing does not** — that is enrichment of one summons, not a new one. Both are keyed on `AlertState.ringSeq`, a per-Session count of latches, read only for change and compared by `alertStatesEqual` (rationale; pinned by `counts a second track ringing behind an already-latched one` and `does not count a track that is already ringing` in `lib/src/lib/alert-manager.test.ts`, `replaces the icon when the ring counter advances` in `lib/src/components/AlertBell.test.tsx`). **Remote Clients have no counter** — `DirectoryEntry.ringing` is an edgeless boolean, so a Pocket bell rings on mount and then holds. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. +Bell rotation follows public status; motion follows latch edges. **When a track latches, ring each mounted bell for four 800ms cycles, then hold 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **A newly mounted ringing bell may replay once without advancing `ringSeq`** (test: `replays the finite burst when a ringing presentation remounts` in `lib/src/components/AlertBell.test.tsx`; rationale). **A newly latched track replays the burst; further reports on that track only enrich its summons.** `AlertState.ringSeq` counts per-Session latches and is compared by `alertStatesEqual` (tests: `counts a second track ringing behind an already-latched one` and `does not count a track that is already ringing` in `lib/src/lib/alert-manager.test.ts`, `replaces the icon when the ring counter advances` in `lib/src/components/AlertBell.test.tsx`; rationale). **Remote Clients have no counter:** `DirectoryEntry.ringing` is an edgeless boolean, so Pocket rings on mount and holds. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. Bell interactions — one transition table, in `dismissOrToggleAlert`: diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 11cff475b..aa78e01bd 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -88,6 +88,8 @@ **Why a counter, not the status.** Bounding the burst turned a continuous cue into an edge-triggered one, and the public status has no such edge: `hasActiveRing` ORs three independently latching tracks, so a second alert behind a latched one leaves `ALERT_RINGING` in place. `notification` is no better — `applyCommandExitRinging` deliberately preserves a richer protocol notification. With both unchanged, `alertStatesEqual` also judged the two states equal and never emitted, so the renderer could not have reacted even had it wanted to. `ringSeq` is the smallest thing that changes exactly once per latch. +**Why a presentation mount may replay.** Minimizing and reattaching move the visible cue between a Pane and a Door. Replaying once makes the cue legible in its new location without carrying the CSS animation clock through Activity state; the finite burst still expires without further input. + **Why latches and not notifications.** Counting every ring rule instead would have made the counter unbounded: past the first latch `deferOrDeliverNotification` stops deferring, so a Session bell-ing in a loop emits one host→webview update per PTY chunk, each one restarting a 3.2s burst that then never finishes — the always-running animation the finite burst exists to remove. Counting latches bounds it at three per Session per dismissal and matches the model that function already states: an existing ring means enrichment, not a fresh summons. A timestamp floor would bound it too, but it would put the CSS duration in the manager. **Why `cfg.alert.ringingPaused` suppresses the burst.** It is the Chromatic freeze that pins the bell; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 3.2 seconds. diff --git a/lib/src/components/AlertBell.test.tsx b/lib/src/components/AlertBell.test.tsx index 72b87a644..1b21af74c 100644 --- a/lib/src/components/AlertBell.test.tsx +++ b/lib/src/components/AlertBell.test.tsx @@ -44,3 +44,14 @@ it('keeps the icon across a re-render at the same ring counter', async () => { await act(async () => root.render()); expect(bell()).toBe(first); }); + +it('replays the finite burst when a ringing presentation remounts', async () => { + await act(async () => root.render()); + const first = bell(); + + await act(async () => root.render(null)); + await act(async () => root.render()); + + expect(bell()).not.toBe(first); + expect(bell()?.getAttribute('class')).toContain('animate-bell-ring'); +}); From 495185d7399cd85a1d74c9f2ee2e953625d6bc4d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 21:56:16 -0700 Subject: [PATCH 6/8] docs: state alert latch replay bound precisely --- docs/specs/alert.rationale.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index aa78e01bd..67d0d89ad 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -90,7 +90,7 @@ **Why a presentation mount may replay.** Minimizing and reattaching move the visible cue between a Pane and a Door. Replaying once makes the cue legible in its new location without carrying the CSS animation clock through Activity state; the finite burst still expires without further input. -**Why latches and not notifications.** Counting every ring rule instead would have made the counter unbounded: past the first latch `deferOrDeliverNotification` stops deferring, so a Session bell-ing in a loop emits one host→webview update per PTY chunk, each one restarting a 3.2s burst that then never finishes — the always-running animation the finite burst exists to remove. Counting latches bounds it at three per Session per dismissal and matches the model that function already states: an existing ring means enrichment, not a fresh summons. A timestamp floor would bound it too, but it would put the CSS duration in the manager. +**Why latches and not notifications.** Counting every ring rule instead would let a Session bell-ing in a loop emit one host→webview update per PTY chunk, each restarting a 3.2s burst that never finishes — the always-running animation the finite burst exists to remove. A latch advances the counter at most once while that track remains latched; after release, relatching is a fresh summons and may replay. That matches the model `deferOrDeliverNotification` already states: an existing ring is enrichment, not a fresh summons. A timestamp floor would bound notifications too, but it would put the CSS duration in the manager. **Why `cfg.alert.ringingPaused` suppresses the burst.** It is the Chromatic freeze that pins the bell; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 3.2 seconds. From cb69556e5bed758d7016b64f6f48bb7c634f0731 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 21:58:12 -0700 Subject: [PATCH 7/8] test: complete alert ring sequence migration --- lib/src/components/Door.test.tsx | 6 +++--- lib/src/components/MobileWall.test.tsx | 4 ++-- lib/src/lib/workspace-union.test.ts | 9 ++++++++- lib/src/stories/Door.stories.tsx | 1 + vscode-ext/src/message-types.ts | 3 --- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/lib/src/components/Door.test.tsx b/lib/src/components/Door.test.tsx index b85838a8e..080d8d8af 100644 --- a/lib/src/components/Door.test.tsx +++ b/lib/src/components/Door.test.tsx @@ -25,7 +25,7 @@ afterEach(() => { describe('Door spoken-alarm state', () => { it('inverts and animates the whole Door while its Session is speaking', () => { act(() => root.render( - , + , )); const door = container.querySelector('[data-alert-speech-state="speaking"]'); @@ -38,7 +38,7 @@ describe('Door spoken-alarm state', () => { it('marks SPOKEN with a static inset ring rather than motion', () => { act(() => root.render( - , + , )); const door = container.querySelector('[data-alert-speech-state="spoken"]'); @@ -55,7 +55,7 @@ describe('Door spoken-alarm state', () => { */ it('keeps the bell and TODO pill visible while SPOKEN persists', () => { act(() => root.render( - , + , )); const door = container.querySelector('[data-alert-speech-state="spoken"]'); diff --git a/lib/src/components/MobileWall.test.tsx b/lib/src/components/MobileWall.test.tsx index a5a92ce85..214f4266e 100644 --- a/lib/src/components/MobileWall.test.tsx +++ b/lib/src/components/MobileWall.test.tsx @@ -24,7 +24,7 @@ const registry = vi.hoisted(() => ({ vi.mock('../lib/terminal-registry', () => ({ ...registry, - DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', todo: false }, + DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', ringSeq: 0, todo: false }, })); vi.mock('./TerminalPane', () => ({ @@ -57,7 +57,7 @@ function renderWall(showKillButton?: boolean) { root.render( diff --git a/lib/src/lib/workspace-union.test.ts b/lib/src/lib/workspace-union.test.ts index 92a9d1d92..2577548f7 100644 --- a/lib/src/lib/workspace-union.test.ts +++ b/lib/src/lib/workspace-union.test.ts @@ -3,7 +3,14 @@ import { computeWorkspaceUnion, EMPTY_WORKSPACE_UNION } from './workspace-union' import type { ActivityState } from './session-activity-store'; function activity(entries: Record>): Map { - const base: ActivityState = { status: 'WATCHING_DISABLED', watchingEnabled: false, todo: false, notification: null }; + const base: ActivityState = { + status: 'WATCHING_DISABLED', + watchingEnabled: false, + todo: false, + notification: null, + awaited: false, + ringSeq: 0, + }; return new Map(Object.entries(entries).map(([id, partial]) => [id, { ...base, ...partial }])); } diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index a1a505400..8f4b4b6e5 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -27,6 +27,7 @@ const meta: Meta = { args: { title: 'build-server', status: 'WATCHING_DISABLED', + ringSeq: 0, todo: false, width: 260, reducedMotion: false, diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index b28499875..b5a54053c 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,9 +1,6 @@ import type { - ActivityNotification, AwaitOutcome, AwaitUntil, - SessionStatus, - TodoState, } from '../../lib/src/lib/alert-manager'; import type { AlertSettings } from '../../lib/src/lib/alert-settings'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; From 8e9f39d5c7642620bdf56eb8b95f34bd88dd2305 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 3 Sep 2026 22:12:44 -0700 Subject: [PATCH 8/8] fix: centralize alert persistence projection --- docs/specs/vscode.md | 2 +- vscode-ext/src/session-state.ts | 12 ++-- vscode-ext/test/session-state.test.ts | 92 +++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 vscode-ext/test/session-state.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index bbdc57f1e..82e711d93 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -18,7 +18,7 @@ The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs - **Alert state is global.** One module-level `AlertManager` in `message-router.ts` is shared across all routers, survives router disposal, and is fed by PTY data regardless of webview visibility. - **WATCHING rules are host-authoritative.** The first webview after extension-host startup seeds the shared host rule set and **no later webview may replace it**. - **Never let a resuming router steal another webview's PTYs.** Each router tracks its PTYs in `ownedPtyIds`; a module-level `globalOwnedPtyIds` set enforces it. -- **Every save path must merge current alert states.** The frontend periodic save (`onSaveState`) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) both call `mergeAlertStates` — missing it reverts alert state on restore. +- **Every save path must merge current alert states through the shared persistence projection.** The frontend periodic save (`onSaveState`) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) both narrow alerts with `toPersistedAlertState`; missing the merge reverts alert state on restore, while passing live state through persists transient fields. - **retainContextWhenHidden.** Set on both `WebviewPanel` and `WebviewView` so xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without a resume. - **Two save sources must produce consistent state**: the frontend's periodic `dormouse:saveState` and the backend's deactivate flush-then-refresh. - **Every host → webview send carries the message token**, and **never add a `message` listener that skips `isHostMessage`**. diff --git a/vscode-ext/src/session-state.ts b/vscode-ext/src/session-state.ts index 435e8ee9a..ac41cb4c9 100644 --- a/vscode-ext/src/session-state.ts +++ b/vscode-ext/src/session-state.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as ptyManager from './pty-manager'; import type { AlertState } from '../../lib/src/lib/alert-manager'; -import { browserPersistedPane, readPersistedSession, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; +import { browserPersistedPane, readPersistedSession, toPersistedAlertState, type PersistedAlertState, type PersistedPane, type PersistedSession } from '../../lib/src/lib/session-types'; import { detectResumeCommand } from '../../lib/src/lib/resume-patterns'; import { stripTerminalControls } from '../../lib/src/lib/terminal-controls'; import { log } from './log'; @@ -20,12 +20,8 @@ export function saveSessionState(context: vscode.ExtensionContext, state: unknow } function toPersistedAlert(alert: AlertState | undefined, fallback: PersistedAlertState | null | undefined): PersistedAlertState | null { - if (!alert) return fallback ?? null; - return { - status: alert.status, - todo: alert.todo, - notification: alert.notification, - }; + const current = alert ?? fallback; + return current ? toPersistedAlertState(current) : null; } /** @@ -64,7 +60,7 @@ export async function refreshSavedSessionStateFromPtys( saved.panes.map(async (pane) => { if (pane.surfaceType === 'browser') { log.info(`[session] ${pane.id}: browser surface, skipping PTY refresh`); - return browserPersistedPane(pane, pane.alert ?? null); + return browserPersistedPane(pane, toPersistedAlert(undefined, pane.alert)); } const alert = toPersistedAlert(alertStates?.get(pane.id), pane.alert); diff --git a/vscode-ext/test/session-state.test.ts b/vscode-ext/test/session-state.test.ts new file mode 100644 index 000000000..57a976075 --- /dev/null +++ b/vscode-ext/test/session-state.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_ALERT_STATE, type AlertState } from '../../lib/src/lib/alert-manager'; +import type { PersistedSession } from '../../lib/src/lib/session-types'; + +const ptyManager = vi.hoisted(() => ({ + getBufferedPtys: vi.fn(), + getCwd: vi.fn(), +})); + +vi.mock('../src/pty-manager', () => ptyManager); + +import { mergeAlertStates, refreshSavedSessionStateFromPtys } from '../src/session-state'; + +const liveAlert = (overrides: Partial = {}): AlertState => ({ + ...DEFAULT_ALERT_STATE, + ...overrides, +}); + +function contextWithState(initial: unknown) { + let state = initial; + return { + context: { + workspaceState: { + get: () => state, + update: (_key: string, next: unknown) => { + state = next; + return Promise.resolve(); + }, + }, + } as never, + read: () => state, + }; +} + +describe('VS Code session alert persistence', () => { + beforeEach(() => { + vi.clearAllMocks(); + ptyManager.getBufferedPtys.mockReturnValue(new Map()); + ptyManager.getCwd.mockResolvedValue(null); + }); + + it('projects live alert state before a periodic save', () => { + const session: PersistedSession = { + version: 3, + panes: [{ id: 'terminal-a', title: 'Terminal A', cwd: null, untouched: false }], + }; + + const merged = mergeAlertStates(session, new Map([ + ['terminal-a', liveAlert({ + status: 'ALERT_RINGING', + watchingEnabled: true, + todo: true, + attentionDismissedRing: true, + awaited: true, + ringSeq: 7, + })], + ])) as PersistedSession; + + expect(merged.panes[0].alert).toEqual({ + status: 'ALERT_RINGING', + todo: true, + notification: null, + }); + }); + + it('strips transient alert fields from browser and terminal fallbacks during host refresh', async () => { + const staleAlert = { + status: 'NOTHING_TO_SHOW' as const, + todo: true, + notification: null, + watchingEnabled: true, + awaited: true, + ringSeq: 4, + }; + const store = contextWithState({ + version: 3, + panes: [ + { id: 'browser-a', title: 'Browser A', cwd: null, untouched: false, surfaceType: 'browser', alert: staleAlert }, + { id: 'terminal-a', title: 'Terminal A', cwd: '/saved', untouched: false, alert: staleAlert }, + ], + }); + + await refreshSavedSessionStateFromPtys(store.context); + + const saved = store.read() as PersistedSession; + expect(saved.panes.map((pane) => pane.alert)).toEqual([ + { status: 'NOTHING_TO_SHOW', todo: true, notification: null }, + { status: 'NOTHING_TO_SHOW', todo: true, notification: null }, + ]); + }); +});