diff --git a/.impeccable/design.json b/.impeccable/design.json index e1e2c7a40..b5908c51f 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -120,7 +120,7 @@ }, "alarm": { "role": "primary", - "displayName": "Alarm / Bell (terminal ANSI yellow, runtime-rotated per surface)", + "displayName": "Alarm (terminal ANSI yellow, runtime-rotated per surface)", "canonical": "var(--vscode-terminal-ansiYellow)", "tonalRamp": ["#4a3818", "#6e5424", "#917030", "#b58c45", "#d3a85c", "#f9d181", "#fbdda3", "#fee9b3"] }, @@ -155,13 +155,13 @@ "motion": [ { "name": "ease-quart-out", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Default for pane spawn/kill and any layout-adjacent transition. Exponential ease-out, no bounce." }, { "name": "spring-overshoot", "value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "purpose": "Reserved for celebratory state-resolution: TODO check pop-in, kill-confirm letter flash, copy-confirm scale. Used only at 220–500ms; never on layout." }, - { "name": "ease-in-out-bell", "value": "ease-in-out", "purpose": "Bell ring continuous animation (800ms, infinite, alternating ±45deg rotation)." }, + { "name": "ease-in-out-alarm", "value": "ease-in-out", "purpose": "Alarm pulse (650ms, one bounded burst on arrival, infinite while speaking)." }, { "name": "ease-out-shake", "value": "ease-out", "purpose": "Kill-overlay shake-x (400ms, x-axis only)." }, { "name": "duration-pane", "value": "440ms", "purpose": "Pane spawn and kill choreography." }, { "name": "duration-flourish", "value": "500ms", "purpose": "TODO pill dismiss sequence." }, { "name": "duration-confirm", "value": "220ms", "purpose": "Kill-overlay confirm letter flash." }, { "name": "duration-copy-flash", "value": "260ms", "purpose": "Selection popup copy-confirm scale flash." }, - { "name": "duration-bell-ring", "value": "800ms", "purpose": "One full ring cycle." }, + { "name": "duration-alarm-pulse","value": "650ms", "purpose": "One full alarm pulse cycle." }, { "name": "duration-shake", "value": "400ms", "purpose": "Kill-cancel shake-x." } ], "breakpoints": [ @@ -174,8 +174,8 @@ "kind": "custom", "refersTo": "door", "description": "Pane-header indicator on the baseboard. Top corners rounded, square bottom; the signature component of the system.", - "html": "", - "css": ".ds-door { display: flex; align-items: center; gap: 8px; height: 24px; min-width: 68px; max-width: 220px; padding: 0 10px; overflow: hidden; border: none; border-radius: 8px 8px 0 0; background: var(--color-door-bg, #5e452e); color: var(--color-door-fg, #d3af86); font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; line-height: 1rem; font-weight: 500; cursor: pointer; } .ds-door__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .ds-door__badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; } .ds-door__bell { color: var(--vscode-terminal-ansiYellow, #f9d181); }" + "html": "", + "css": ".ds-door { display: flex; align-items: center; gap: 8px; height: 24px; min-width: 68px; max-width: 220px; padding: 0 10px; overflow: hidden; border: none; border-radius: 8px 8px 0 0; background: var(--color-door-bg, #5e452e); color: var(--color-door-fg, #d3af86); font-family: var(--vscode-editor-font-family, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 0.75rem; line-height: 1rem; font-weight: 500; cursor: pointer; } .ds-door__title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; } .ds-door__badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }" }, { "name": "Pane Header (Active)", diff --git a/DESIGN.md b/DESIGN.md index 5f6a99989..b7d8c4d8a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -94,7 +94,7 @@ components: Dormouse is a tenant in someone else's house. The house is VSCode. The user picked the furniture (their theme), the lighting (their mode), the typography (their editor font). Dormouse moves in, multiplies what the user can do with their terminals, and leaves the decor alone. The interface should be indistinguishable from a built-in panel: not because it imitates VSCode, but because it inherits from VSCode. Every color, every font, every surface is a passthrough of the host's tokens. -The system is intentionally minimal and bg-only. Chrome recedes; terminals are the content. Hierarchy is conveyed through background shifts between `header-active-bg` and `header-inactive-bg`, not through borders, shadows, or accent stripes. Status is conveyed through shape and position (a bell icon, a door's alert state) and through the active terminal palette's own ANSI red/green/yellow, not through a separate design-system palette. +The system is intentionally minimal and bg-only. Chrome recedes; terminals are the content. Hierarchy is conveyed through background shifts between `header-active-bg` and `header-inactive-bg`, not through borders, shadows, or accent stripes. Status is conveyed through shape and position (a door's alert state, a ringing Pane's outline) and through the active terminal palette's own ANSI red/green/yellow, not through a separate design-system palette. The system explicitly rejects: rounded SaaS cards, gradient accents, hacker-aesthetic green-on-black, "Slack-style" Electron chrome bloat, decorative animations, and any token that hardcodes a color. If a user installs a high-contrast theme, the chrome can look flatter than usual: that is accepted, not "fixed" with overrides. @@ -137,7 +137,7 @@ This system has no "primary" accent in the brand sense. The closest analogue is - **Terminal Background / Foreground** (`var(--vscode-terminal-background)` / `var(--vscode-terminal-foreground)`): the terminal content surface and xterm default text. Orthogonal to the chrome. - **Error** (`var(--vscode-terminal-ansiRed)`): destructive actions and kill-confirm letter flash. - **Success** (`var(--vscode-terminal-ansiGreen)`): TODO check, theme-store install confirm. -- **Alarm** (`var(--vscode-terminal-ansiYellow)` baseline; runtime-overridden): alert tint. `computeDynamicPalette()` replaces each `--color-alarm-vs-*` token with plain white or black by the OKLab lightness of its background (active header, inactive header, Door, or terminal body), so ringing bells and the whole-Pane alarm treatment stay maximally legible on any surface. +- **Alarm** (`var(--vscode-terminal-ansiYellow)` baseline; runtime-overridden): alert tint. `computeDynamicPalette()` replaces each `--color-alarm-vs-*` token with plain white or black by the OKLab lightness of its background (active header, inactive header, Door, or terminal body), so every alarm inset and the whole-Pane alarm treatment stay maximally legible on any surface. ### Fixed Exceptions Every literal color the Host-Theme-Only Rule below permits, in full. Each is here because the surface it paints is not read as part of the theme; a literal anywhere else is a bug. @@ -198,18 +198,18 @@ Doors are the pane-header indicators on the baseboard. The most signature compon - **Surface:** `bg-door-bg` + `text-door-fg`. These resolve at runtime via `computeDynamicPalette()` and may match either the inactive-header palette or the terminal palette, whichever has stronger separation from `app-bg`. - **Dimensions:** `h-6` (24px), `min-w-[68px]`, `max-w-[220px]`; the title button pads `pl-2.5` (10px), `gap-2` between its glyph, title, and badges, ending `pr-2.5` alone or `pr-1` when the notepad button follows it. The notepad button carries the trailing inset itself (`pl-0.5 pr-2`). - **Type:** `text-sm font-medium font-mono`. -- **Content:** leading browser-display icon cluster on a browser Surface (`size={12}` each, `gap-0.5` — a wide robot plus the presentation glyph, or the presentation glyph alone for `iframe`; named in the Door's accessible name, `docs/specs/dor-browser.md` → Browser Chrome); truncated title; optional TODO pill (`text-xs font-semibold tracking-[0.08em]`, success-tinted when flourishing); optional bell icon (`size={11}`, `weight="fill"`), `text-alarm-vs-door` when ringing; trailing notepad button (`size={12}`, `weight="fill"`) when the minimized Surface holds notes. -- **Alarm:** one 2px inset overlay (`--color-alarm-vs-door`) draws the edge for both the unlabelled ring, where it flashes once on arrival, and `SPOKEN`, which persists until the ring is attended and adds a speaker icon *beside* the TODO pill and bell instead of evicting them. `SPEAKING` instead inverts and pulses the whole Door and takes the badge slot for its speaker-plus-label, for one utterance. Both speech states carry a speaker icon (shape, not color); all three name the state in the accessible name. The row inventory is `docs/specs/layout.md` → Alarm overlay. +- **Content:** leading browser-display icon cluster on a browser Surface (`size={12}` each, `gap-0.5` — a wide robot plus the presentation glyph, or the presentation glyph alone for `iframe`; named in the Door's accessible name, `docs/specs/dor-browser.md` → Browser Chrome); truncated title; optional TODO pill (`text-xs font-semibold tracking-[0.08em]`, success-tinted when flourishing); trailing notepad button (`size={12}`, `weight="fill"`) when the minimized Surface holds notes. +- **Alarm:** one 2px inset overlay (`--color-alarm-vs-door`) draws the edge for both the unlabelled ring, where it flashes once on arrival, and `SPOKEN`, which persists until the ring is attended and adds a speaker icon *beside* the TODO pill instead of evicting it. `SPEAKING` instead inverts and pulses the whole Door and takes the badge slot for its speaker-plus-label, for one utterance. Both speech states carry a speaker icon (shape, not color); all three name the state in the accessible name. The row inventory is `docs/specs/layout.md` → Alarm overlay. - **Hover/Focus:** no decorative hover on the door itself; the focus state is conveyed by the parent pane's selection ring, not by a per-door treatment. The door is a labelled `role="group"` wrapper holding one or two buttons rather than one button — the title button reattaches, the notepad button opens the popover and does not (`docs/specs/notepad.md` → Notepad UI) — and only the notepad button takes the standard `hover:bg-current/10` wash. ### Buttons #### Header Action Button -The icon-and-tooltip button used inside pane headers (kill, alert toggle, todo, etc.). +The icon-and-tooltip button used inside pane headers (kill, minimize, notepad, etc.). - **Shape:** `rounded` (4px) when icon-only, also `rounded` for labeled variants. - **Color:** `text-inherit` — inherits the header's foreground, so it tints with the active/inactive header palette. - **Hover:** `hover:bg-current/10` — a 10%-opacity wash of the current text color. Theme-agnostic, works light or dark. -- **Tooltip:** rendered through a portal as a `PopupButtonRow` 8px below the button, with `text-sm` primary line and an optional muted detail line. Keybindings inside the tooltip auto-render as `[bracketed]` shortcuts. +- **Tooltip:** rendered through a portal as a `PopupButtonRow` 8px below the button, one `text-sm` line right-aligned to its edge. Keybindings inside the tooltip auto-render as `[bracketed]` shortcuts. #### Popup Button (`popupButton`) The flat segments inside a `PopupButtonRow` — the row owns the border, background, shadow, and `text-sm`, so a segment contributes only padding and state. Every segment currently inherits the row's foreground; these rows offer rather than ask, so none of them carries an emphasized action. diff --git a/TUTORIAL_MOCKUP.md b/TUTORIAL_MOCKUP.md index da0193753..acfe56e52 100644 --- a/TUTORIAL_MOCKUP.md +++ b/TUTORIAL_MOCKUP.md @@ -28,7 +28,7 @@ Four beats. Beat 2 is the section; the rest is scaffolding around it. 1. **A rule, not a switch** — alerts belong to a command, not a tab. 2. **Attention** — three runs of the same task, three outcomes. -3. **Two more ways the bell rings** — neither needs a rule. +3. **Two more ways a pane rings** — neither needs a rule. 4. **Nothing gets lost** — the TODO receipt. ## Beat 1 — A rule, not a switch @@ -64,13 +64,13 @@ Reference table (candidate for the section prose or the dialog, not a step): | typing into it | hovering it | | clicking a door, or `Enter` on a door | a door sitting in the baseboard | -## Beat 3 — Two more ways the bell rings +## Beat 3 — Two more ways a pane rings Both are rule-free, and both obey the same attention rule as Beat 2. | id | title | hint | |---|---|---| -| `al-notif` | A program can ring the bell itself | Press `n` for a fake build that sends a notification. No rule needed — any program that asks for attention gets it, and its message rides along on the TODO tag. | +| `al-notif` | A program can ring on its own | Press `n` for a fake build that sends a notification. No rule needed — any program that asks for attention gets it, and its message rides along on the TODO tag. | | `al-cmd-exit` | A long command finished while you were away | Dormouse noticed you watched this command start, then left. Anything that ran longer than 15 seconds and finished after you walked away is worth a word. | ## Beat 4 — Nothing gets lost @@ -89,16 +89,14 @@ al-spreads One rule, every pane running it al-quiet-attended Sit in the pane — it finishes silently al-ring-switch Look away — the same task rings al-ring-idle Stay put but idle — it rings anyway -al-notif A program can ring the bell itself +al-notif A program can ring on its own al-cmd-exit A long command finished while you were away al-todo-auto A dismissed ring leaves a TODO al-todo-clear Press `Enter` in the pane to clear it al-todo-manual Add a TODO by hand ``` -Changes from today's nine: `al-busy` ("the bell tilts while the command works") -is **cut** as a step — it is feedback, not a concept, and it is what the user is -already staring at during Beat 2. `al-ring` is **split into three** so each +Changes from today's eight: `al-ring` is **split into three** so each attention mechanism gets its own line. Everything else keeps its id. ## Two things the mockup has to solve diff --git a/docs/specs/alert.md b/docs/specs/alert.md index 3915cd846..2bff86d7c 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -161,7 +161,7 @@ Rules: - **Removing a rule silences its WATCHING rings**, even after the command has exited; other dismissal paths follow Clearing And TODO. A command merely ending never clears the ring. - **The rule set is app-global and persisted** (`dormouse:watched-commands`), starting empty, so WATCHING is off everywhere until the user turns it on. **The host is authoritative wherever one serves several webviews** — the VS Code extension host, and the standalone sidecar — so a stale webview can neither replace unrelated rules nor keep reporting an obsolete list: the first webview's persisted copy is taken as the seed, an edit is a delta, and the host broadcasts its canonical snapshot back. The seed/mutation/broadcast wire contract is `docs/specs/transport.md`. -**Limitation:** WATCHING needs the shell to report command boundaries (`OSC 633` / `OSC 133`). Shells without integration (`docs/specs/terminal-escapes.md`) never report a command name, so WATCHING never engages and the bell reports "nothing is running". Terminal reports still work; command-exit alerting also requires semantic command boundaries. **Never route the keystroke fallback in `docs/specs/terminal-state.md` into the `AlertManager`** (rationale). +**Limitation:** WATCHING needs the shell to report command boundaries (`OSC 633` / `OSC 133`). Shells without integration (`docs/specs/terminal-escapes.md`) never report a command name, so WATCHING never engages and the terminal context reports `No command running`. Terminal reports still work; command-exit alerting also requires semantic command boundaries. **Never route the keystroke fallback in `docs/specs/terminal-state.md` into the `AlertManager`** (rationale). | State | Meaning | |---|---| @@ -350,36 +350,37 @@ Source of truth: `lib/src/components/SettingsDialog.tsx`; `WorkspaceAlarmSetting | `ringing` | Any member Session is `ALERT_RINGING`. | | `todo` | Any member Surface has `todo === true`. | | `count` | Number of members ringing or TODO; each Surface counts once. | +| `ringingSince` | The earliest ringing member's episode start, else `null`. | -**Must keep presentation generations outside the union.** The strip tracks each existing member’s `ringSeq`; an increase while ringing starts one finite Workspace cue, including while that Workspace is active. Adding/restoring/transferring a member seeds its counter silently; removal and Workspace switching never synthesize a cue. Returning to an inactive indicator resumes its original animation clock. Pinned by `lib/src/lib/workspace-ring-cues.test.ts`. +**Must key the hidden tab's arrival burst on `ringingSince`**, held only for that ringing interval, so no later member, attended member, or Workspace switch replays it (rationale). Pinned by `keeps one burst while a Workspace stays ringing` and `clocks the burst from the ring that began while the Workspace was visible` in `lib/src/components/WorkspaceStrip.test.tsx`. **Must keep the projection display-only:** it never enters the Activity machine or fires its own ring. A Surface with no activity entry contributes nothing. Callers **must include** minimized (`Doored`) Surfaces. **Must project every Workspace, active or not.** The Activity store spans the whole Window, so what scopes it to one Workspace is the membership each mounted Wall publishes — panes ∪ doors, on every layout commit. -Source of truth: `computeWorkspaceUnion` in `lib/src/lib/workspace-union.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `lib/src/lib/workspace-union.test.ts`; `WorkspaceRingCues` in `lib/src/lib/workspace-ring-cues.ts`. +Source of truth: `computeWorkspaceUnion` in `lib/src/lib/workspace-union.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `lib/src/lib/workspace-union.test.ts`. Where it surfaces is host-specific: - **VS Code** reflects the terminal portion onto native chrome — `docs/specs/vscode.md`, which also owns why browser-surface TODO stays webview-local. -- **Standalone** shows terminal rings/TODOs on panes and doors, and a browser Surface's `todo` on its own door. A **hidden** Workspace's tab additionally carries its union's TODO pill and bell, with `count` in the tab's accessible name; the visible Workspace's tab carries none, its panes and doors already saying it (`WorkspaceStrip` in `lib/src/components/WorkspaceStrip.tsx`). +- **Standalone** shows terminal rings/TODOs on panes and doors, and a browser Surface's `todo` on its own door. A **hidden** Workspace's tab additionally carries its union's TODO pill and, while ringing, the alarm inset, with `count` in the tab's accessible name; the visible Workspace's tab carries none, its panes and doors already saying it (`WorkspaceStrip` in `lib/src/components/WorkspaceStrip.tsx`). -**Must use `alarm-vs-header-inactive` for the hidden Workspace tab's bell**, matching its inactive-header background. +**Must use `alarm-vs-header-inactive` for the hidden Workspace tab's inset**, matching its inactive-header background. ## UI Contract ### Pane Header -The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and the terminal context opened by right-click or by the alert button. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. +The header shows a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and the terminal context opened by right-click or by `a`. **Never tint a ringing Session's header**: the Pane overlay already outlines it. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. -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. +`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`; rationale). -- **The alert button, and `a` on the selected Pane in command mode, dismiss a ringing Session and open the terminal context, whatever the status; they never edit a WATCHING rule.** The button names that action — `Dismiss alert` while ringing, `Alert settings` otherwise — not a toggle. +- **`a` on the selected Pane in command mode dismisses a ringing Session and opens the terminal context, whatever the status; it never edits a WATCHING rule.** - **A WATCHING rule is created only in the terminal context** ("Watch all `` commands"), which offers the row whenever a foreground command is running, and removed there or in Settings. Removing it anywhere drops it for every Session running that command. - Right-click always opens the context. Pressing `t` toggles TODO. -- **The mobile header's alert button only dismisses** — the mobile composition has no terminal context (`docs/specs/mobile-terminal-ui.md`). +- **The mobile composition dismisses by attention alone** — a tap or a keystroke — and wears its ring as the alarm inset, never an icon (`docs/specs/mobile-terminal-ui.md`). A Client cannot dismiss yet (`docs/specs/remote-api.md` → Future). -**Must keep context alert controls scoped to the source**, with TODO, running-command WATCHING, and notification detail. Settings owns the global watched-command list. **Must suppress helper alerting until promotion, including after exit**, covering bell/notification protocols, watched commands, TODO, speech, push, and attention projections; semantic command/readiness state remains active. Promotion starts ordinary alert behavior without replaying suppressed events. +**Must keep context alert controls scoped to the source**, with TODO, running-command WATCHING, and notification detail. Settings owns the global watched-command list. **Must suppress helper alerting until promotion, including after exit**, covering BEL/notification protocols, watched commands, TODO, speech, push, and attention projections; semantic command/readiness state remains active. Promotion starts ordinary alert behavior without replaying suppressed events. Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `setHelper` in `lib/src/lib/alert-manager.ts`, which every host calls at helper spawn, listing, and promotion. @@ -387,17 +388,15 @@ The TODO pill always displays `TODO`; remote notification text belongs in previe **Must wear the alarm treatment on every ringing terminal Pane**, labelled only once the speech sink acts. **Must bound the unlabelled pulse to one finite burst per episode, never replayed by a remount; `SPEAKING` pulses for its utterance, `SPOKEN` never** (rationale). **`prefers-reduced-motion` keeps the strong static treatment and suppresses only the pulse**, as does `cfg.alert.ringingPaused` (rationale). The three rows, their layers, strengths, and sizing are inventoried by `docs/specs/layout.md` → Alarm overlay. -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`; `dismissSessionAlert` in `lib/src/lib/session-activity-store.ts`; `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `lib/src/components/TodoPillBody.tsx`; `AlertRingIndicator` in `lib/src/components/wall/AlertRingIndicator.tsx`; `alertRingRow` and `alertRingBurstProps` in `lib/src/components/alert-ring.ts`. +Source of truth: `latchRing` in `lib/src/lib/alert-manager.ts`; `dismissSessionAlert` in `lib/src/lib/session-activity-store.ts`; `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `lib/src/components/TodoPillBody.tsx`; `AlertRingIndicator` in `lib/src/components/wall/AlertRingIndicator.tsx`; `alertRingRow`, `useAlertRingBurst`, `AlertRingInset` in `lib/src/components/alert-ring.tsx`. ### Door A Door is display-only for alert state: -- show the bell only when `status !== 'WATCHING_DISABLED'` - show the TODO pill when `todo === true` -- use the same bell tilt/animation mapping as the Pane header - while ringing with no speech state, wear the ring `spoken` uses, unlabelled, named `needs attention` -- while its Session is `speaking`, replace the compact bell/TODO cluster with the explicit `SPEAKING` label and invert + pulse the whole Door — that state lasts one utterance. `spoken` persists until the ring is attended, so it keeps a static high-contrast inset and adds a speaker icon *alongside* the bell and TODO pill instead of replacing them; those are the baseboard's persistent signals and **must not go dark for an unbounded window** +- while its Session is `speaking`, replace the compact TODO cluster with the explicit `SPEAKING` label and invert + pulse the whole Door — that state lasts one utterance. `spoken` persists until the ring is attended, so it keeps a static high-contrast inset and adds a speaker icon *beside* the TODO pill instead of replacing it; those are the baseboard's persistent signals and **must not go dark for an unbounded window** - do not expose a Door-specific alert menu Click or `Enter` on a Door reattaches into passthrough and clears a ring; `d` reattaches in command mode and leaves the ring intact (Attention). diff --git a/docs/specs/alert.rationale.md b/docs/specs/alert.rationale.md index 88d357f99..84811fa1c 100644 --- a/docs/specs/alert.rationale.md +++ b/docs/specs/alert.rationale.md @@ -104,19 +104,14 @@ Guarding only completion leaves a stale `start` free to replace the active utter ## Pane Header -**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 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 `SPEAKING` may pulse unbounded and `SPOKEN` may not.** An utterance is seconds long and stops on its own, so the pulse it carries is self-bounding. `SPOKEN` persists until the ring is attended, so animating it would be exactly the per-Session animation with no end that bounding the burst exists to remove. - **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. +**Why `cfg.alert.ringingPaused` suppresses the pulse.** It is the Chromatic freeze that pins the alarm; even a bounded animation could otherwise snapshot at an arbitrary phase during its first 2.6 seconds. -**Why the unlabelled treatment pulses once per episode.** The whole-Pane treatment is a per-Session animation on a surface far larger than a bell, so an infinite pulse would pay the cost measured above and pay it worse; bounding it buys the entry cue and then stops. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it, the same trade the bell's mount replay makes in the other direction (its className carries no clock to start from). +**Why the unlabelled treatment pulses once per episode.** An infinite per-Session animation is expensive, and the whole-Pane treatment covers far more surface than the retired bell icon did. With four focused panes wearing an infinite animation, three minutes cost 6.89 MB of embedder memory, 1,127 style recalculations, and 3.99 seconds of renderer CPU; 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). A handful of cycles preserves the entry cue without leaving an animation running for the lifetime of an unattended alert. The episode — not a track latch — is the key because the episode is the summons the sinks already work from: a second track latching inside one enriches an alarm the user was already shown, and re-flashing the whole Pane for it would read as a new alarm. Running the burst off `episode.startedAt` rather than from mount makes the CSS clock a property of the episode, so minimize → reattach or a Workspace switch lands past an expired burst instead of replaying it. ## Text And Security @@ -128,4 +123,6 @@ A persisted reminder intentionally forgets rings and detector history. Reusing i ## Workspace union -The maximum child counter hides a new alert from a child with a smaller counter. Summing counters would turn adding or removing a member into a notification. Per-member observation keeps semantic union and presentation edges separate; retaining the cue's clock also prevents switching Workspaces from restarting an old burst (2026-09). +A Workspace-level counter was tried first and dropped: the maximum child counter hides a new alert from a child with a smaller counter, and summing counters turns adding or removing a member into a notification. The earliest ringing member's episode replaces it because a hidden tab shows one alarm however many members are behind it, and because switching Workspaces re-derives the union from scratch — an `episode.startedAt` survives that, while a locally tracked generation had to be retained alongside it (2026-09). + +"Only that interval" is load-bearing because the tab's memory of a ring is a cache the visible Workspace never refreshes. The retired `WorkspaceRingCues` observed the active Workspace too, so a ring that started and ended while its tab was visible could not leave anything behind; the union cache that replaced it can. Left in place, the stale `ringingSince` is carried forward into the next ring, whose `animationDelay` is then already past the burst's end — the tab wears a static edge for a summons that should have flashed (2026-09). diff --git a/docs/specs/layout.md b/docs/specs/layout.md index bfca45798..08ff474b8 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -52,17 +52,17 @@ A 30px header doubling as a drag handle: **a `pointerdown` past a 5px threshold **Must use browser chrome for a serving Tool, with a Terminal Context disclosure for its serving terminal.** Tool composition belongs to `docs/specs/dor-tool.md` → Lifecycle. -Elements left to right: derived label; alert bell; TODO pill (compact+); flexible gap; mouse-reporting override icon (compact+, only while the inside program requests mouse reporting); notepad icon (`docs/specs/notepad.md` → "Notepad UI"); split left/right, split top/bottom (full only); then the pane-action group: zoom/unzoom, minimize, kill (hover turns error-red). +Elements left to right: derived label; TODO pill (compact+); flexible gap; mouse-reporting override icon (compact+, only while the inside program requests mouse reporting); notepad icon (`docs/specs/notepad.md` → "Notepad UI"); split left/right, split top/bottom (full only); then the pane-action group: zoom/unzoom, minimize, kill (hover turns error-red). The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal-state.md` owns the priority chain and disambiguator. Layout renders it: primary truncates with ellipsis, secondary muted beside it, a failed last command appends an error-colored glyph. Click renames/pins; right-click — or `>` in command mode — opens the header context menu. #### Header context menu -**Must open the terminal context from terminal header, alert, body, and command-mode `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. +**Must open the terminal context from terminal header, body, and command-mode `a` and `>` entry points.** Browser-only Surfaces and Doors have no context. Tool context displays its primary terminal; `docs/specs/terminal-context.md` → Tool context owns that composition. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. **Must float the context inside its source Pane with a one-rem inset on every side**, overlapping the header, with a theme-derived edge and raised shadow. Render it in the Lath leaf's overlay slot, outside the body's clipping box, so it follows the leaf's layout without remounting the helper. Keep one context per Wall. Outside pointer press and explicit close dismiss it. No separate context heading or clipboard toolbar is shown. -**Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Alert-button activation uses the button center; command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. +**Must reveal the context from the opening pointer position, clamped to its bounds, over 320ms.** Command-mode `a` and `>` use the header's bottom-left; openings without a position use the context's top-left. Keep final layout dimensions throughout the reveal. Start helper creation, settings reads, and port scanning immediately on mount; fade mounted content, including detail dialogs, in over 140ms after 160ms. Reduced motion or disabled layout animation skips both animations and the delay. **Must contract dismissals toward the opening origin over 180ms, fading content over 100ms**, starting from the current reveal when interrupted. Make the closing context inert and pause helper polling immediately; release focus without waiting for removal. Reopening cancels pending removal. Reduced motion dismisses immediately; promotion, source removal, and replacement by another context retain their immediate lifecycle transitions. diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index f1dcb7811..07c867c35 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -39,9 +39,12 @@ Chrome rules: * The Touch row and its selector tray sit on `terminal-bg`; the Input row and the reserve area on `header-inactive-bg` / `header-inactive-fg` (rationale). * The mobile session header is a flush bar — **never** the desktop title corner - radius. Order: title, alert bell, secondary detail, TODO pill, minimize, kill - (suppressed by `showKillButton={false}`, as Pocket does). Both consumers wire - minimize to the Sessions reserve, not a desktop Door. + radius. Order: title, secondary detail, TODO pill, minimize, kill (suppressed + by `showKillButton={false}`, as Pocket does). Both consumers wire minimize to + the Sessions reserve, not a desktop Door. A ring shows as the alarm inset on + the bar and on its session-list row, and **a tap on the terminal attends the + Session**, which is how it is dismissed here (`docs/specs/alert.md` -> Pane + Header). * **Must install `useDynamicPalette` in `MobileTerminalUi`** for gesture tokens; it never mounts the desktop `Wall`. `docs/specs/theme.md` owns publication and the CSS baselines available before the effect runs. diff --git a/docs/specs/remote-api.md b/docs/specs/remote-api.md index 57ca3f7ba..c30bce2ca 100644 --- a/docs/specs/remote-api.md +++ b/docs/specs/remote-api.md @@ -360,7 +360,7 @@ The wire half, as new event names: { event: 'terminal.semantic'; data: TerminalSemanticEvent } ``` -`terminal.resize` lets an attached viewer show its own tether state instead of rendering garbled wrap until re-attach; `terminal.semantic` frees the attached pane's header from the coalesced `directory.snapshot` cadence. +`terminal.resize` lets an attached viewer show its own tether state instead of rendering garbled wrap until re-attach; `terminal.semantic` frees the attached pane's header from the coalesced `directory.snapshot` cadence. Attention rides the same stage — a `terminal.attend` on touch, without which a Client cannot put a ring out (`alertAttend` is inert today). ### 6. Graded grants and layout mutations diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 0e7806f93..de4684105 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -95,7 +95,7 @@ Source of truth: `cwdFromManualPath` in `lib/src/lib/terminal-state.ts`; `seedTe - `commandFinish` moves `currentCommand` to `lastCommand`, stores `event.finishedAt` (otherwise reducer time) and `exitCode`, snapshots the latest in-run OSC 0/2/9 title into `lastCommand.finalTerminalTitle` (titles older than `startedAt` or younger than `finishedAt` excluded), clears `currentCommand`, and sets `{ kind: "finished", exitCode }`. **With no `currentCommand` it only sets the activity**, never inventing a `lastCommand`. - `title` updates `title` and the per-source entry in `titleCandidates`. **Later OSC title events never erase earlier candidates from other sources.** -Command-line tokenizing is dialect-free: **`\` escapes exactly the set `shellEscapePosix` writes** (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`; both halves pinned by `terminal-state.test.ts`). **A leading `&` is PowerShell's call operator, never a POSIX background suffix**, and is dropped rather than read as a boundary. **An unquoted Windows path containing spaces stays split.** **A launcher suffix is not part of a program's name** — `npm.cmd` and `C:\tools\claude.exe` are `npm` and `claude` for the header, the WATCHING key, and the bell tooltip alike. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately. (rationale) +Command-line tokenizing is dialect-free: **`\` escapes exactly the set `shellEscapePosix` writes** (`POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts`; both halves pinned by `terminal-state.test.ts`). **A leading `&` is PowerShell's call operator, never a POSIX background suffix**, and is dropped rather than read as a boundary. **An unquoted Windows path containing spaces stays split.** **A launcher suffix is not part of a program's name** — `npm.cmd` and `C:\tools\claude.exe` are `npm` and `claude` for the header, the WATCHING key, and the terminal context alike. Accepted: `foo.bat` and `foo.exe` in one directory cannot be watched separately. (rationale) ### Keystroke fallback diff --git a/docs/specs/terminal-state.rationale.md b/docs/specs/terminal-state.rationale.md index 56fa36304..28d9340d2 100644 --- a/docs/specs/terminal-state.rationale.md +++ b/docs/specs/terminal-state.rationale.md @@ -18,7 +18,7 @@ Native path payloads are not URLs: decoding `%20` or trimming edge spaces change **Why an unquoted Windows path with spaces stays split.** Which token ends the program name is undecidable without asking the filesystem, and the tokenizer has no filesystem. -**What the launcher-suffix rule prevents.** PATHEXT gives one program several spellings (`npm`, `npm.cmd`, `npm.exe`); keying the header, WATCHING rule row, and bell tooltip on the suffixed name would split one program into two rules and let the three disagree about which is running. +**What the launcher-suffix rule prevents.** PATHEXT gives one program several spellings (`npm`, `npm.cmd`, `npm.exe`); keying the header, WATCHING rule row, and terminal context on the suffixed name would split one program into two rules and let the three disagree about which is running. ## Keystroke fallback diff --git a/docs/specs/theme.md b/docs/specs/theme.md index ae993643e..b966e93d0 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -104,9 +104,9 @@ the `body` copy sees what `applyTheme()` writes to `body.style` (rationale). `lib/src/lib/themes/consumed-keys.test.ts` enforces it **per file**, because a host may import either layer alone. **The seven dynamic-palette tokens also carry body-level baselines** matching their `@theme` -declarations, so direct CSS-var consumers (the mobile gesture SVG, a bell ringing -before the first pass) render before `useDynamicPalette()` publishes refined -values. +declarations, so direct CSS-var consumers (the mobile gesture SVG, an alarm +inset before the first pass) render before `useDynamicPalette()` publishes +refined values. **Never put hardcoded color defaults or `var(..., fallback)` chains in `theme-colors.css` or `theme.css`** (Host-Theme-Only Rule): hosts plus the resolver provide every @@ -276,7 +276,7 @@ and **must run them through `completeThemeVars()` and `flattenSelectionAlpha()`* materialized `--vscode-*` set the app sees. The preview decorator writes them to both `html` (VSCode's host globals) and `body` (matching `applyTheme()`), and publishes the dynamic palette through `computeDynamicPalette()` so stories -outside a full Wall — doors, focus rings, ringing bells — still get the runtime +outside a full Wall — doors, focus rings, alarm insets — still get the runtime picks. `PREFERRED_STORYBOOK_THEME` in `lib/.storybook/preview.ts` names the default simulated host theme, **falling back to the first bundled theme** so a renamed or removed bundle cannot leave stories without theme vars. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 0aef00f63..2f58cb12f 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -51,7 +51,7 @@ Extras: `Starred on GitHub` (persisted separately, `onOpenGithub`), `🐭 Flappy - **`s`** (Alerts) — reports `longtask` on both alert panes so command-keyed WATCHING demonstrates `al-spreads`, pumping only the quiet `tut-boxed` (rationale), keeping the command alive through WATCHING’s silence chain. **A replay cancels the prior delayed exit**, so presses during the countdown cannot stack pumps; afterwards `TutorialShell.reportRunningCommand()` restores each pane's real command. - **`n`** (Alerts) — writes a raw `OSC 777` notification to `tut-boxed`, exercising the terminal-report track, which needs no WATCHING rule. -- **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the bell (rationale). +- **`x`** (Alerts) — starts a fake `slowbuild` on `tut-splash` and reports its exit after the captured duration. **The command name must stay unwatched**, so the command-exit track rather than WATCHING owns the ring (rationale). - **`p`** (Copy paste) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Desktop only — Pocket omits the callback, and the runner hides the prompt line without it. ### Pocket Copy paste specifics @@ -87,7 +87,7 @@ Hooks in `dormouse-lib` / `MobileTerminalUi` that exist for tutorial observabili - **`WallEvent.kill` / `move` / `paneAdded`** — discriminants on the `WallEvent` union. `kill` fires from `killPaneImmediately`, so every kill path (confirm dialog, tmux `x`, door kill, `dor kill`) credits `kb-kill`. **`move` must fire from both** the Cmd/Ctrl-Arrow swap in `lib/src/components/wall/keyboard/handle-pane-shortcuts.ts` **and** the center-drop swap in `Wall.onProposeMove` (rationale). **`paneAdded` fires once per pane that becomes visible** — seed ids, splits, dor surfaces, restores, auto-spawn — via Lath’s leaf-id diff, with seeds announced explicitly. - **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert manager for a fixed duration with no data output (the `s` demo). Returns a cancel handle; stops on its own if the pty dies mid-duration. -- **`FakePtyAdapter.sendOutput(id, data, { skipActivity })`** — pushes data through the real protocol parser as if the PTY produced it — `alertManager.onData()` for visible bytes, the notification/semantic-event paths for OSCs (rationale). **Unlike `writePty` it is not suppressed while a scenario is playing.** `TutRunner` passes `skipActivity: true` for every frame, so redrawing the TUI never tilts its own pane's bell. +- **`FakePtyAdapter.sendOutput(id, data, { skipActivity })`** — pushes data through the real protocol parser as if the PTY produced it — `alertManager.onData()` for visible bytes, the notification/semantic-event paths for OSCs (rationale). **Unlike `writePty` it is not suppressed while a scenario is playing.** `TutRunner` passes `skipActivity: true` for every frame, so redrawing the TUI never rings its own pane. - **`FakePtyAdapter.onPtySpawn`** — fires synchronously inside `spawnPty`, before the scenario plays, so a page attaches a shell without racing `TerminalPane`'s mount. - **`subscribeToWatchedCommands` / `getWatchedCommands`** (`lib/src/lib/watched-commands.ts`, re-exported from `terminal-registry`) — the WATCHING rule set, watched to credit `al-watch-cmd`. - **`MobileTerminalUi.onGestureInput(input, data)`** — optional, fired only for radial-menu actions, so Pocket credits gesture items without mistaking native keyboard input for a gesture. diff --git a/docs/specs/tutorial.rationale.md b/docs/specs/tutorial.rationale.md index b23a301ac..3dd037a39 100644 --- a/docs/specs/tutorial.rationale.md +++ b/docs/specs/tutorial.rationale.md @@ -30,7 +30,7 @@ ## Fake shell behavior -**Why shell integration is mandatory rather than nice-to-have.** A playground pane emitting no `OSC 633` would report "nothing is running" for every bell — including the pane hosting the tutorial itself, leaving the alert section with nothing to demonstrate. Reporting them also makes every playground pane OSC-driven, which is what keeps `docs/specs/terminal-state.md`'s keystroke fallback from engaging there. +**Why shell integration is mandatory rather than nice-to-have.** A playground pane emitting no `OSC 633` would report "No command running" for every alert — including the pane hosting the tutorial itself, leaving the alert section with nothing to demonstrate. Reporting them also makes every playground pane OSC-driven, which is what keeps `docs/specs/terminal-state.md`'s keystroke fallback from engaging there. ## Lib hooks backing the tutorial diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 475140ceb..5a9eaf1ec 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -87,9 +87,9 @@ Consequences: The host computes each webview's union (`ringing` / `todo`) from the module-level `AlertManager` scoped to that router's `ownedPtyIds`, delivered via `attachRouter`'s `onUnion` callback. `ownedPtyIds` are PTY-backed, so **VS Code chrome reflects terminal Session ring + TODO only** — a browser Surface's TODO stays webview-local, `alert:state` being keyed by PTY-backed Session ids (see [Future](#future)). -Each hosting primitive uses the chrome it has, following the in-app ` <bell> [TODO]` pattern where possible: +Each hosting primitive uses the chrome it has, following the in-app `<title> [TODO]` pattern plus the alarm ring where possible: -- **Editor tab (`WebviewPanel`):** `panel.title` takes the suffix — `Dormouse` + ` 🔔` (ringing) + ` [TODO]` (todo), both when both apply; the bell is an emoji stand-in because a tab title is plain text. `panel.iconPath` stays the Dormouse mascot. +- **Editor tab (`WebviewPanel`):** `panel.title` takes the suffix — `Dormouse` + ` 🔔` (ringing) + ` [TODO]` (todo), both when both apply; the bell emoji stands in for the Pane's alarm ring because a tab title is plain text. `panel.iconPath` stays the Dormouse mascot. - **Panel view (`WebviewView`):** a presence **badge** — `view.badge.value = 1` whenever anything owes attention, ring-vs-TODO in the tooltip. **Never use `view.title`** — this single-view bottom-panel container shows the static `viewsContainers[].title`, which has no runtime API (rationale). **Clear with `0`, never `undefined`** — VS Code hides a 0-value badge but does not clear an `undefined` one on a panel container. `view.description` stays the shell name. Reflection updates on every owned-PTY `AlertManager.onStateChange` and on `claim` / `release`. Source of truth: `computeWorkspaceUnion` in `lib/src/lib/workspace-union.ts`, `notifyUnion` in `vscode-ext/src/message-router.ts`, `workspaceTitle` / `workspaceBadge` in `vscode-ext/src/workspace-chrome.ts`, `setupPanel` in `vscode-ext/src/extension.ts`, `DormouseViewProvider` in `vscode-ext/src/webview-view-provider.ts`. diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index add9d71c0..b156eabec 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -86,7 +86,7 @@ const fakePlatform = initPlatform('fake'); // Ask `isChromatic()`, never the user agent: Chromatic only rewrites the UA on // its Chrome runner, and identifies every other browser (Safari, Firefox, Edge) // with a `chromatic=true` query parameter instead. A UA sniff therefore left -// every guard below OFF in Safari — a bell ringing on an 800ms infinite loop, a +// every guard below OFF in Safari — an alarm pulsing on a 650ms infinite loop, a // blinking cursor, terminals on WebGL, and mid-tween pane geometry — which is // what made the Safari snapshots unstable while Chrome's stayed clean. if (isChromatic()) { @@ -112,10 +112,10 @@ if (isChromatic()) { // Zero every CSS transition. Unlike the keyframe animations above, each of // which has a static substitute, transitions are started by state that lands // AFTER first paint — the primed-state decorator applies two rAFs in, which - // kicks off the bell's `transition-transform` rotation — so a capture can land - // mid-tween. Only the duration is overridden, so the resting appearance is - // unchanged; it is simply reached on the first frame. An author `!important` - // outranks even inline transition declarations (the selection ring's + // kicks off the header palette's `transition-colors` crossfade — so a capture + // can land mid-tween. Only the duration is overridden, so the resting + // appearance is unchanged; it is simply reached on the first frame. An author + // `!important` outranks even inline transition declarations (the selection ring's // unfocus-saturate fade), so a snapshot showing such a fade already finished // is expected, not a regression. const instantTransitions = document.createElement('style'); @@ -415,8 +415,8 @@ const preview: Preview = { // alone. A renderer that is not painting (a hidden or occluded tab, a // throttled background window) never fires rAF at all, which would leave // every primed story rendering its unprimed default: no TODO pill, no - // notification, a bell with nothing to show. `settle-terminals.ts` holds - // the same rule for the same reason. + // notification, no alarm treatment. `settle-terminals.ts` holds the same + // rule for the same reason. const afterFrame = (fn: () => void) => { let done = false; const run = () => { diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index c895435aa..aabb98471 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -26,7 +26,7 @@ export const cfg = { resizeDebounce: 500, /** ms — attention idle expiry. How long before "looking at this pane" wears off. */ userAttention: 15_000, - /** When true, the ALERT_RINGING bell-ring animation is frozen at T=0 (for deterministic Chromatic snapshots). */ + /** When true, the ALERT_RINGING alarm pulse animations are frozen at T=0 (for deterministic Chromatic snapshots). */ ringingPaused: false, }, terminal: { diff --git a/lib/src/components/AlertBell.test.tsx b/lib/src/components/AlertBell.test.tsx deleted file mode 100644 index 1b21af74c..000000000 --- a/lib/src/components/AlertBell.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** @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(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />)); - const first = bell(); - expect(first?.getAttribute('class')).toContain('animate-bell-ring'); - - await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={2} size={14} />)); - 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(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />)); - const first = bell(); - - await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} className="shrink-0" />)); - expect(bell()).toBe(first); -}); - -it('replays the finite burst when a ringing presentation remounts', async () => { - await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />)); - const first = bell(); - - await act(async () => root.render(null)); - await act(async () => root.render(<AlertBell status="ALERT_RINGING" ringSeq={1} size={14} />)); - - expect(bell()).not.toBe(first); - expect(bell()?.getAttribute('class')).toContain('animate-bell-ring'); -}); diff --git a/lib/src/components/AlertBell.tsx b/lib/src/components/AlertBell.tsx deleted file mode 100644 index 04ac8c2a0..000000000 --- a/lib/src/components/AlertBell.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useMemo } from 'react'; -import { BellIcon } from '@phosphor-icons/react'; -import { clsx } from 'clsx'; -import type { SessionStatus } from '../lib/terminal-registry'; -import { bellIconClass } from './bell-icon-class'; -import { animationClockStyle } from './alert-ring'; - -/** - * 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, ringStartedAt, size, className }: { - status: SessionStatus; - /** `ActivityState.ringSeq` — read only for change, never magnitude. */ - ringSeq: number; - /** Workspace cues retain their start across presentation changes; null is a cold snapshot. */ - ringStartedAt?: number | null; - size: number; - className?: string; -}) { - const watching = status !== 'WATCHING_DISABLED'; - const animation = useMemo(() => ringStartedAt === undefined ? undefined - : ringStartedAt === null ? { animation: 'none' } - : animationClockStyle(ringStartedAt), [ringStartedAt]); - return ( - <BellIcon - key={ringSeq} - style={animation} - size={size} - weight={watching ? 'fill' : 'regular'} - className={watching ? clsx(bellIconClass(status), className) : className} - /> - ); -} diff --git a/lib/src/components/Baseboard.tsx b/lib/src/components/Baseboard.tsx index d8cfb9a0f..e1801a5ca 100644 --- a/lib/src/components/Baseboard.tsx +++ b/lib/src/components/Baseboard.tsx @@ -257,7 +257,6 @@ export function Baseboard({ items, onReattach, notice, onDoorDragStart }: Basebo browserDisplay: item.browserDisplay, toolDirty: item.kind === 'tool' && dirtyTools.get(item.id) === true, status: activity.status, - ringSeq: activity.ringSeq, todo: activity.todo, speechState: speechStates.get(item.id), episode: activity.episode ?? null, diff --git a/lib/src/components/Door.test.tsx b/lib/src/components/Door.test.tsx index 64b409f18..4696b4146 100644 --- a/lib/src/components/Door.test.tsx +++ b/lib/src/components/Door.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { describe('Door alarm state', () => { it('rings with a static inset ring and no label until the speech sink acts', () => { act(() => root.render( - <Door title="build-server" status="ALERT_RINGING" ringSeq={1} todo episode={EPISODE} />, + <Door title="build-server" status="ALERT_RINGING" todo episode={EPISODE} />, )); const door = container.querySelector<HTMLElement>('[data-alert-ring-state="ringing"]'); @@ -40,16 +40,31 @@ describe('Door alarm state', () => { expect(door?.textContent).not.toContain('SPEAKING'); expect(door?.getAttribute('aria-label')).toBe('build-server, needs attention'); // The ring is the treatment, so it carries the bounded arrival burst. - const ring = door?.querySelector<HTMLElement>('[data-alert-ring-inset]'); - expect(ring?.className).toContain('inset_0_0_0_2px'); + const ring = door?.querySelector<HTMLElement>('[data-alert-ring-inset="door"]'); expect(classes(ring)).toContain('motion-safe:animate-alarm-pulse-burst'); }); + /** The burst rides a keyed element, so a second summons on a Door that never + * unmounted has to replace it rather than let an expired animation stand. */ + it('remounts the inset for a fresh episode', () => { + const render = (episode: { id: string; startedAt: number }) => act(() => root.render( + <Door title="build-server" status="ALERT_RINGING" episode={episode} />, + )); + + render(EPISODE); + const first = container.querySelector('[data-alert-ring-inset="door"]'); + render(EPISODE); + expect(container.querySelector('[data-alert-ring-inset="door"]')).toBe(first); + + render({ id: 'episode-2', startedAt: Date.now() }); + expect(container.querySelector('[data-alert-ring-inset="door"]')).not.toBe(first); + }); + /** A Door that is not ringing has no alarm state, whatever the renderer last * said about speech. */ it('shows no alarm edge for a quiet Session', () => { act(() => root.render( - <Door title="build-server" ringSeq={0} speechState="spoken" episode={null} />, + <Door title="build-server" speechState="spoken" episode={null} />, )); expect(container.querySelector('[data-alert-ring-state]')).toBeNull(); @@ -58,7 +73,7 @@ describe('Door alarm state', () => { it('inverts and animates the whole Door while its Session is speaking', () => { act(() => root.render( - <Door title="build-server" status="ALERT_RINGING" ringSeq={1} todo speechState="speaking" + <Door title="build-server" status="ALERT_RINGING" todo speechState="speaking" episode={EPISODE} />, )); @@ -73,33 +88,33 @@ describe('Door alarm state', () => { it('marks SPOKEN with a static inset ring rather than motion', () => { act(() => root.render( - <Door title="build-server" status="ALERT_RINGING" ringSeq={1} speechState="spoken" + <Door title="build-server" status="ALERT_RINGING" speechState="spoken" episode={EPISODE} />, )); const door = container.querySelector<HTMLElement>('[data-alert-ring-state="spoken"]'); - const ring = door?.querySelector<HTMLElement>('[data-alert-ring-inset]'); - expect(ring?.className).toContain('inset_0_0_0_2px'); + const ring = door?.querySelector<HTMLElement>('[data-alert-ring-inset="door"]'); + expect(ring).not.toBeNull(); expect(classes(ring).some(c => c.includes('animate-'))).toBe(false); expect(door?.getAttribute('aria-label')).toBe('build-server, spoken'); }); /** * `spoken` is cleared only when the ring resolves, so a user who never attends - * leaves it set indefinitely. It may not evict the bell and TODO pill for that - * whole window — those are the baseboard's persistent status signals, and a - * Door showing neither is indistinguishable from a quiet one. + * leaves it set indefinitely. It may not evict the speaker glyph and TODO pill + * for that whole window — those are the baseboard's persistent status signals, + * and a Door showing neither is indistinguishable from a quiet one. */ - it('keeps the bell and TODO pill visible while SPOKEN persists', () => { + it('keeps the speaker glyph and TODO pill visible while SPOKEN persists', () => { act(() => root.render( - <Door title="build-server" status="ALERT_RINGING" ringSeq={1} todo speechState="spoken" + <Door title="build-server" status="ALERT_RINGING" todo speechState="spoken" episode={EPISODE} />, )); const door = container.querySelector<HTMLElement>('[data-alert-ring-state="spoken"]'); expect(door?.querySelector('.todo-pill-shell')).not.toBeNull(); - // Speaker icon + bell icon, both alongside the pill. - expect(door?.querySelectorAll('svg').length).toBe(2); + // The speaker glyph alongside the pill, and no other icon with it. + expect(door?.querySelectorAll('svg').length).toBe(1); }); }); @@ -111,7 +126,6 @@ describe('Door notepad button', () => { <Door doorId="pane-a" title="build-server" - ringSeq={0} episode={null} onClick={onClick} onOpenNotepad={onOpenNotepad} @@ -174,7 +188,7 @@ describe('Door notepad button', () => { describe('Door unsaved changes', () => { it.each(['speaking', 'spoken'] as const)('keeps the dirty dot beside notes and %s state', speechState => { - act(() => root.render(<Door doorId="dirty" title="Editor" ringSeq={1} toolDirty + act(() => root.render(<Door doorId="dirty" title="Editor" toolDirty speechState={speechState} noteCount={2} todo status="ALERT_RINGING" episode={EPISODE} />)); const door = container.querySelector('[data-door-id="dirty"]')!; expect(door.querySelector('[role="img"][aria-label="Unsaved changes"]')).not.toBeNull(); diff --git a/lib/src/components/Door.tsx b/lib/src/components/Door.tsx index 35c2f2edf..15cc68a9f 100644 --- a/lib/src/components/Door.tsx +++ b/lib/src/components/Door.tsx @@ -6,14 +6,13 @@ import type { AlertSpeechState, SessionStatus, TodoState } from '../lib/terminal import type { BrowserDisplayMode } from './wall/agent-browser-screen'; import { BROWSER_DISPLAY_LABEL, BrowserDisplayIcon } from './wall/BrowserDisplayIcon'; import { useTodoPillContent } from './TodoPillBody'; -import { AlertBell } from './AlertBell'; import { notepadLabel } from './use-notepad'; import type { AlertEpisode } from '../lib/alert-episode'; -import { ALERT_RING_LABEL, alarmPulseClass, alertRingRow, useAlertRingBurst } from './alert-ring'; +import { ALERT_RING_LABEL, AlertRingInset, alarmPulseClass, alertRingRow, useAlertRingBurst } from './alert-ring'; import { ALERT_SPEECH_TRACKING_CLASS, - DOOR_ALARM_INSET_CLASS, DOOR_TAB_CLASS, + TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS, } from './design'; @@ -27,9 +26,6 @@ export interface DoorProps { /** Set only for a Tool whose last report says it has unsaved changes. */ toolDirty?: boolean; status?: SessionStatus; - /** `ActivityState.ringSeq`; a change replays the bell's ringing burst. Dies - * with the bell in the next PR. */ - ringSeq: number; todo?: TodoState; speechState?: AlertSpeechState; /** `ActivityState.episode` — the Session's current ringing interval. A new one @@ -63,7 +59,6 @@ export function Door({ browserDisplay, toolDirty = false, status = 'WATCHING_DISABLED', - ringSeq, todo = false, speechState, episode, @@ -72,7 +67,6 @@ export function Door({ onDragPress, onOpenNotepad, }: DoorProps) { - const showBell = status !== 'WATCHING_DISABLED'; const row = alertRingRow(status, speechState); const burst = useAlertRingBurst(row, episode); const todoPill = useTodoPillContent(todo); @@ -142,7 +136,7 @@ export function Door({ <SpeakerHighIcon size={13} weight="fill" /> <span>SPEAKING</span> </span> - ) : (spoken || todoPill.visible || showBell) && ( + ) : (spoken || todoPill.visible) && ( <span className="flex shrink-0 items-center gap-1.5"> {spoken && ( <SpeakerHighIcon size={12} weight="fill" className="text-alarm-vs-door" /> @@ -155,11 +149,6 @@ export function Door({ {todoPill.body} </span> )} - {showBell && ( - <span className={row ? 'text-alarm-vs-door' : ''}> - <AlertBell status={status} ringSeq={ringSeq} size={11} /> - </span> - )} </span> )} </button> @@ -181,15 +170,7 @@ export function Door({ <NotepadIcon size={12} weight="fill" /> </button> )} - {insetRing && ( - <span - key={burst?.key} - data-alert-ring-inset - aria-hidden - style={burst?.style} - className={clsx(DOOR_ALARM_INSET_CLASS, burst?.className)} - /> - )} + {insetRing && <AlertRingInset ground="door" burst={burst} className={TERMINAL_TOP_RADIUS_CLASS} />} </div> ); } diff --git a/lib/src/components/HeaderActionButton.tsx b/lib/src/components/HeaderActionButton.tsx index 5b19102a8..8fff80637 100644 --- a/lib/src/components/HeaderActionButton.tsx +++ b/lib/src/components/HeaderActionButton.tsx @@ -6,28 +6,18 @@ export interface HeaderActionButtonProps { className: string; ariaLabel: string; tooltip?: string | null; - tooltipDetail?: string; - tooltipAlign?: 'left' | 'right'; - onMouseDownCapture?: (e: React.MouseEvent<HTMLButtonElement>) => void; onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void; onClick: (e: React.MouseEvent<HTMLButtonElement>) => void; - onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void; children: React.ReactNode; - dataAlertButtonFor?: string; } export function HeaderActionButton({ className, ariaLabel, tooltip, - tooltipDetail, - tooltipAlign = 'right', - onMouseDownCapture, onMouseDown, onClick, - onContextMenu, children, - dataAlertButtonFor, }: HeaderActionButtonProps) { const buttonRef = useRef<HTMLButtonElement>(null); const [isVisible, setIsVisible] = useState(false); @@ -42,9 +32,9 @@ export function HeaderActionButton({ if (!rect) return; setTooltipStyle({ position: 'fixed', - left: tooltipAlign === 'left' ? rect.left : rect.right, + left: rect.right, top: rect.bottom + 8, - transform: tooltipAlign === 'left' ? 'translate(0, 0)' : 'translate(-100%, 0)', + transform: 'translate(-100%, 0)', }); }; @@ -55,7 +45,7 @@ export function HeaderActionButton({ window.removeEventListener('scroll', updatePosition, true); window.removeEventListener('resize', updatePosition); }; - }, [isVisible, tooltipAlign]); + }, [isVisible]); return ( <> @@ -64,8 +54,6 @@ export function HeaderActionButton({ ref={buttonRef} type="button" className={className} - data-alert-button-for={dataAlertButtonFor} - onMouseDownCapture={onMouseDownCapture} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); @@ -76,11 +64,6 @@ export function HeaderActionButton({ setIsVisible(false); onClick(e); }} - onContextMenu={onContextMenu ? (e) => { - e.preventDefault(); - e.stopPropagation(); - onContextMenu(e); - } : undefined} aria-label={ariaLabel} onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => setIsVisible(false)} @@ -96,10 +79,7 @@ export function HeaderActionButton({ className="pointer-events-none z-[9999] whitespace-nowrap px-2 py-1.5" style={tooltipStyle} > - <div className="flex flex-col gap-0.5 leading-none"> - <div>{renderShortcuts(tooltipPrimary)}</div> - {tooltipDetail && <div>{renderShortcuts(tooltipDetail)}</div>} - </div> + <div className="leading-none">{renderShortcuts(tooltipPrimary)}</div> </PopupButtonRow>, document.body, )} diff --git a/lib/src/components/MobileTerminalUi.test.tsx b/lib/src/components/MobileTerminalUi.test.tsx index 1307623e3..2aff232c9 100644 --- a/lib/src/components/MobileTerminalUi.test.tsx +++ b/lib/src/components/MobileTerminalUi.test.tsx @@ -4,9 +4,11 @@ import { act, StrictMode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MobileTerminalUi, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; +import { MobileTerminalUi, type MobileTerminalSessionItem, type MobileTerminalTouchMode, type MobileTerminalUiProps } from './MobileTerminalUi'; import { setNativeFieldValue } from '../lib/dom'; +const EPISODE = { id: 'episode-1', startedAt: Date.now() }; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; function pointerEvent( @@ -117,29 +119,32 @@ afterEach(() => { vi.useRealTimers(); }); -describe('MobileTerminalUi keyboard input', () => { - function renderInput(props: Partial<MobileTerminalUiProps> = {}) { - const container = document.createElement('div'); - document.body.appendChild(container); - const root = createRoot(container); - roots.push(root); - const renderWith = (nextProps: Partial<MobileTerminalUiProps>) => act(() => root.render( - <StrictMode> - <MobileTerminalUi terminal={<div data-testid="terminal" />} {...nextProps} /> - </StrictMode>, - )); - renderWith(props); - return { - input: container.querySelector<HTMLTextAreaElement>('textarea')!, - terminal: container.querySelector<HTMLDivElement>('[data-testid="terminal"]')!, - typeButton: container.querySelector<HTMLButtonElement>('[aria-label="Type input mode"]')!, - renderWith, - }; - } +/** One mount of the whole composition, shared by every suite below: the input + * tests reach for the textarea, the session-list ones for the container. */ +function renderUi(props: Partial<MobileTerminalUiProps> = {}) { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const renderWith = (nextProps: Partial<MobileTerminalUiProps>) => act(() => root.render( + <StrictMode> + <MobileTerminalUi terminal={<div data-testid="terminal" />} {...nextProps} /> + </StrictMode>, + )); + renderWith(props); + return { + container, + input: container.querySelector<HTMLTextAreaElement>('textarea')!, + terminal: container.querySelector<HTMLDivElement>('[data-testid="terminal"]')!, + typeButton: container.querySelector<HTMLButtonElement>('[aria-label="Type input mode"]')!, + renderWith, + }; +} +describe('MobileTerminalUi keyboard input', () => { it('leaves IME editing keys to the composition and sends committed text once', () => { const onSendInput = vi.fn(); - const { input } = renderInput({ onSendInput }); + const { input } = renderUi({ onSendInput }); act(() => { input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); setNativeFieldValue(input, '日本'); @@ -158,7 +163,7 @@ describe('MobileTerminalUi keyboard input', () => { it.each([{ isComposing: true }, { keyCode: 229 }])('ignores IME confirmation keydown with %j', (imeState) => { const onSendInput = vi.fn(); - const { input } = renderInput({ onSendInput }); + const { input } = renderUi({ onSendInput }); const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, ...imeState }); act(() => input.dispatchEvent(event)); expect(event.defaultPrevented).toBe(false); @@ -167,7 +172,7 @@ describe('MobileTerminalUi keyboard input', () => { it('sends software-keyboard Backspace and Enter without keydown or a textarea change', () => { const onSendInput = vi.fn(); - const { input } = renderInput({ onSendInput }); + const { input } = renderUi({ onSendInput }); for (const inputType of ['deleteContentBackward', 'insertLineBreak', 'insertParagraph']) { const event = new InputEvent('beforeinput', { inputType, bubbles: true, cancelable: true }); act(() => input.dispatchEvent(event)); @@ -179,7 +184,7 @@ describe('MobileTerminalUi keyboard input', () => { it('leaves software-keyboard deletion inside an IME composition alone', () => { const onSendInput = vi.fn(); - const { input } = renderInput({ onSendInput }); + const { input } = renderUi({ onSendInput }); act(() => input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))); const event = new InputEvent('beforeinput', { inputType: 'deleteContentBackward', bubbles: true, cancelable: true }); act(() => input.dispatchEvent(event)); @@ -189,7 +194,7 @@ describe('MobileTerminalUi keyboard input', () => { it('cancels pending focus when a pane touch dismisses the keyboard', () => { vi.useFakeTimers(); - const { input, terminal, typeButton } = renderInput(); + const { input, terminal, typeButton } = renderUi(); act(() => typeButton.click()); expect(document.activeElement).toBe(input); act(() => terminal.dispatchEvent(pointerEvent('pointerdown'))); @@ -199,7 +204,7 @@ describe('MobileTerminalUi keyboard input', () => { it('cancels pending pane blur when Type is tapped again', () => { vi.useFakeTimers(); - const { input, terminal, typeButton } = renderInput(); + const { input, terminal, typeButton } = renderUi(); act(() => vi.advanceTimersByTime(600)); act(() => terminal.dispatchEvent(pointerEvent('pointerdown'))); act(() => typeButton.click()); @@ -210,7 +215,7 @@ describe('MobileTerminalUi keyboard input', () => { it('blurs when the consumer switches from Type to Sessions', () => { vi.useFakeTimers(); - const { input, typeButton, renderWith } = renderInput({ activeKeyboardMode: 'type' }); + const { input, typeButton, renderWith } = renderUi({ activeKeyboardMode: 'type' }); act(() => typeButton.click()); expect(document.activeElement).toBe(input); renderWith({ activeKeyboardMode: 'sessions' }); @@ -357,3 +362,27 @@ describe('MobileTerminalUi touch modes', () => { expect(received).toEqual([]); }); }); + +describe('MobileTerminalUi session list', () => { + const renderSessions = (sessions: MobileTerminalSessionItem[]) => + renderUi({ activeKeyboardMode: 'sessions', sessions }).container; + + const inset = (container: HTMLElement, title: string): string | null => + [...container.querySelectorAll('button')] + .find((b) => b.textContent?.includes(title))! + .querySelector('[data-alert-ring-inset]') + ?.getAttribute('data-alert-ring-inset') ?? null; + + /** The row is the alarm's only carrier now (`docs/specs/alert.md` -> Pane Header). */ + it('wears the alarm inset only on a ringing row, on its own ground', () => { + const container = renderSessions([ + { id: 'a', title: 'ringing-active', active: true, status: 'ALERT_RINGING', episode: EPISODE }, + { id: 'b', title: 'ringing-idle', status: 'ALERT_RINGING', episode: EPISODE }, + { id: 'c', title: 'quiet', status: 'BUSY', episode: null }, + ]); + + expect(inset(container, 'ringing-active')).toBe('header-active'); + expect(inset(container, 'ringing-idle')).toBe('door'); + expect(inset(container, 'quiet')).toBeNull(); + }); +}); diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index d64ba59af..3137b6087 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -19,7 +19,6 @@ import { TextTIcon, } from '@phosphor-icons/react'; import { clsx } from 'clsx'; -import { AlertBell } from './AlertBell'; import { MobileGestureConfirmDialog, MobileGestureRadialMenu, @@ -40,6 +39,8 @@ import { import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; import { isEditableTarget } from '../lib/dom'; import { TouchUiContext } from './touch-ui-context'; +import { AlertRingInset, alertRingRow, useAlertRingBurst } from './alert-ring'; +import type { AlertEpisode } from '../lib/alert-episode'; import type { SessionStatus } from '../lib/terminal-registry'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; @@ -52,8 +53,9 @@ export interface MobileTerminalSessionItem { secondary?: string | null; active?: boolean; status?: SessionStatus; - /** `ActivityState.ringSeq`; a change replays the ringing burst. */ - ringSeq: number; + /** `ActivityState.episode` — the Session's current ringing interval, which + * anchors the row's arrival burst; `null` while it is quiet. */ + episode: AlertEpisode | null; todo?: boolean; } @@ -321,59 +323,62 @@ function SessionsPane({ return ( <div className="h-full overflow-auto p-2"> <div className="grid gap-1"> - {sessions.map((session) => { - const active = session.active === true; - const ringing = session.status === 'ALERT_RINGING' || session.status === 'MIGHT_NEED_ATTENTION'; - return ( - <button - key={session.id} - type="button" - disabled={disabled} - aria-current={active ? 'page' : undefined} - onClick={() => onSelect?.(session.id)} - className={clsx( - 'flex min-h-10 min-w-0 items-center gap-2 rounded px-2 text-left font-mono text-xs transition-colors', - 'focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-focus-ring', - 'disabled:pointer-events-none disabled:opacity-60', - // Rows sit on the header-inactive reserve, so the inactive row - // recesses to the app pair — the guaranteed app↔inactive delta - // (theme.md's three-pair rule); surface-raised is unreliable here. - active - ? 'bg-header-active-bg text-header-active-fg shadow-[inset_0_0_0_1px_var(--color-focus-ring)]' - : 'bg-app-bg text-app-fg', - )} - > - <TerminalWindowIcon size={15} weight={active ? 'bold' : 'regular'} className="shrink-0" /> - <span className="min-w-0 flex-1"> - <span className="block truncate font-medium">{session.title}</span> - {session.secondary ? ( - <span className="block truncate opacity-70">{session.secondary}</span> - ) : null} - </span> - {session.todo ? ( - <span className="shrink-0 rounded border border-current px-1 py-px text-[0.55rem] font-semibold leading-none tracking-[0.08em]"> - TODO - </span> - ) : null} - {ringing ? ( - <AlertBell - size={14} - status={session.status ?? 'ALERT_RINGING'} - ringSeq={session.ringSeq} - className={clsx( - 'shrink-0', - active ? 'text-alarm-vs-header-active' : 'text-alarm-vs-door', - )} - /> - ) : null} - </button> - ); - })} + {sessions.map((session) => ( + <SessionRow key={session.id} session={session} disabled={disabled} onSelect={onSelect} /> + ))} </div> </div> ); } +/** A row is its own component so it can hold the burst hook; the mobile list has + * no speech sink, so the unlabelled row is the only one it ever wears. */ +function SessionRow({ session, disabled, onSelect }: { + session: MobileTerminalSessionItem; + disabled: boolean; + onSelect?: (id: string) => void; +}) { + const active = session.active === true; + const row = alertRingRow(session.status, null); + const burst = useAlertRingBurst(row, session.episode); + + return ( + <button + type="button" + disabled={disabled} + aria-current={active ? 'page' : undefined} + onClick={() => onSelect?.(session.id)} + className={clsx( + 'relative flex min-h-10 min-w-0 items-center gap-2 overflow-hidden rounded px-2 text-left font-mono text-xs transition-colors', + 'focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-focus-ring', + 'disabled:pointer-events-none disabled:opacity-60', + // Rows sit on the header-inactive reserve, so the inactive row recesses + // to the app pair — the guaranteed app↔inactive delta (theme.md's + // three-pair rule); surface-raised is unreliable here. + active + ? 'bg-header-active-bg text-header-active-fg shadow-[inset_0_0_0_1px_var(--color-focus-ring)]' + : 'bg-app-bg text-app-fg', + )} + > + <TerminalWindowIcon size={15} weight={active ? 'bold' : 'regular'} className="shrink-0" /> + <span className="min-w-0 flex-1"> + <span className="block truncate font-medium">{session.title}</span> + {session.secondary ? ( + <span className="block truncate opacity-70">{session.secondary}</span> + ) : null} + </span> + {session.todo ? ( + <span className="shrink-0 rounded border border-current px-1 py-px text-[0.55rem] font-semibold leading-none tracking-[0.08em]"> + TODO + </span> + ) : null} + {/* No alarm token is computed against `app-bg`, so an inactive row borrows + the Door's — the nearest recessed ground the palette does cover. */} + {row && <AlertRingInset ground={active ? 'header-active' : 'door'} burst={burst} className="rounded" />} + </button> + ); +} + type MobileGestureConfirmationAction = Extract<MobileGestureAction, { kind: 'confirm' }>; function localPointerPoint(event: PointerEvent<HTMLElement>): MobileGesturePoint { diff --git a/lib/src/components/MobileWall.test.tsx b/lib/src/components/MobileWall.test.tsx index d86f53022..4d2caeea2 100644 --- a/lib/src/components/MobileWall.test.tsx +++ b/lib/src/components/MobileWall.test.tsx @@ -11,12 +11,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; const registry = vi.hoisted(() => ({ activitySnapshot: new Map(), clearSessionTodo: vi.fn(), - dismissSessionAlert: vi.fn(), disposeSession: vi.fn(), getActivitySnapshot: vi.fn(), getOrCreateTerminal: vi.fn(), terminalPaneStateSnapshot: new Map(), getTerminalPaneStateSnapshot: vi.fn(), + markSessionAttention: vi.fn(), setTerminalUserTitle: vi.fn(), subscribeToActivity: vi.fn(() => () => {}), subscribeToTerminalPaneState: vi.fn(() => () => {}), @@ -24,7 +24,7 @@ const registry = vi.hoisted(() => ({ vi.mock('../lib/terminal-registry', () => ({ ...registry, - DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', ringSeq: 0, todo: false }, + DEFAULT_ACTIVITY_STATE: { status: 'WATCHING_DISABLED', episode: null, todo: false }, })); vi.mock('./TerminalPane', () => ({ @@ -57,7 +57,7 @@ function renderWall(showKillButton?: boolean) { root.render( <StrictMode> <MobileWall - sessions={[{ id: 'pane-a', title: 'remote shell', ringSeq: 0 }]} + sessions={[{ id: 'pane-a', title: 'remote shell' }]} activeSessionId="pane-a" showKillButton={showKillButton} /> @@ -66,6 +66,16 @@ function renderWall(showKillButton?: boolean) { }); } +function headerInset(): string | null { + return container.querySelector<HTMLElement>('.bg-header-active-bg') + ?.querySelector('[data-alert-ring-inset]') + ?.getAttribute('data-alert-ring-inset') ?? null; +} + +function terminalPane(): HTMLElement { + return container.querySelector<HTMLElement>('[data-testid="terminal-pane"]')!.parentElement!; +} + describe('MobileWall', () => { it('shows the Kill control by default', () => { renderWall(); @@ -79,4 +89,34 @@ describe('MobileWall', () => { expect(container.querySelector('button[aria-label="Kill"]')).toBeNull(); expect(container.querySelector('button[aria-label="Minimize"]')).not.toBeNull(); }); + + it('leaves the header plain while the Session is quiet', () => { + renderWall(); + + expect(headerInset()).toBeNull(); + }); + + it('wears the alarm inset on the header while the Session rings', () => { + registry.activitySnapshot.set('pane-a', { + status: 'ALERT_RINGING', episode: { id: 'e1', startedAt: Date.now() }, todo: false, + }); + try { + renderWall(); + expect(headerInset()).toBe('header-active'); + } finally { + registry.activitySnapshot.clear(); + } + }); + + /** Mobile has no terminal context and no right-click, so attention is the + * whole dismissal (`docs/specs/alert.md` -> Pane Header). */ + it('attends the Session when the terminal is touched', () => { + renderWall(); + + act(() => { + terminalPane().dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + }); + + expect(registry.markSessionAttention).toHaveBeenCalledWith('pane-a'); + }); }); diff --git a/lib/src/components/MobileWall.tsx b/lib/src/components/MobileWall.tsx index d3c4f2861..c6cac2c77 100644 --- a/lib/src/components/MobileWall.tsx +++ b/lib/src/components/MobileWall.tsx @@ -5,18 +5,18 @@ import { } from '@phosphor-icons/react'; import { HeaderActionButton } from './HeaderActionButton'; import { TerminalPane } from './TerminalPane'; -import { AlertBell } from './AlertBell'; +import { AlertRingInset, alertRingRow, useAlertRingBurst } from './alert-ring'; import { TODO_PILL_TRACKING_CLASS } from './design'; import { useTodoPillContent } from './TodoPillBody'; import type { MobileTerminalSessionItem } from './MobileTerminalUi'; import { clearSessionTodo, DEFAULT_ACTIVITY_STATE, - dismissSessionAlert, disposeSession, getActivitySnapshot, getOrCreateTerminal, getTerminalPaneStateSnapshot, + markSessionAttention, setTerminalUserTitle, subscribeToActivity, subscribeToTerminalPaneState, @@ -75,7 +75,7 @@ export function useMobileWallSessionItems( secondary: derivedHeader.secondary, active: session.id === activeSessionId, status: activity.status, - ringSeq: activity.ringSeq, + episode: activity.episode ?? null, todo: activity.todo, }; }), [activeSessionId, activityStates, appTitleForPane, sessions, terminalStates, visiblePaneStates]); @@ -154,7 +154,14 @@ export function MobileWall({ onKill={() => killSession(activeItem.id)} showKillButton={showKillButton} /> - <div className="min-h-0 flex-1 overflow-hidden bg-terminal-bg"> + {/* Touching the pane attends it, which is what puts a ring out here: + mobile has no terminal context and no right-click + (`docs/specs/alert.md` -> Pane Header). Keystrokes already attend + through `wireXtermHandlers`. */} + <div + className="min-h-0 flex-1 overflow-hidden bg-terminal-bg" + onPointerDown={() => markSessionAttention(activeItem.id)} + > <TerminalPane id={activeItem.id} isFocused /> </div> </div> @@ -172,37 +179,19 @@ function MobileWallHeader({ onKill: () => void; showKillButton: boolean; }) { - const status = session.status ?? 'WATCHING_DISABLED'; const todoPill = useTodoPillContent(session.todo === true); - // Mobile has no terminal context, so dismissing a ring is the button's whole - // action; it never edits a rule (`docs/specs/alert.md` -> Pane Header). - const alertButtonLabel = status === 'ALERT_RINGING' ? 'Dismiss alert' : 'Alert status'; - const showTodoPill = todoPill.visible; + const row = alertRingRow(session.status, null); + const burst = useAlertRingBurst(row, session.episode); return ( - <div className="flex h-8 shrink-0 items-center gap-1.5 bg-header-active-bg pl-2 pr-[5px] font-mono text-sm leading-none text-header-active-fg"> + <div className="relative flex h-8 shrink-0 items-center gap-1.5 overflow-hidden bg-header-active-bg pl-2 pr-[5px] font-mono text-sm leading-none text-header-active-fg"> <div className="flex min-w-0 flex-1 items-center gap-1.5"> <span className="min-w-0 shrink truncate font-medium">{session.title}</span> - <HeaderActionButton - className={[ - 'flex h-5 min-w-5 items-center justify-center rounded transition-colors shrink-0 hover:bg-current/10', - status === 'ALERT_RINGING' ? 'text-alarm-vs-header-active' : '', - ].join(' ')} - onClick={() => dismissSessionAlert(session.id)} - ariaLabel={alertButtonLabel} - tooltip={alertButtonLabel} - tooltipAlign="left" - dataAlertButtonFor={session.id} - > - <span className="flex items-center justify-center"> - <AlertBell status={status} ringSeq={session.ringSeq} size={14} /> - </span> - </HeaderActionButton> {session.secondary ? ( <span className="min-w-0 shrink truncate opacity-70">{session.secondary}</span> ) : null} </div> - {showTodoPill ? ( + {todoPill.visible ? ( <button type="button" data-session-todo-for={session.id} @@ -238,6 +227,7 @@ function MobileWallHeader({ </HeaderActionButton> ) : null} </div> + {row && <AlertRingInset ground="header-active" burst={burst} />} </div> ); } diff --git a/lib/src/components/WorkspaceStrip.test.tsx b/lib/src/components/WorkspaceStrip.test.tsx index 860e2671b..23e90c369 100644 --- a/lib/src/components/WorkspaceStrip.test.tsx +++ b/lib/src/components/WorkspaceStrip.test.tsx @@ -128,9 +128,70 @@ describe('WorkspaceStrip', () => { expect(activateButton(first).getAttribute('aria-label')).toBe('Workspace 1, 2 needing attention'); expect(tabFor(first).querySelector('.todo-pill-shell')).not.toBeNull(); - expect(tabFor(first).querySelector('svg')).not.toBeNull(); + expect(tabFor(first).querySelector('[data-alert-ring-inset]')).not.toBeNull(); // The visible Workspace shows its Surfaces, so its tab stays plain. expect(tabFor('ws-2').querySelector('.todo-pill-shell')).toBeNull(); + expect(tabFor('ws-2').querySelector('[data-alert-ring-inset]')).toBeNull(); + }); + + /** The inset is the ring's only presence on a tab, so a Workspace whose + * members merely owe a TODO must not wear it. */ + it('leaves the alarm inset off a hidden Workspace with no ringing member', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + setWorkspaceSurfaces(first, ['pane-a']); + setTerminalActivity('pane-a', { todo: true }); + await render(); + + expect(tabFor(first).querySelector('.todo-pill-shell')).not.toBeNull(); + expect(tabFor(first).querySelector('[data-alert-ring-inset]')).toBeNull(); + }); + + /** The tab's summons is the Workspace's whole ringing interval, so losing the + * member that started it must not remount the inset and replay its burst. */ + it('keeps one burst while a Workspace stays ringing', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + setWorkspaceSurfaces(first, ['pane-a', 'pane-b']); + setTerminalActivity('pane-a', { status: 'ALERT_RINGING', episode: { id: 'older', startedAt: 1_000 } }); + setTerminalActivity('pane-b', { status: 'ALERT_RINGING', episode: { id: 'newer', startedAt: 2_000 } }); + await render(); + + const inset = tabFor(first).querySelector('[data-alert-ring-inset]'); + expect(inset).not.toBeNull(); + + // The older member is attended; the Workspace is still ringing through the + // newer one, whose later start would otherwise become a fresh summons. + await act(async () => { setTerminalActivity('pane-a', { status: 'NOTHING_TO_SHOW' }); }); + + expect(tabFor(first).querySelector('[data-alert-ring-inset]')).toBe(inset); + }); + + /** The cache is the tab's memory of one ring, so a ring that ends while its + * Workspace is visible must not leave its start behind for the next one. */ + it('clocks the burst from the ring that began while the Workspace was visible', async () => { + const first = getWorkspacesSnapshot().workspaces[0].id; + await act(async () => { createWorkspace({ id: 'ws-2' }); }); + setWorkspaceSurfaces(first, ['pane-a']); + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000); + setTerminalActivity('pane-a', { status: 'ALERT_RINGING', episode: { id: 'first', startedAt: 1_000 } }); + await render(); + expect(tabFor(first).querySelector('[data-alert-ring-inset]')).not.toBeNull(); + + // Attend that ring from its own tab, then let the member ring again while + // the Workspace is the visible one and wears no inset. + await act(async () => { activateButton(first).click(); }); + await act(async () => { setTerminalActivity('pane-a', { status: 'NOTHING_TO_SHOW' }); }); + await act(async () => { + setTerminalActivity('pane-a', { status: 'ALERT_RINGING', episode: { id: 'second', startedAt: 9_000 } }); + }); + + // Leaving reveals the summons: a burst 100ms old, not one clocked from the + // ring that ended eight seconds ago and already past the animation's end. + now.mockReturnValue(9_100); + await act(async () => { activateButton('ws-2').click(); }); + const inset = tabFor(first).querySelector<HTMLElement>('[data-alert-ring-inset]')!; + expect(inset.style.animationDelay).toBe('-100ms'); }); it('renames the active tab on click, holding the chrome keyboard lease while the editor is open', async () => { diff --git a/lib/src/components/WorkspaceStrip.tsx b/lib/src/components/WorkspaceStrip.tsx index 27b2cc38f..647e0ec4b 100644 --- a/lib/src/components/WorkspaceStrip.tsx +++ b/lib/src/components/WorkspaceStrip.tsx @@ -1,4 +1,3 @@ -import { WorkspaceRingCues, type WorkspaceRingCue } from '../lib/workspace-ring-cues'; import { memo, useCallback, @@ -12,11 +11,11 @@ import { } from 'react'; import { clsx } from 'clsx'; import { PlusIcon, XIcon } from '@phosphor-icons/react'; -import { AlertBell } from './AlertBell'; import { InlineEditInput } from './wall/InlineEditInput'; import { WorkspaceKillConfirm } from './WorkspaceKillConfirm'; import { useTodoPillContent } from './TodoPillBody'; -import { chromeButton, DOOR_TAB_CLASS, HEADER_PALETTE_TRANSITION_CLASS, ModalFrame, modalActionButton, OVERLAY_MAX_HEIGHT, TAB_INACTIVE_FADE_STYLE, TODO_PILL_TRACKING_CLASS } from './design'; +import { AlertRingInset, useAlertRingBurst } from './alert-ring'; +import { chromeButton, DOOR_TAB_CLASS, HEADER_PALETTE_TRANSITION_CLASS, ModalFrame, modalActionButton, OVERLAY_MAX_HEIGHT, TAB_INACTIVE_FADE_STYLE, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from './design'; import { createWorkspaceStripDrag, type StripDragHost } from './workspace-strip-drag'; import { acquireChromeKeyboardLease } from './wall/chrome-keyboard-lease'; import { getWallHandle } from './wall/wall-handles'; @@ -144,9 +143,6 @@ export function WorkspaceStrip({ [pendingClose, pendingMove, moveError], ); - // Cues observe the active Workspace too, so switching tabs cannot create one. - const ringCues = useRef(new WorkspaceRingCues()); - ringCues.current.update(workspaces.map(workspace => workspace.id), membership, activity); // One union per tab, computed in the loop it is rendered in. The visible // Workspace never shows indicators, so it skips the projection entirely. const unionsRef = useRef(new Map<WorkspaceId, WorkspaceUnion>()); @@ -156,13 +152,26 @@ export function WorkspaceStrip({ if (!workspaces.some((workspace) => workspace.id === id)) unionsRef.current.delete(id); } const unionFor = (id: WorkspaceId, active: boolean): WorkspaceUnion => { - if (active) return EMPTY_WORKSPACE_UNION; - const next = computeWorkspaceUnion(membership.get(id) ?? [], activity); + // A visible Workspace shows no indicators, and its cached union must not + // outlive the ring it described: a ring that ends while the Workspace is + // active would otherwise carry its `ringingSince` into the next one. + if (active) { + unionsRef.current.delete(id); + return EMPTY_WORKSPACE_UNION; + } + const projected = computeWorkspaceUnion(membership.get(id) ?? [], activity); + const previous = unionsRef.current.get(id); + // One uninterrupted ringing interval per tab: while the Workspace stays + // ringing its summons is the first moment it did, so attending the member + // that set `ringingSince` must not advance it to a survivor's later start + // and re-burst the tab. + const next = previous?.ringing && projected.ringing + ? { ...projected, ringingSince: previous.ringingSince } + : projected; // Hand back the previous object when nothing in it changed, so a memoized // tab re-renders only when its own indicators do. - const previous = unionsRef.current.get(id); if (previous && previous.ringing === next.ringing && previous.todo === next.todo - && previous.count === next.count) return previous; + && previous.count === next.count && previous.ringingSince === next.ringingSince) return previous; unionsRef.current.set(id, next); return next; }; @@ -182,7 +191,6 @@ export function WorkspaceStrip({ name={workspace.name} active={isActive} union={unionFor(workspace.id, isActive)} - ringCue={ringCues.current.get(workspace.id)} renaming={renamingId === workspace.id} dragging={draggingId === workspace.id} registerElement={registerElement} @@ -257,7 +265,6 @@ const WorkspaceTab = memo(function WorkspaceTab({ name, active, union, - ringCue, renaming, dragging, registerElement, @@ -273,7 +280,6 @@ const WorkspaceTab = memo(function WorkspaceTab({ name: string; active: boolean; union: WorkspaceUnion; - ringCue: WorkspaceRingCue; renaming: boolean; dragging: boolean; registerElement: (element: HTMLElement | null) => (() => void) | undefined; @@ -288,8 +294,14 @@ const WorkspaceTab = memo(function WorkspaceTab({ const todoPill = useTodoPillContent(union.todo); // The visible Workspace shows its Surfaces, so its indicators would say what // the panes already say; only a hidden one needs them. - const showIndicators = !active && (union.ringing || todoPill.visible); - const label = showIndicators && union.count > 0 ? `${name}, ${union.count} needing attention` : name; + const showAlarmInset = !active && union.ringing; + const showTodoPill = !active && todoPill.visible; + const burst = useAlertRingBurst( + showAlarmInset ? 'ringing' : null, + union.ringingSince === null ? null : { startedAt: union.ringingSince }, + ); + const label = (showAlarmInset || showTodoPill) && union.count > 0 + ? `${name}, ${union.count} needing attention` : name; return ( <div @@ -344,21 +356,12 @@ const WorkspaceTab = memo(function WorkspaceTab({ }} > <span className="min-w-0 flex-1 truncate">{name}</span> - {showIndicators && ( - <span className="flex shrink-0 items-center gap-1.5"> - {todoPill.visible && ( - <span - className={`todo-pill-shell text-xs font-semibold ${TODO_PILL_TRACKING_CLASS}`} - data-flourishing={todoPill.flourishing ? 'true' : 'false'} - > - {todoPill.body} - </span> - )} - {union.ringing && ( - <span className="text-alarm-vs-header-inactive"> - <AlertBell status="ALERT_RINGING" ringSeq={ringCue.sequence} ringStartedAt={ringCue.at} size={11} /> - </span> - )} + {showTodoPill && ( + <span + className={`todo-pill-shell shrink-0 text-xs font-semibold ${TODO_PILL_TRACKING_CLASS}`} + data-flourishing={todoPill.flourishing ? 'true' : 'false'} + > + {todoPill.body} </span> )} </button> @@ -378,6 +381,9 @@ const WorkspaceTab = memo(function WorkspaceTab({ <XIcon size={11} weight="bold" aria-hidden="true" /> </button> )} + {showAlarmInset && ( + <AlertRingInset ground="header-inactive" burst={burst} className={TERMINAL_TOP_RADIUS_CLASS} /> + )} </div> ); }); diff --git a/lib/src/components/alert-ring.ts b/lib/src/components/alert-ring.tsx similarity index 56% rename from lib/src/components/alert-ring.ts rename to lib/src/components/alert-ring.tsx index 6eb1b8be5..d683eb353 100644 --- a/lib/src/components/alert-ring.ts +++ b/lib/src/components/alert-ring.tsx @@ -1,7 +1,9 @@ import { useMemo, type CSSProperties } from 'react'; +import { clsx } from 'clsx'; import { cfg } from '../cfg'; import type { AlertEpisode } from '../lib/alert-episode'; import type { AlertRingState, AlertSpeechState, SessionStatus } from '../lib/terminal-registry'; +import { ALERT_RING_INSET_BY_GROUND, ALERT_RING_INSET_CLASS, type AlertRingGround } from './design'; /** * Which row of the alarm treatment a Session wears, or `null` for none. @@ -45,11 +47,11 @@ export function alarmPulseClass(bounded: boolean): string { /** Starts a CSS animation on a clock that began at `startedAt`, so an element * mounted later lands where that animation already is — past its end when it * has already finished. */ -export function animationClockStyle(startedAt: number): CSSProperties { +function animationClockStyle(startedAt: number): CSSProperties { return { animationDelay: `${-Math.max(0, Date.now() - startedAt)}ms` }; } -export interface AlertRingBurstProps { +interface AlertRingBurstProps { /** A fresh episode is a fresh summons, so remounting on it replays the burst; * a second track latching inside one keeps the key and only enriches. */ key: string; @@ -57,31 +59,69 @@ export interface AlertRingBurstProps { style?: CSSProperties; } +/** + * What one burst is anchored to: an identity that changes exactly when a new + * summons begins, and the instant it began. + * + * A Session's `AlertEpisode` is one. A derived summons — a Workspace union's — + * has no id of its own and supplies the instant alone, which serves as both. + */ +export type AlertRingBurstSource = AlertEpisode | { startedAt: number }; + +const burstKey = (source: AlertRingBurstSource): string => + 'id' in source ? source.id : String(source.startedAt); + /** Everything one alarm element needs to move for `row`, bundled so the class, * the remount key, and the clock can never be applied apart. */ -export function alertRingBurstProps(row: AlertRingState, episode: AlertEpisode): AlertRingBurstProps { +function alertRingBurstProps(row: AlertRingState, source: AlertRingBurstSource): AlertRingBurstProps { const bounded = row === 'ringing'; const className = row === 'spoken' ? '' : alarmPulseClass(bounded); return { - key: episode.id, + key: burstKey(source), className, // Only a burst has a clock to anchor, and only one the freeze left running. - style: bounded && className ? animationClockStyle(episode.startedAt) : undefined, + style: bounded && className ? animationClockStyle(source.startedAt) : undefined, }; } -/** `alertRingBurstProps` anchored once per episode: recomputing the clock on a +/** `alertRingBurstProps` anchored once per summons: recomputing the clock on a * later render would shove a live burst back to its start. */ export function useAlertRingBurst( row: AlertRingState | null, - episode: AlertEpisode | null | undefined, + source: AlertRingBurstSource | null | undefined, ): AlertRingBurstProps | null { - const id = episode?.id; - const startedAt = episode?.startedAt; + const key = source == null ? undefined : burstKey(source); + const startedAt = source?.startedAt; return useMemo( - () => (row && id !== undefined && startedAt !== undefined - ? alertRingBurstProps(row, { id, startedAt }) + () => (row && key !== undefined && startedAt !== undefined + ? alertRingBurstProps(row, { id: key, startedAt }) : null), - [row, id, startedAt], + [row, key, startedAt], + ); +} + +/** + * The alarm's edge, as one overlay child of a `relative` box that clips. + * + * Every alarm surface draws the same 2px inset and differs only in `ground` — + * the background its token was contrast-picked against — so the shadow literals + * stay in one table and no call site restates them. `className` carries the + * host's own geometry (its corner radius), because the overlay has to repeat it + * or the parent's clip notches a square ring's corners away. + */ +export function AlertRingInset({ ground, burst, className }: { + ground: AlertRingGround; + /** `useAlertRingBurst`'s result; `null` leaves a static edge with no motion. */ + burst: AlertRingBurstProps | null; + className?: string; +}) { + return ( + <span + key={burst?.key} + data-alert-ring-inset={ground} + aria-hidden + style={burst?.style} + className={clsx(ALERT_RING_INSET_CLASS, ALERT_RING_INSET_BY_GROUND[ground], burst?.className, className)} + /> ); } diff --git a/lib/src/components/bell-icon-class.test.ts b/lib/src/components/bell-icon-class.test.ts deleted file mode 100644 index 3b122dbc5..000000000 --- a/lib/src/components/bell-icon-class.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index e2ffee9a2..000000000 --- a/lib/src/components/bell-icon-class.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { cfg } from '../cfg'; -import type { SessionStatus } from '../lib/terminal-registry'; - -/** Returns the Tailwind className string for a BellIcon's rotation/animation based on alert status. */ -export function bellIconClass(status: SessionStatus): string { - return [ - 'transition-transform', - status === 'MIGHT_BE_BUSY' && '-rotate-[22.5deg]', - (status === 'BUSY' || status === 'OSC_NOTIF_BUSY' || status === 'COMMAND_EXIT_ARMED') && 'rotate-45', - status === 'MIGHT_NEED_ATTENTION' && 'rotate-[60deg]', - status === 'ALERT_RINGING' && ( - cfg.alert.ringingPaused - ? 'rotate-45' - : 'rotate-45 motion-safe:animate-bell-ring' - ), - ].filter(Boolean).join(' '); -} diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 93ca93114..60f8de0aa 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -32,14 +32,19 @@ export const DOOR_TAB_CLASS = clsx( TERMINAL_TOP_RADIUS_CLASS, ); -/** The Door's alarm ring, painted by one overlay child so the ringing and - * `SPOKEN` rows draw the same edge. It repeats the Door's radius because the - * parent's `overflow-hidden` clip would otherwise notch a square ring's - * corners away. */ -export const DOOR_ALARM_INSET_CLASS = clsx( - 'pointer-events-none absolute inset-0 shadow-[inset_0_0_0_2px_var(--color-alarm-vs-door)]', - TERMINAL_TOP_RADIUS_CLASS, -); +/** The surface an alarm inset is drawn on, which is what picks its token: each + * `--color-alarm-vs-*` is contrast-picked against one background. */ +export type AlertRingGround = 'door' | 'header-active' | 'header-inactive'; + +export const ALERT_RING_INSET_CLASS = 'pointer-events-none absolute inset-0'; + +/** Spelled out per ground, never built from a template — Tailwind's scanner + * reads source text, so a composed arbitrary value would never be emitted. */ +export const ALERT_RING_INSET_BY_GROUND: Record<AlertRingGround, string> = { + door: 'shadow-[inset_0_0_0_2px_var(--color-alarm-vs-door)]', + 'header-active': 'shadow-[inset_0_0_0_2px_var(--color-alarm-vs-header-active)]', + 'header-inactive': 'shadow-[inset_0_0_0_2px_var(--color-alarm-vs-header-inactive)]', +}; // The Workspace strip's two halves of one idea: the selected tab is seated // against the Wall, and the rest recede into the app ground. Keep them diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index fd7c3cc5b..ab7f79407 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -157,18 +157,18 @@ it('keeps the focus ring on an in-flight button while withholding only its hover it('shows both directories in the mismatch warning', () => { props.mismatch = true; props.helperCwd = '~/other'; render(); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/other'); expect(container.querySelector('[role="alert"]')?.textContent).toContain('~/repo'); }); -it('shares header, alert and uncaptured body entry points; captured mouse has no Shift escape', () => { +it('shares header and uncaptured body entry points; captured mouse has no Shift escape', () => { const open = vi.fn(); const value = { id: null, mounted: null, open, close: vi.fn(), promote: vi.fn(), openPort: vi.fn() }; act(() => root.render(<TerminalContextContext.Provider value={value}><TerminalPaneHeader id="parent" /><TerminalPanel id="parent" /></TerminalContextContext.Provider>)); const header = container.querySelector('[data-pane-header-for]')!; const body = container.querySelector('textarea')!; const rightClick = (target: Element, shiftKey = false) => act(() => target.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, shiftKey, clientX: 120, clientY: 90 }))); - rightClick(header); rightClick(container.querySelector('[data-alert-button-for]')!); rightClick(body); - expect(open).toHaveBeenCalledTimes(3); + rightClick(header); rightClick(body); + expect(open).toHaveBeenCalledTimes(2); for (const call of open.mock.calls) expect(call).toEqual(['parent', { origin: { x: 120, y: 90 } }]); act(() => setMouseReporting('parent', 'vt200')); - rightClick(body); rightClick(body, true); expect(open).toHaveBeenCalledTimes(3); - rightClick(header); expect(open).toHaveBeenCalledTimes(4); + rightClick(body); rightClick(body, true); expect(open).toHaveBeenCalledTimes(2); + rightClick(header); expect(open).toHaveBeenCalledTimes(3); }); it('opens the parent notepad from the Helper control and keeps edits on that parent', async () => { diff --git a/lib/src/components/wall/TerminalPaneHeader.test.tsx b/lib/src/components/wall/TerminalPaneHeader.test.tsx index bd8cfecee..c15c5f102 100644 --- a/lib/src/components/wall/TerminalPaneHeader.test.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.test.tsx @@ -11,7 +11,7 @@ import { ensureResizeObserver, stubResizeObserver, stubWallActions as stubAction import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; import { setPlatform } from '../../lib/platform'; import { setNativeFieldValue } from '../../lib/dom'; -import { removeTerminalPaneState } from '../../lib/terminal-registry'; +import { clearTerminalActivity, removeTerminalPaneState, setTerminalActivity } from '../../lib/terminal-registry'; import { removeMouseSelectionState, setMouseReporting } from '../../lib/mouse-selection'; import { recordToolDirty, resetToolDirty } from '../../lib/tool-dirty-store'; import { @@ -40,6 +40,7 @@ afterEach(() => { act(() => root.unmount()); container.remove(); platform.reset(); + clearTerminalActivity('term-1'); removeTerminalPaneState('term-1'); }); @@ -64,6 +65,23 @@ function renameInput(): HTMLInputElement { return input!; } +describe('TerminalPaneHeader — alert state', () => { + /** The header is untinted whatever the Session's status: the Pane overlay's + * perimeter ring is the whole treatment (`docs/specs/alert.md` -> Pane + * Header), and a second tinted surface would double-report it. */ + it('never tints for a ringing Session, and offers it no control of its own', () => { + renderHeader(stubActions(), null); + const quiet = container.querySelector<HTMLElement>('[data-pane-header-for="term-1"]')!.className; + + act(() => { setTerminalActivity('term-1', { status: 'ALERT_RINGING' }); }); + + const header = container.querySelector<HTMLElement>('[data-pane-header-for="term-1"]')!; + expect(header.className).toBe(quiet); + expect(header.innerHTML).not.toContain('alarm-vs'); + expect(container.querySelector('[data-alert-ring-inset]')).toBeNull(); + }); +}); + describe('TerminalPaneHeader — inline rename', () => { it('clicking the title starts a rename', () => { const onStartRename = vi.fn(); @@ -184,7 +202,6 @@ describe('TerminalPaneHeader — notepad icon', () => { const labels = Array.from(container.querySelectorAll<HTMLElement>('button[aria-label]')) .map((button) => button.getAttribute('aria-label')); expect(labels).toEqual([ - 'Alert settings', 'Override mouse capture', 'Notepad', 'Split left/right', diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 8a6c55aea..3bb82b070 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -11,7 +11,6 @@ import { import { ToolDirtyIndicator, useToolDirty } from '../ToolDirtyIndicator'; import { HeaderActionButton } from '../HeaderActionButton'; import { HEADER_PALETTE_TRANSITION_CLASS, POPUP_SURFACE_CLASS, TERMINAL_TOP_RADIUS_CLASS, TODO_PILL_TRACKING_CLASS } from '../design'; -import { AlertBell } from '../AlertBell'; import { useTodoPillContent } from '../TodoPillBody'; import { useHeaderTier } from './use-header-tier'; import { NotepadHeaderButton } from './NotepadHeaderButton'; @@ -27,7 +26,6 @@ import { import { clearSessionTodo, DEFAULT_ACTIVITY_STATE, - dismissSessionAlert, getActivitySnapshot, getTerminalPaneStateSnapshot, subscribeToActivity, @@ -128,7 +126,6 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { const isActiveHeader = mode === 'passthrough' && isSelected && windowFocused; const isRenaming = renamingId === id; const tabRef = useRef<HTMLDivElement>(null); - const suppressAlertClickRef = useRef(false); const tier = useHeaderTier(tabRef, terminalHeaderTier); const [todoPreviewRect, setTodoPreviewRect] = useState<DOMRect | null>(null); const [renameWarning, setRenameWarning] = useState<{ rect: DOMRect; reason: RenameRejection; value: string } | null>(null); @@ -140,14 +137,6 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { const roomForNotepad = compactOrWider || tier === 'minimal' || (tier === 'minimal-tight' && !dirty); const tiny = tier === 'tiny'; const showTodoPill = todoPill.visible && compactOrWider; - // The alert button dismisses and opens the terminal context; it never edits - // a rule, so it names that action (`docs/specs/alert.md` -> Pane Header). - const ringing = activity.status === 'ALERT_RINGING'; - const alertButtonAriaLabel = ringing ? 'Dismiss alert' : 'Alert settings'; - const alertButtonTooltip = `[a] ${alertButtonAriaLabel}`; - const alertButtonTooltipDetail = ringing - ? 'Click to dismiss and show options' - : 'Click or right-click for options'; const todoNotificationPreview = formatNotificationPreview(activity.notification); const todoPreviewId = `todo-notification-preview-${id}`; @@ -167,12 +156,6 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { setTodoPreviewRect(button.getBoundingClientRect()); }, [activity.notification]); - const triggerAlertButtonAction = useCallback((button: HTMLButtonElement) => { - dismissSessionAlert(id); - const rect = button.getBoundingClientRect(); - context.open(id, { origin: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } }); - }, [id, context]); - useEffect(() => { if (!activity.notification) setTodoPreviewRect(null); }, [activity.notification]); @@ -184,7 +167,8 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { className={tabVariant({ state: isActiveHeader ? 'active' : 'inactive' })} onMouseDown={() => actions.onClickPanel(id)} onContextMenu={(e) => { - // Header and alert entry points share the terminal context. + // The whole header is the terminal context's entry point; `[a]` opens + // the same menu anchored here (`docs/specs/alert.md` -> Pane Header). e.preventDefault(); e.stopPropagation(); context.open(id, { origin: { x: e.clientX, y: e.clientY } }); @@ -217,39 +201,6 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { )} </span> )} - <HeaderActionButton - className={[ - 'flex h-5 min-w-5 items-center justify-center rounded transition-colors shrink-0 hover:bg-current/10', - ringing - ? (isActiveHeader ? 'text-alarm-vs-header-active' : 'text-alarm-vs-header-inactive') - : '', - ].join(' ')} - onMouseDownCapture={(e) => { - if (e.button !== 0) return; - suppressAlertClickRef.current = true; - e.preventDefault(); - e.stopPropagation(); - e.nativeEvent.stopImmediatePropagation?.(); - triggerAlertButtonAction(e.currentTarget); - }} - onClick={(e) => { - if (suppressAlertClickRef.current) { - suppressAlertClickRef.current = false; - return; - } - triggerAlertButtonAction(e.currentTarget); - }} - onContextMenu={(e) => context.open(id, { origin: { x: e.clientX, y: e.clientY } })} - ariaLabel={alertButtonAriaLabel} - tooltip={alertButtonTooltip} - tooltipDetail={alertButtonTooltipDetail} - tooltipAlign="left" - dataAlertButtonFor={id} - > - <span className="flex items-center justify-center"> - <AlertBell status={activity.status} ringSeq={activity.ringSeq} size={14} /> - </span> - </HeaderActionButton> {showTodoPill && ( <button type="button" @@ -315,7 +266,7 @@ export function TerminalPaneHeader({ id, title, params }: PaneProps) { ><SplitVerticalIcon size={14} /></HeaderActionButton> </div> )} - {/* The title/bell region clips via `overflow-hidden` so this group + {/* The title region clips via `overflow-hidden` so this group never has to (`docs/specs/layout.md` → "Pane header responsive sizing"). */} <PaneActionGroup surfaceId={id} zoomed={zoomed} activeHeader={isActiveHeader} showMinimizeKill={!tiny} /> diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 04a4a272d..16312013a 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -830,7 +830,7 @@ describe('AlertManager in isolation', () => { manager.attend(id); manager.clearAttention(id); - // Armed underneath, but the monitor's own state is what the bell shows. + // Armed underneath, but the monitor's own state is what is published. expect(manager.getState(id).status).toBe('NOTHING_TO_SHOW'); manager.setWatchedCommands([]); diff --git a/lib/src/lib/platform/fake-adapter.ts b/lib/src/lib/platform/fake-adapter.ts index 000b4d6da..f18ab9ccf 100644 --- a/lib/src/lib/platform/fake-adapter.ts +++ b/lib/src/lib/platform/fake-adapter.ts @@ -368,12 +368,12 @@ export class FakePtyAdapter implements PlatformAdapter { * the alert-manager's activity feed the same way real PTY data does in * the Tauri/VSCode adapters — without this, browser-side echo (e.g. * TutorialShell's per-character echo, AsciiSplashRunner frames) never - * reaches the activity monitor and the bell can never tilt or ring. + * reaches the activity monitor and a pane can never ring. * * Pass `{ skipActivity: true }` for writes that are pure UI chrome and * shouldn't count as a "task is active" signal — e.g. a tutorial TUI * re-rendering its menu on state change. Without the opt-out, every - * runner frame would tilt the bell on whichever pane hosts the runner. + * runner frame would look like work on whichever pane hosts the runner. */ sendOutput(id: string, data: string, options: { skipActivity?: boolean } = {}): void { if (!this.terminals.has(id)) return; @@ -385,7 +385,7 @@ export class FakePtyAdapter implements PlatformAdapter { * no data output — useful for animating a fake "task running" state on * a pane while the visual feedback lives elsewhere. Calls * `alertManager.onData(id)` immediately, then again every `intervalMs` - * until `durationMs` elapses, after which silence resumes and the bell + * until `durationMs` elapses, after which silence resumes and the Session * transitions naturally to MIGHT_NEED_ATTENTION → ALERT_RINGING. * Returns a dispose handle that cancels remaining ticks. */ diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index a569bd330..424a7a236 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -75,8 +75,9 @@ export function getInheritableCwd(id: string): string | undefined { /** * The bare program name of the pane's foreground command, or null when the pane * is at a prompt (or its shell reported no command line). This is the key the - * WATCHING rule set is stored under, so the bell and the alert dialog both use - * it to decide which rule they are toggling — see `docs/specs/alert.md`. + * WATCHING rule set is stored under, so the terminal context and the alert + * dialog both use it to decide which rule they are toggling — see + * `docs/specs/alert.md`. */ export function getRunningCommandArgv0(id: string): string | null { const raw = paneStates.get(id)?.currentCommand?.rawCommandLine; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 2f7ed54b8..2a7778d09 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -322,7 +322,7 @@ describe('command title summarizer', () => { }); // One name per program: the launcher suffix is dropped everywhere, so the - // header reads the same name as the WATCHING rule row and the bell tooltip. + // header reads the same name as the WATCHING rule row and the terminal context. it('reads a Windows launcher as the program it launches', () => { expect(summarizeCommandLine('vim.exe notes.txt')).toBe('vim'); expect(summarizeCommandLine('cargo.exe watch -x test')).toBe('cargo watch -x test'); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index c795f5f58..086932cfd 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -1015,7 +1015,7 @@ export const WINDOWS_EXECUTABLE_SUFFIX = /\.(?:exe|cmd|bat|com|ps1)$/i; /** * argv[0] reduced to the one name a program answers to: no path, no launcher * suffix. The single answer to "which program is this", so the header, the - * WATCHING rule row and the bell tooltip cannot disagree about it. + * WATCHING rule row and the terminal context cannot disagree about it. */ export function commandProgramName(command: string): string { return commandBasename(command).replace(WINDOWS_EXECUTABLE_SUFFIX, ''); diff --git a/lib/src/lib/workspace-ring-cues.test.ts b/lib/src/lib/workspace-ring-cues.test.ts deleted file mode 100644 index ed8494d44..000000000 --- a/lib/src/lib/workspace-ring-cues.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { expect, it } from 'vitest'; -import { WorkspaceRingCues } from './workspace-ring-cues'; -import { DEFAULT_ACTIVITY_STATE } from './session-activity-store'; - -it('observes a lower-counter child without mistaking membership changes for rings', () => { - const cues = new WorkspaceRingCues(); - const membership = new Map([['w', ['a', 'b']]]); - const activity = new Map([ - ['a', { ...DEFAULT_ACTIVITY_STATE, status: 'ALERT_RINGING' as const, ringSeq: 7 }], - ['b', { ...DEFAULT_ACTIVITY_STATE, status: 'ALERT_RINGING' as const, ringSeq: 1 }], - ]); - const update = () => cues.update(['w'], membership, activity); - update(); - expect(cues.get('w')).toEqual({ sequence: 0, at: null }); - activity.set('b', { ...activity.get('b')!, ringSeq: 2 }); - update(); - const cue = cues.get('w'); - expect(cue.sequence).toBe(1); - membership.set('w', ['b']); - update(); - expect(cues.get('w')).toBe(cue); - membership.set('w', ['a', 'b']); - update(); - expect(cues.get('w')).toBe(cue); - update(); // selecting a different Workspace doesn't change membership/evidence. - expect(cues.get('w')).toBe(cue); -}); - -it('seeds new, transferred, or restored members and forgets closed Workspaces', () => { - const cues = new WorkspaceRingCues(); - const activity = new Map([['a', { ...DEFAULT_ACTIVITY_STATE, status: 'ALERT_RINGING' as const, ringSeq: 5 }]]); - cues.update(['w', 'v'], new Map([['w', ['a']]]), activity); - cues.update(['w', 'v'], new Map([['v', ['a']]]), activity); - expect(cues.get('v').sequence).toBe(0); - activity.set('a', { ...activity.get('a')!, ringSeq: 6 }); - cues.update(['v'], new Map([['v', ['a']]]), activity); - expect(cues.get('v').sequence).toBe(1); - cues.update([], new Map(), activity); - expect(cues.get('v')).toEqual({ sequence: 0, at: null }); -}); - -it('forgets a departed member replaced by one without Activity, so its rejoin seeds silently', () => { - const cues = new WorkspaceRingCues(); - const activity = new Map([['a', { ...DEFAULT_ACTIVITY_STATE, status: 'ALERT_RINGING' as const, ringSeq: 1 }]]); - cues.update(['w'], new Map([['w', ['a']]]), activity); - // `a` leaves and `b` joins before its Activity entry exists: same size, different ids. - cues.update(['w'], new Map([['w', ['b']]]), activity); - activity.set('a', { ...activity.get('a')!, ringSeq: 3 }); - cues.update(['w'], new Map([['w', ['a', 'b']]]), activity); - expect(cues.get('w').sequence).toBe(0); -}); diff --git a/lib/src/lib/workspace-ring-cues.ts b/lib/src/lib/workspace-ring-cues.ts deleted file mode 100644 index 74ee34c8f..000000000 --- a/lib/src/lib/workspace-ring-cues.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { ActivityState } from './session-activity-store'; - -export interface WorkspaceRingCue { sequence: number; at: number | null } -interface Observation { - members: Map<string, number>; - cue: WorkspaceRingCue; -} - -/** Presentation edges only: joining/leaving a Workspace is not a new alert. */ -export class WorkspaceRingCues { - private readonly workspaces = new Map<string, Observation>(); - - /** Runs on every activity notification, so it mutates the retained observation in place. */ - update(workspaceIds: readonly string[], membership: ReadonlyMap<string, readonly string[]>, activity: ReadonlyMap<string, ActivityState>): void { - const live = new Set(workspaceIds); - for (const id of this.workspaces.keys()) if (!live.has(id)) this.workspaces.delete(id); - for (const id of workspaceIds) { - let observation = this.workspaces.get(id); - if (!observation) this.workspaces.set(id, observation = { members: new Map(), cue: { sequence: 0, at: null } }); - const { members } = observation; - const current = new Set(membership.get(id) ?? []); - // A departed member forgets its counter, so rejoining seeds it silently. - for (const sessionId of members.keys()) if (!current.has(sessionId)) members.delete(sessionId); - let fresh = false; - for (const sessionId of current) { - const state = activity.get(sessionId); - if (!state) { members.delete(sessionId); continue; } - const before = members.get(sessionId); - if (before !== undefined && state.ringSeq > before && state.status === 'ALERT_RINGING') fresh = true; - members.set(sessionId, state.ringSeq); - } - if (fresh) observation.cue = { sequence: observation.cue.sequence + 1, at: Date.now() }; - } - } - - get(id: string): WorkspaceRingCue { - return this.workspaces.get(id)?.cue ?? { sequence: 0, at: null }; - } -} diff --git a/lib/src/lib/workspace-union.test.ts b/lib/src/lib/workspace-union.test.ts index 1e26da996..d71c7dd2e 100644 --- a/lib/src/lib/workspace-union.test.ts +++ b/lib/src/lib/workspace-union.test.ts @@ -14,6 +14,8 @@ function activity(entries: Record<string, Partial<ActivityState>>): Map<string, return new Map(Object.entries(entries).map(([id, partial]) => [id, { ...base, ...partial }])); } +const episode = (id: string, startedAt: number) => ({ id, startedAt }); + describe('computeWorkspaceUnion', () => { it('is empty when no surface owes attention', () => { const union = computeWorkspaceUnion(['a', 'b'], activity({ a: {}, b: { status: 'BUSY' } })); @@ -22,22 +24,22 @@ describe('computeWorkspaceUnion', () => { it('reports ringing when any terminal Session is ALERT_RINGING', () => { const union = computeWorkspaceUnion(['a', 'b'], activity({ a: {}, b: { status: 'ALERT_RINGING' } })); - expect(union).toEqual({ ringing: true, todo: false, count: 1 }); + expect(union).toEqual({ ringing: true, todo: false, count: 1, ringingSince: null }); }); it('reports todo for a flagged terminal Session', () => { const union = computeWorkspaceUnion(['a'], activity({ a: { todo: true } })); - expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringingSince: null }); }); it('counts a browser Surface TODO (no ring) — status stays WATCHING_DISABLED', () => { const union = computeWorkspaceUnion(['web'], activity({ web: { status: 'WATCHING_DISABLED', todo: true } })); - expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringingSince: null }); }); it('counts a surface that is both ringing and todo only once', () => { const union = computeWorkspaceUnion(['a'], activity({ a: { status: 'ALERT_RINGING', todo: true } })); - expect(union).toEqual({ ringing: true, todo: true, count: 1 }); + expect(union).toEqual({ ringing: true, todo: true, count: 1, ringingSince: null }); }); it('sums distinct surfaces owing attention', () => { @@ -45,20 +47,35 @@ describe('computeWorkspaceUnion', () => { ['a', 'b', 'c', 'd'], activity({ a: { status: 'ALERT_RINGING' }, b: { todo: true }, c: { status: 'BUSY' }, d: {} }), ); - expect(union).toEqual({ ringing: true, todo: true, count: 2 }); + expect(union).toEqual({ ringing: true, todo: true, count: 2, ringingSince: null }); }); it('ignores surface ids with no activity entry', () => { const union = computeWorkspaceUnion(['a', 'missing'], activity({ a: { todo: true } })); - expect(union).toEqual({ ringing: false, todo: true, count: 1 }); + expect(union).toEqual({ ringing: false, todo: true, count: 1, ringingSince: null }); + }); + + it('starts ringing at the earliest ringing member, whatever the iteration order', () => { + const union = computeWorkspaceUnion( + ['a', 'b', 'c'], + activity({ + b: { status: 'ALERT_RINGING', episode: episode('later', 2_000) }, + a: { status: 'ALERT_RINGING', episode: episode('first', 1_000) }, + c: { todo: true }, + }), + ); + expect(union).toEqual({ ringing: true, todo: true, count: 3, ringingSince: 1_000 }); }); - it('keeps presentation generations out of the semantic union', () => { + it('ignores a quiet member\'s stale episode', () => { const union = computeWorkspaceUnion( ['a', 'b'], - activity({ a: { status: 'ALERT_RINGING', ringSeq: 3 }, b: { status: 'ALERT_RINGING', ringSeq: 7 } }), + activity({ + a: { status: 'BUSY', episode: episode('stale', 1) }, + b: { status: 'ALERT_RINGING', episode: episode('live', 9) }, + }), ); - expect(union).toEqual({ ringing: true, todo: false, count: 2 }); + expect(union.ringingSince).toBe(9); }); it('is empty for an empty surface set', () => { diff --git a/lib/src/lib/workspace-union.ts b/lib/src/lib/workspace-union.ts index 2e59886c8..5fc4c1148 100644 --- a/lib/src/lib/workspace-union.ts +++ b/lib/src/lib/workspace-union.ts @@ -12,9 +12,12 @@ export interface WorkspaceUnion { todo: boolean; /** Number of member Surfaces owing attention (ringing or todo); each counts once. */ count: number; + /** When this Workspace started ringing: the earliest ringing member's episode + * start, or `null` when none rings. The union's own summons, not a member's. */ + ringingSince: number | null; } -export const EMPTY_WORKSPACE_UNION: WorkspaceUnion = { ringing: false, todo: false, count: 0 }; +export const EMPTY_WORKSPACE_UNION: WorkspaceUnion = { ringing: false, todo: false, count: 0, ringingSince: null }; /** * Project the union over a Workspace's member Surfaces. `surfaceIds` are the @@ -29,6 +32,7 @@ export function computeWorkspaceUnion( let ringing = false; let todo = false; let count = 0; + let ringingSince: number | null = null; for (const id of surfaceIds) { const state = activity.get(id); if (!state) continue; @@ -37,6 +41,11 @@ export function computeWorkspaceUnion( if (isRinging) ringing = true; if (isTodo) todo = true; if (isRinging || isTodo) count += 1; + // The earliest start, so a second Session joining an already-ringing + // Workspace does not restart the tab's arrival burst. + if (isRinging && state.episode && (ringingSince === null || state.episode.startedAt < ringingSince)) { + ringingSince = state.episode.startedAt; + } } - return { ringing, todo, count }; + return { ringing, todo, count, ringingSince }; } diff --git a/lib/src/remote/pocket-app/wall-model.test.ts b/lib/src/remote/pocket-app/wall-model.test.ts index 6b80dedef..199b00a57 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, ringSeq: 0, todo: false }, - { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, ringSeq: 0, todo: false }, + { id: 's1', title: 'zsh', secondary: '/home/me', active: false, status: undefined, episode: null, todo: false }, + { id: 's2', title: 'vim', secondary: null, active: true, status: undefined, episode: null, todo: false }, ]); }); @@ -104,7 +104,7 @@ describe('directorySessionItems', () => { 's1', ); expect(items).toEqual([ - { id: 's2', title: 'alive', secondary: null, active: false, status: undefined, ringSeq: 0, todo: false }, + { id: 's2', title: 'alive', secondary: null, active: false, status: undefined, episode: null, todo: false }, ]); }); }); diff --git a/lib/src/remote/pocket-app/wall-model.ts b/lib/src/remote/pocket-app/wall-model.ts index 6f2fc746f..46f30cebd 100644 --- a/lib/src/remote/pocket-app/wall-model.ts +++ b/lib/src/remote/pocket-app/wall-model.ts @@ -26,9 +26,10 @@ export function directoryWallSessions(entries: DirectoryEntry[]): MobileWallSess /** * Map the directory snapshot onto the affordances a {@link MobileTerminalSessionItem} - * exposes: `ringing` → `ALERT_RINGING` (the only status the session list renders - * a bell for), `hasTODO` → the TODO pill, and `cwd`/`activity` → the secondary - * line. `id` is the surfaceId so the registry binds each pane's xterm by it. + * exposes: `ringing` → `ALERT_RINGING` (the only status the session list wears + * the alarm inset for), `hasTODO` → the TODO pill, and `cwd`/`activity` → the + * secondary line. `id` is the surfaceId so the registry binds each pane's xterm + * by it. */ export function directorySessionItems( entries: DirectoryEntry[], @@ -40,10 +41,9 @@ 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, + // `DirectoryEntry.ringing` is an edgeless boolean, so a remote ring has no + // start to clock an arrival burst from; the row wears the static inset. + episode: null, todo: entry.hasTODO, })); } diff --git a/lib/src/stories/Door.stories.tsx b/lib/src/stories/Door.stories.tsx index e1522fd89..dcb8b7e66 100644 --- a/lib/src/stories/Door.stories.tsx +++ b/lib/src/stories/Door.stories.tsx @@ -60,7 +60,6 @@ const meta: Meta<typeof DoorStory> = { args: { title: 'build-server', status: 'WATCHING_DISABLED', - ringSeq: 0, // Stories draw the static treatment; the arrival burst is frozen out by // `cfg.alert.ringingPaused` under Chromatic anyway. episode: null, @@ -70,7 +69,9 @@ const meta: Meta<typeof DoorStory> = { }, argTypes: { title: { control: 'text' }, - status: { control: 'radio', options: ['WATCHING_DISABLED', 'NOTHING_TO_SHOW', 'MIGHT_BE_BUSY', 'BUSY', 'OSC_NOTIF_BUSY', 'COMMAND_EXIT_ARMED', 'MIGHT_NEED_ATTENTION', 'ALERT_RINGING'] }, + // Only the latched ring reaches the Door; every other status draws the same + // plain pill (`docs/specs/alert.md` -> Door). + status: { control: 'radio', options: ['WATCHING_DISABLED', 'ALERT_RINGING'] }, todo: { control: 'boolean' }, speechState: { control: 'radio', options: [undefined, 'speaking', 'spoken'] }, width: { control: 'number' }, @@ -81,28 +82,23 @@ const meta: Meta<typeof DoorStory> = { export default meta; type Story = StoryObj<typeof DoorStory>; -export const AlertDisabled: Story = {}; -export const AlertEnabled: Story = { args: { status: 'NOTHING_TO_SHOW' } }; -export const AlertMightBeBusy: Story = { args: { status: 'MIGHT_BE_BUSY' } }; -export const AlertBusy: Story = { args: { status: 'BUSY' } }; -export const AlertMightNeedAttention: Story = { args: { status: 'MIGHT_NEED_ATTENTION' } }; -export const AlertRinging: Story = { args: { status: 'ALERT_RINGING' } }; +export const Default: Story = {}; +export const Ringing: Story = { args: { status: 'ALERT_RINGING' } }; export const TodoOnly: Story = { args: { todo: true } }; -export const TodoAndAlertEnabled: Story = { args: { todo: true, status: 'NOTHING_TO_SHOW' } }; -export const TodoAndAlertRinging: Story = { args: { todo: true, status: 'ALERT_RINGING' } }; +export const TodoAndRinging: Story = { args: { todo: true, status: 'ALERT_RINGING' } }; export const Speaking: Story = { args: { status: 'ALERT_RINGING', todo: true, speechState: 'speaking' } }; export const HasSpoken: Story = { args: { status: 'ALERT_RINGING', todo: true, speechState: 'spoken' } }; export const LongTitleWithIndicators: Story = { args: { title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name', todo: true, - status: 'NOTHING_TO_SHOW', + status: 'ALERT_RINGING', }, }; /** A Door carrying notes: a second button, filled, that never reattaches. */ export const WithNotes: Story = { - args: { noteCount: 3, status: 'NOTHING_TO_SHOW' }, + args: { noteCount: 3 }, }; export const WithNotesAndIndicators: Story = { diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index 4e5819128..a63ef1891 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -1,6 +1,6 @@ import { useState, type ReactNode } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; -import { BellIcon, FrameCornersIcon, XIcon } from '@phosphor-icons/react'; +import { FrameCornersIcon, XIcon } from '@phosphor-icons/react'; import { PANE_HEADER_HEIGHT_PX } from '../components/design'; import { NotepadHeaderButton } from '../components/wall/NotepadHeaderButton'; import { NotepadPanel } from '../components/NotepadPanel'; @@ -94,7 +94,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena const preserved = ['preserved', 'editor', 'differentDirectory'].includes(scenario); const ports = (scenario === 'multiplePorts' ? [5173, 6006, 9229] : [5173]).map(port => ({ port, host: 'localhost', url: `http://localhost:${port}/`, processName: port === 5173 ? 'vite' : port === 6006 ? 'storybook' : 'node inspector' })); return <div className="relative h-[680px] overflow-hidden rounded-lg bg-terminal-bg font-mono text-sm text-terminal-fg" style={{ width: paneWidth }}> - <div className="flex items-center gap-2 bg-header-active-bg px-2.5 text-header-active-fg" style={{ height: PANE_HEADER_HEIGHT_PX }}><span>pnpm dev</span><BellIcon size={13} /><span className="ml-auto flex items-center gap-3"><FrameCornersIcon size={13} /><XIcon size={13} /></span></div> + <div className="flex items-center gap-2 bg-header-active-bg px-2.5 text-header-active-fg" style={{ height: PANE_HEADER_HEIGHT_PX }}><span>pnpm dev</span><span className="ml-auto flex items-center gap-3"><FrameCornersIcon size={13} /><XIcon size={13} /></span></div> <pre className="m-0 p-3 leading-6 text-muted">{'~/projects/dormouse ❯ pnpm dev\n\n VITE ready\n ➜ Local: http://localhost:5173/'}</pre> <div className="absolute inset-0"> <TerminalContextView title="pnpm dev" surfaceRef="surface:3" cwd={PARENT_DIR} helperCwd={HELPER_DIR} mismatch={scenario === 'differentDirectory'} diff --git a/lib/src/stories/TerminalPaneHeader.stories.tsx b/lib/src/stories/TerminalPaneHeader.stories.tsx index 5d29aaee7..64cdf2d54 100644 --- a/lib/src/stories/TerminalPaneHeader.stories.tsx +++ b/lib/src/stories/TerminalPaneHeader.stories.tsx @@ -68,8 +68,8 @@ interface PanePriming { notification?: ActivityNotification; /** * The foreground command, reported the way shell integration would; `null` is - * a pane at its prompt. WATCHING is keyed on its name, so without one the bell - * has no rule to name and every dialog renders its "nothing is running" variant. + * a pane at its prompt. WATCHING is keyed on its name, so without one there is + * no rule to name and every dialog renders its "nothing is running" variant. */ command?: string | null; /** @@ -200,7 +200,7 @@ function ContextWallStory() { return <div className="flex flex-col" style={{ width: 900, height: 680 }}><Wall initialPaneIds={[SESSION_ID]} initialMode="command" /></div>; } -/** Open the terminal context from the bell of a Wall whose one pane is `pane`. */ +/** Open the terminal context from the header of a Wall whose one pane is `pane`. */ function contextDialogStory(pane: PanePriming): Story { return { render: ContextWallStory, @@ -210,20 +210,20 @@ function contextDialogStory(pane: PanePriming): Story { // Output for the pane's terminal, which `settleTerminals` waits on. fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) }, }, - play: openAlertRightClickDialog, + play: openHeaderRightClickDialog, }; } /** Wait for priming before opening the source's alert controls in context. */ -async function openAlertRightClickDialog() { +async function openHeaderRightClickDialog() { await waitForPrimedState(); - const alertButton = await requireElement<HTMLButtonElement>( - `[data-alert-button-for="${SESSION_ID}"]`, - 'alert bell', + const header = await requireElement<HTMLElement>( + `[data-pane-header-for="${SESSION_ID}"]`, + 'pane header', ); - const rect = alertButton.getBoundingClientRect(); - alertButton.dispatchEvent(new MouseEvent('contextmenu', { + const rect = header.getBoundingClientRect(); + header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, @@ -234,38 +234,6 @@ async function openAlertRightClickDialog() { await settleTerminals(); } -/** - * Hover the bell so its tooltip renders — the tooltip is what names the action, - * e.g. `[a] Dismiss alert` vs `[a] Alert settings`. - * - * Hover rather than focus: a programmatic `.focus()` does not reliably drive - * React's `onFocus` here, while `mouseover` is exactly what React synthesizes - * `onMouseEnter` from. Retried because the primed-state decorator applies over - * two rAFs and can re-render the header out from under an early hover; throws - * if the tooltip never appears, so a regression surfaces in the Interactions - * panel instead of as a silently empty snapshot. - */ -async function hoverAlertButton() { - await waitForPrimedState(); - const start = performance.now(); - while (performance.now() - start < RETRY_BUDGET_MS) { - const bell = document.querySelector<HTMLButtonElement>(`[data-alert-button-for="${SESSION_ID}"]`); - const rect = bell?.getBoundingClientRect(); - if (bell && rect) { - bell.dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - cancelable: true, - relatedTarget: document.body, - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - })); - } - await wait(50); - if (document.querySelector('[role="tooltip"]')) return; - } - throw new Error('alert bell tooltip never rendered'); -} - /** * Open the TODO pill's notification preview. * @@ -274,8 +242,8 @@ async function hoverAlertButton() { * is exactly what React synthesizes `onMouseEnter` from — neither adds a visual * state of its own (the pill's hover tint is CSS `:hover`, which a synthetic * event never sets). Retried, and throws if the preview never appears, for the - * same reason as `hoverAlertButton`: silently snapshotting a header with no - * preview is the failure this story exists to catch. + * same reason as `openHeaderRightClickDialog`: silently snapshotting a header + * with no preview is the failure this story exists to catch. */ async function openTodoNotificationPreview() { await waitForPrimedState(); @@ -415,34 +383,14 @@ const meta: Meta<typeof TabStory> = { export default meta; type Story = StoryObj<typeof TabStory>; -export const AlertDisabled: Story = { - parameters: primedPane({ status: 'WATCHING_DISABLED' }), -}; - -export const AlertEnabled: Story = { +export const Default: Story = { parameters: primedPane({ status: 'NOTHING_TO_SHOW' }), }; -export const AlertMightBeBusy: Story = { - parameters: primedPane({ status: 'MIGHT_BE_BUSY' }), -}; - -export const AlertBusy: Story = { - parameters: primedPane({ status: 'BUSY' }), -}; - -export const AlertMightNeedAttention: Story = { - parameters: primedPane({ status: 'MIGHT_NEED_ATTENTION' }), -}; - -export const AlertRinging: Story = { - parameters: primedPane({ status: 'ALERT_RINGING' }), -}; - // --- Command-keyed WATCHING (docs/specs/alert.md) -------------------------- // -// The bell acts on the *running command's* rule, not on this pane, so what it -// offers depends on what the pane is running and whether a rule already exists. +// The context acts on the *running command's* rule, not on this pane, so what +// it offers depends on what the pane is running and whether a rule exists. export const AlertRightClickDialog: Story = contextDialogStory({ status: 'NOTHING_TO_SHOW', @@ -455,21 +403,6 @@ export const AlertDialogNoCommandRunning: Story = contextDialogStory({ command: null, }); -export const BellTooltipOffersRule: Story = { - parameters: primedPane({ status: 'WATCHING_DISABLED', command: 'claude --resume' }), - play: hoverAlertButton, -}; - -export const BellTooltipRemovesRule: Story = { - parameters: primedPane({ status: 'NOTHING_TO_SHOW', command: 'claude --resume' }), - play: hoverAlertButton, -}; - -export const BellTooltipNoCommandRunning: Story = { - parameters: primedPane({ status: 'WATCHING_DISABLED', command: null }), - play: hoverAlertButton, -}; - export const TodoOnly: Story = { parameters: primedPane({ status: 'WATCHING_DISABLED', todo: true }), }; @@ -518,29 +451,21 @@ export const NotificationDialogLongBody: Story = contextDialogStory({ command: 'pnpm test', }); -export const TodoAndAlertEnabled: Story = { - parameters: primedPane({ status: 'NOTHING_TO_SHOW', todo: true }), -}; - -export const TodoAndAlertRinging: Story = { - parameters: primedPane({ status: 'ALERT_RINGING', todo: true }), -}; - -export const CompactWidthWithAlert: Story = { +export const CompactWidth: Story = { args: { width: 220, }, parameters: primedPane({ status: 'NOTHING_TO_SHOW' }), }; -export const MinimalWidthWithAlert: Story = { +export const MinimalWidth: Story = { args: { width: 150, }, parameters: primedPane({ status: 'NOTHING_TO_SHOW' }), }; -export const LongTitleWithAlertAndTodo: Story = { +export const LongTitleWithTodoAndRinging: Story = { args: { width: 360, }, @@ -617,7 +542,7 @@ export const NarrowWithMouseCaptureControlsVisible: Story = { }; // Notepad icons with notes across the full, compact, and minimal tiers. -// AlertEnabled and MinimalWidthWithAlert cover the empty notepad. +// Default and MinimalWidth cover the empty notepad. export const NotepadWithNotes: Story = { args: { noteCount: 3 }, parameters: primedPane({ status: 'NOTHING_TO_SHOW' }), diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index 561327da1..5bb324bb2 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -99,8 +99,8 @@ async function minimizeFirstVisiblePane() { } async function openAlertDialog() { - const alertButton = await requireElement<HTMLButtonElement>('[data-alert-button-for]', 'alert bell'); - alertButton.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); + const header = await requireElement<HTMLElement>('[data-pane-header-for]', 'pane header'); + header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); await requireElement('[data-terminal-context]', 'terminal context'); await settleTerminals(); } @@ -161,9 +161,8 @@ export const AlertModalOpen: Story = { }), }, play: async () => { - // Settle first: the bell only offers the context once the primed ALERT_RINGING - // status has landed, so clicking it earlier is a no-op and the story - // snapshots a wall with no context. + // Settle first: the context reports the primed ALERT_RINGING status, so + // opening it earlier would snapshot a wall whose alert rows are still cold. await settleTerminals(); await openAlertDialog(); }, diff --git a/lib/src/theme-colors.css b/lib/src/theme-colors.css index b6bbd281f..978369d5b 100644 --- a/lib/src/theme-colors.css +++ b/lib/src/theme-colors.css @@ -53,7 +53,7 @@ --color-success: var(--vscode-terminal-ansiGreen); /* Alarm — per-surface black/white contrast pick, computed at runtime by - * dynamic-palette.ts from the OKLab lightness of the bg the bell sits on. + * dynamic-palette.ts from the OKLab lightness of the bg the alarm sits on. * The binding below is only the baseline before the dynamic pass runs. */ --color-alarm-vs-header-active: var(--vscode-terminal-ansiYellow); --color-alarm-vs-header-inactive: var(--vscode-terminal-ansiYellow); diff --git a/lib/src/theme.css b/lib/src/theme.css index 24d095a1f..84354e929 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -39,7 +39,6 @@ --text-sm--line-height: 1rem; /* Animation */ - --animate-bell-ring: bell-ring 800ms ease-in-out 4; --animate-alarm-pulse: alarm-pulse 650ms ease-in-out infinite; /* Four cycles, so an unattended ring leaves no animation running. */ --animate-alarm-pulse-burst: alarm-pulse 650ms ease-in-out 4; @@ -60,11 +59,6 @@ body { --font-mono: var(--vscode-editor-font-family); } -@keyframes bell-ring { - 0%, 100% { rotate: 45deg; } - 50% { rotate: -45deg; } -} - @keyframes alarm-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } diff --git a/scripts/pairing-walkthrough/README.md b/scripts/pairing-walkthrough/README.md index 773e73714..ab1f0ffa3 100644 --- a/scripts/pairing-walkthrough/README.md +++ b/scripts/pairing-walkthrough/README.md @@ -79,7 +79,7 @@ it. | 5 | `qr` | Clicks **Set up a phone**, waits for the code, screenshots, crops to the QR, makes a camera-shaped Y4M, and decodes the crop to prove it is legible. → `qr-full.png`, `qr.png`, `qr.y4m`, `invitation-url.txt` | | 6 | `pocket` | Launches a second, isolated Chrome with the fake camera pointed at `qr.y4m`, attaches with `agent-browser connect <port>`, opens the **plain origin**, and gives the page a CDP virtual authenticator. → `05-pocket-first-run.png` | | 7 | `code` | Taps **Scan a setup code**; Pocket's own scanner decodes the fake camera, registers a passkey with the scanned token, signs in, and shows two digits. Reads them, and waits for the Burrow's modal to open. → `06-scanner.png`, `07-code-screen.png`, `08-burrow-pairing-modal.png`, `pairing-code.txt` | -| 8 | `terminal` | Types the two digits into the Burrow's modal and authorizes; waits for Pocket to connect itself and land on the terminal; runs a command from the phone and reads the file it wrote; rings the Burrow and finds the bell on the phone; then leaves to the Burrows view and connects again. → `09-burrow-approved.png` … `14-pocket-reconnected.png`, `terminal-proof.txt`, `notify-proof.txt`, `reconnect-proof.txt` | +| 8 | `terminal` | Types the two digits into the Burrow's modal and authorizes; waits for Pocket to connect itself and land on the terminal; runs a command from the phone and reads the file it wrote; rings the Burrow and finds the alarm on the phone; then leaves to the Burrows view and connects again. → `09-burrow-approved.png` … `14-pocket-reconnected.png`, `terminal-proof.txt`, `notify-proof.txt`, `reconnect-proof.txt` | | 8′ | `mismatch` | (`wrong-code`) Types the *next* two digits instead, and waits for the panel to report a mismatch; checks the paired count did not move and follows the phone back to its list. → `09-burrow-mismatch.png`, `10-pocket-mismatch.png` | | 8′ | `cancel` | (`denied`) Presses the modal's Cancel and waits for the panel to report it; same two checks. → `09-burrow-cancelled.png`, `10-pocket-cancelled.png` | | 7′ | `dead-code` | (`expired-code`) Replaces the camera's Y4M with a blank frame, opens the scanner, and pastes the Burrow's own code re-issued twice — once stamped with a 2023 expiry, once for another origin as well. Waits for the phone's own sentence each time, and checks the two differ. → `06-pocket-expired.png`, `07-pocket-foreign.png` | @@ -158,7 +158,7 @@ not healthier than one that has them. `summary.json` also carries what only a run can know: the decoded pairing URL and how much of its TTL was left, the round trip from Enter to the file the -laptop's shell wrote (`terminal.roundTripMs`, ~220 ms here), the Enter-to-bell +laptop's shell wrote (`terminal.roundTripMs`, ~220 ms here), the Enter-to-alarm time, and the authenticator's `signCount` after each ceremony. `options` holds what the run chose for itself. The setup password is not among them — the Relay mints its own, and a `--keep` run is signed into by hand with the diff --git a/scripts/pairing-walkthrough/steps.mjs b/scripts/pairing-walkthrough/steps.mjs index 38018191f..93b04eca6 100644 --- a/scripts/pairing-walkthrough/steps.mjs +++ b/scripts/pairing-walkthrough/steps.mjs @@ -1109,7 +1109,7 @@ async function ringFromBurrow(ctx) { notification: { sequence: NOTIFY_SEQUENCE, deliveredInMs: sent.roundTripMs, - // Enter to a bell on the phone, the tap that opens the session list + // Enter to an alarm on the phone, the tap that opens the session list // included — the ring is normally there before the list is looked at. visibleInMs: Date.now() - startedAt, row, @@ -1262,7 +1262,7 @@ function wallReadyExpr() { * The session list as the reserve renders it, found by position rather than by * class: it is the block directly under the input-mode selector, and each row is * one button carrying the pane's title, its TODO pill, and — when the Burrow says - * the pane is ringing — a second icon, the bell + * the pane is ringing — an alarm inset, an overlay span inside the button * (`lib/src/components/MobileTerminalUi.tsx`). * * A statement, not an expression: it leaves the rows in `rows` (falsy while the @@ -1274,7 +1274,7 @@ function sessionRowsExpr() { const rows = reserve && [...reserve.querySelectorAll('button')].map((row) => ({ text: row.innerText.trim(), todo: [...row.querySelectorAll('span')].some((el) => el.textContent.trim() === 'TODO'), - ringing: row.querySelectorAll('svg').length > 1, + ringing: row.querySelector('[data-alert-ring-inset]') !== null, }));`; } diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5708c5379..0bb2d2eca 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -2,7 +2,7 @@ "AGENTS.md": 3400, "SECURITY.md": 200, "SELF_HOST.md": 6200, - "docs/specs/alert.md": 7250, + "docs/specs/alert.md": 7100, "docs/specs/auto-update.md": 1200, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, @@ -11,7 +11,7 @@ "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 1050, "docs/specs/layout.md": 10000, - "docs/specs/mobile-terminal-ui.md": 1950, + "docs/specs/mobile-terminal-ui.md": 2000, "docs/specs/mouse-and-clipboard.md": 3800, "docs/specs/notepad.md": 4000, "docs/specs/pocket-app.md": 4900, diff --git a/vscode-ext/README.md b/vscode-ext/README.md index f4903bfda..14560d659 100644 --- a/vscode-ext/README.md +++ b/vscode-ext/README.md @@ -38,10 +38,7 @@ Dormouse can owe you attention in three independent ways. Two of them need no se Dormouse never guesses which commands deserve an alert. Watching is a rule you create on a command name, and turning it off anywhere removes it everywhere. -- <img width="22" height="22" alt="Bell icon, alerts off" src="images/alert-disabled.gif" /> no watch rule for this pane's command -- <img width="22" height="22" alt="Bell icon, command watched" src="images/alert-enabled.gif" /> this command is watched -- <img width="22" height="22" alt="Bell icon, watched command running" src="images/alert-armed.gif" /> a watched command is running; it will alert when it goes quiet -- <img width="22" height="22" alt="Bell icon, ringing for attention" src="images/alert-ringing.gif" /> finished, and it needs your attention +A ringing pane is outlined and washed in the alarm color until you attend it, so one glance across a full wall finds it. Attending the pane — or pressing `a` — puts the alarm out. Whichever way a pane rings, the ring becomes a **TODO** — a marker beside the pane's title that outlives the alert, so a ring you dismissed does not disappear without a trace. Clear it by clicking it or pressing `t` in command mode. diff --git a/vscode-ext/images/alert-armed.gif b/vscode-ext/images/alert-armed.gif deleted file mode 100644 index 60cfe9b7c..000000000 Binary files a/vscode-ext/images/alert-armed.gif and /dev/null differ diff --git a/vscode-ext/images/alert-disabled.gif b/vscode-ext/images/alert-disabled.gif deleted file mode 100644 index 21d5a25e7..000000000 Binary files a/vscode-ext/images/alert-disabled.gif and /dev/null differ diff --git a/vscode-ext/images/alert-enabled.gif b/vscode-ext/images/alert-enabled.gif deleted file mode 100644 index 484d1c8cf..000000000 Binary files a/vscode-ext/images/alert-enabled.gif and /dev/null differ diff --git a/vscode-ext/images/alert-ringing.gif b/vscode-ext/images/alert-ringing.gif deleted file mode 100644 index aec94e405..000000000 Binary files a/vscode-ext/images/alert-ringing.gif and /dev/null differ diff --git a/vscode-ext/src/workspace-chrome.ts b/vscode-ext/src/workspace-chrome.ts index f11318929..d144e520b 100644 --- a/vscode-ext/src/workspace-chrome.ts +++ b/vscode-ext/src/workspace-chrome.ts @@ -3,16 +3,8 @@ import type { WorkspaceUnion } from '../../lib/src/lib/workspace-union'; const BASE_TITLE = 'Dormouse'; -/** - * Reflect a Workspace's union status onto a webview's native chrome title, - * matching the in-app `<title> <bell> [TODO]` pattern: append ` 🔔` when any - * terminal Session is ringing and ` [TODO]` when any surface is flagged. Both - * can appear, bell first; clear → just the base title. - * - * A tab title is plain text, so the bell is the emoji stand-in for the in-app - * bell icon and TODO is the bracketed word (not an emoji). See - * `docs/specs/vscode.md`. - */ +/** A Workspace's union status as a native chrome title suffix, the plain-text + * stand-in for what the Wall draws (`docs/specs/vscode.md`). */ export function workspaceTitle(union: WorkspaceUnion): string { let title = BASE_TITLE; if (union.ringing) title += ' 🔔'; diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index 6d49988bb..e41e67974 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -60,7 +60,7 @@ exports[`TutRunner snapshots > renders Keyboard navigation with all items incomp exports[`TutRunner snapshots > renders the alert section with all items incomplete 1`] = ` " - Alerts and attention 0/9 complete + Alerts and attention 0/8 complete Esc to go back ● Alert me whenever longtask runs @@ -68,20 +68,19 @@ exports[`TutRunner snapshots > renders the alert section with all items incomple (or select it and press a) and turn on "Watch all longtask commands". Alerts belong to the command, not the tab. · The rule covers every pane running that command - · The bell tilts while the command works · It rings when the command goes quiet · Dismissing a ringing alert leaves a TODO behind · Press Enter inside the pane to clear the TODO · Add a TODO by hand - · A program can ring the bell itself + · A program can ring on its own · A long command that finished while you were away - Three different things can ring the bell: a rule you set on a command name, a - notification the program sends, and a long command finishing while you were + Three different things can make a pane ring: a rule you set on a command name, + a notification the program sends, and a long command finishing while you were elsewhere. None of them ring while you are actually looking at the pane. Press s to start a fake longtask. - Press n for a program that rings the bell itself. + Press n for a program that rings on its own. Press x to start a fake slowbuild. " `; @@ -93,10 +92,10 @@ exports[`TutRunner snapshots > renders the top-level menu 1`] = ` ❯ Make it yours [0/1 complete] Keyboard navigation [0/7 complete] - Alerts and attention [0/9 complete] + Alerts and attention [0/8 complete] Copy paste [0/4 complete] Starred on GitHub [not yet] - 🐭 FlappyTerm 🐭 [LOCKED 0/21] + 🐭 FlappyTerm 🐭 [LOCKED 0/20] Reset progress diff --git a/website/src/lib/tut-detector.test.ts b/website/src/lib/tut-detector.test.ts index 13a99b09d..de80ce532 100644 --- a/website/src/lib/tut-detector.test.ts +++ b/website/src/lib/tut-detector.test.ts @@ -155,28 +155,23 @@ describe("TutDetector", () => { expect(state.isComplete("kb-arrows")).toBe(true); }); - it("does not credit al-busy or al-ring when a pane is already in that status at first observation", () => { + it("does not credit al-ring when a pane is already ringing at first observation", () => { const { state, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ - ["pane-a", activity("BUSY")], ["pane-b", activity("ALERT_RINGING")], ])); - expect(state.isComplete("al-busy")).toBe(false); expect(state.isComplete("al-ring")).toBe(false); }); - it("credits al-busy and al-ring on a true status transition", () => { + it("credits al-ring on a true status transition", () => { const { state, setActivitySnapshot } = makeDetectorHarness(); setActivitySnapshot(new Map([ ["pane-a", activity("NOTHING_TO_SHOW")], ])); - setActivitySnapshot(new Map([ - ["pane-a", activity("BUSY")], - ])); - expect(state.isComplete("al-busy")).toBe(true); + expect(state.isComplete("al-ring")).toBe(false); setActivitySnapshot(new Map([ ["pane-a", activity("ALERT_RINGING")], diff --git a/website/src/lib/tut-detector.ts b/website/src/lib/tut-detector.ts index d2c9a3a49..9ac902595 100644 --- a/website/src/lib/tut-detector.ts +++ b/website/src/lib/tut-detector.ts @@ -212,17 +212,11 @@ export class TutDetector { this.queueSpreadCheck(id); } - // Gate al-busy / al-ring on a true status transition. Without the - // prev.status check, a pane already in BUSY or ALERT_RINGING at the - // moment its first activity event fires (e.g. restored state, or a - // pane spawned after start() that arrives mid-task) would credit - // the user for work they did not do this session. - if ( - prev.status !== current.status && - (current.status === "BUSY" || current.status === "MIGHT_BE_BUSY") - ) { - this.state.markComplete("al-busy"); - } + // Gate al-ring on a true status transition. Without the prev.status + // check, a pane already in ALERT_RINGING at the moment its first + // activity event fires (e.g. restored state, or a pane spawned after + // start() that arrives mid-task) would credit the user for work they + // did not do this session. if (prev.status !== "ALERT_RINGING" && current.status === "ALERT_RINGING") { this.state.markComplete("al-ring"); } diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 5f4d8084a..8e651b135 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -21,7 +21,6 @@ const KEYBOARD_ITEM_IDS = [ const ALERT_ITEM_IDS = [ "al-watch-cmd", "al-spreads", - "al-busy", "al-ring", "al-todo-auto", "al-todo-clear", @@ -203,23 +202,18 @@ export const DESKTOP_SECTIONS: readonly Section[] = [ { id: 'al-spreads', title: 'The rule covers every pane running that command', - hint: 'Both fake tasks light up from the one rule you turned on. Any pane you open later that runs `longtask` will watch too, with no extra clicks.', - }, - { - id: 'al-busy', - title: 'The bell tilts while the command works', - hint: 'Press `s` again if the task already finished.', + hint: 'Both fake tasks are covered by the one rule you set. Any pane you open later that runs `longtask` will watch too, with no extra clicks.', }, { id: 'al-ring', title: 'It rings when the command goes quiet', hint: - `Don't type! If you type, Dormouse will think you are paying attention to this task and the bell will not ring. The bell waits until you attend another pane or stop interacting for the inactivity timeout in Alarm settings.`, + `Don't type! If you type, Dormouse will think you are paying attention to this task and the pane will not ring. It waits until you attend another pane or stop interacting for the inactivity timeout in Alarm settings.`, }, { id: 'al-todo-auto', title: 'Dismissing a ringing alert leaves a TODO behind', - hint: 'Click the bell or interact with the pane to dismiss. The TODO is there so a ring you waved away does not vanish without a trace.', + hint: 'Interact with the pane, or press `a`, to dismiss. The TODO is there so a ring you waved away does not vanish without a trace.', }, { id: 'al-todo-clear', @@ -232,7 +226,7 @@ export const DESKTOP_SECTIONS: readonly Section[] = [ }, { id: 'al-notif', - title: 'A program can ring the bell itself', + title: 'A program can ring on its own', hint: 'Press `n` for a fake build that sends a notification. This needs no rule at all — any program that emits `BEL`, `OSC 9`, `OSC 777`, or `OSC 99` rings, and its message shows on the TODO tag.', }, { @@ -243,7 +237,7 @@ export const DESKTOP_SECTIONS: readonly Section[] = [ }, ], prose: [ - 'Three different things can ring the bell: a rule you set on a command name, a notification the program sends, and a long command finishing while you were elsewhere. None of them ring while you are actually looking at the pane.', + 'Three different things can make a pane ring: a rule you set on a command name, a notification the program sends, and a long command finishing while you were elsewhere. None of them ring while you are actually looking at the pane.', ], }, COPY_PASTE_SECTION, diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index b13d90eae..e1a077742 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -330,7 +330,7 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("🐭 FlappyTerm 🐭"); expect(lastFrame()).not.toContain("???"); - expect(lastFrame()).toContain("[LOCKED 0/21]"); + expect(lastFrame()).toContain("[LOCKED 0/20]"); expect(lastFrame()).toContain("Dormouse Playground Tutorial"); dispose(); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 561e7b938..ab777eff9 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -49,9 +49,9 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", const SPINNER_INTERVAL_MS = 100; /** Static "your turn" pointer for the active section item — deliberately not - * animated, so the checklist doesn't compete for attention with the bell the - * Alerts section is teaching. (Runner frames are written with - * `skipActivity`, so animation would no longer tilt the bell either way.) */ + * animated, so the checklist doesn't compete for attention with the alarm the + * Alerts section is teaching. (Runner frames are written with `skipActivity`, + * so animation would no longer move the detector either way.) */ const ACTIVE_ITEM_GLYPH = "●"; const STAR_PROMPT_TITLE = "Starred on GitHub"; const FLAPPY_TITLE = "🐭 FlappyTerm 🐭"; @@ -909,7 +909,7 @@ export class TutRunner implements InteractiveProgram { private renderBusyDemoLines(): string[] { return [ this.renderDemoLine("s", "longtask", "Fake task", this.busyDemoStart, this.busyDemoDurationMs), - ` ${DIM}Press \`n\` for a program that rings the bell itself.${RESET}`, + ` ${DIM}Press \`n\` for a program that rings on its own.${RESET}`, this.renderDemoLine("x", "slowbuild", "Slow build", this.commandExitDemoStart, this.commandExitDemoDurationMs), ]; } @@ -1018,7 +1018,7 @@ export class TutRunner implements InteractiveProgram { private write(data: string): void { // Runner frames are UI chrome, not task output — skip the activity - // tick so enabling WATCHING on the runner pane doesn't tilt the bell + // tick so enabling WATCHING on the runner pane doesn't look busy // every time the menu re-renders. this.adapter.sendOutput(this.terminalId, data, { skipActivity: true }); } diff --git a/website/src/pages/PlaygroundDesktop.tsx b/website/src/pages/PlaygroundDesktop.tsx index ca81535bd..991f57a3f 100644 --- a/website/src/pages/PlaygroundDesktop.tsx +++ b/website/src/pages/PlaygroundDesktop.tsx @@ -222,7 +222,7 @@ function PlaygroundDesktopExperience() { "\x1b]777;notify;Build finished;3 packages rebuilt\x07", ); }, - // An unwatched command, so the command-exit track owns the bell: + // An unwatched command, so the command-exit track owns the ring: // the user attends the pane, leaves, and the exit rings. onTriggerCommandExitDemo: (durationMs) => { startFakeCommand(adapter, PANE_SPLASH, "slowbuild");