Skip to content

Alerts: alarm settings, configurable inactivity timeout, spoken alarms - #327

Merged
nedtwigg merged 6 commits into
mainfrom
speak-alarms
Jul 29, 2026
Merged

Alerts: alarm settings, configurable inactivity timeout, spoken alarms#327
nedtwigg merged 6 commits into
mainfrom
speak-alarms

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Jul 28, 2026

Copy link
Copy Markdown
Member

Why

The alert system had no user-facing configuration — every timer was a constant in cfg.ts, and the only rule-editing UI was the bell popover, reachable only from a pane that is currently running something. Alarms were also purely visual: a bell tilt and a TODO pill. An alarm raised while you are away from the screen stayed silent until you looked back, which is exactly the case the feature is for.

What changed

An Alarm settings dialog, opened from a control at the far right of the baseboard. It lists the watched commands with a remove control, and carries the two settings below. Push notifications are rendered greyed out — the design is staged in alert.md's new ## Future, and the fields already persist and relay, so landing push changes no stored shape.

The inactivity timeout is configurable. T_USER_ATTENTION moves from a module constant to instance state on AlertManager. Both of its uses follow the setting: how long "looking at this pane" lasts, and the minimum runtime a command needs before its exit may ring. Changing it re-arms a live attention timer from that moment, so shortening the window applies immediately instead of after the window already running.

Alarms can speak. A ring left unattended for a configurable delay says the pane's name through speechSynthesis. The trigger is a fresh transition into ALERT_RINGING, held to the same standard as the bell — a Session observed for the first time already ringing never speaks, which is what keeps a restore or a reconnect replaying a latched ring silent. Attending, dismissing, killing the pane, or switching the setting off during the delay all cancel; both the ring and the setting are re-read when the timer fires rather than captured when it was scheduled. Renderer-side, so it is a silent no-op where speechSynthesis is absent (Tauri on Linux — WebKitGTK ships no speech backend). Desktop shell only; Pocket does not arm it.

What speech actually says, and why that matters

Speech says the derived pane label — the same string the pane header and door show. That intentionally includes terminal-supplied titles. OSC 0, OSC 2, and legacy OSC 9 text participate in normal label derivation, so a program can end up choosing what your laptop says out loud when its title currently wins.

This is deliberate rather than overlooked: opting into spoken alarms opts into hearing the pane name Dormouse displays, even when a program supplied that name. What speech does not do is select ActivityNotification title/body as a separate payload — OSC 9 text reaches speech only through its existing second job as a pane-title candidate. alert.md states this in both the Spoken alarms section and Text And Security, and a test pins it.

Sanitizing that label is not cosmetic. WebKit silently drops an utterance containing angle brackets and leaves the synthesizer wedged, so every later utterance is dropped too until the page reloads. Pane labels carry chrome like <idle>, which made the first spoken alarm in standalone silent and permanently disabled every alarm after it. Combined with terminal titles reaching speech, that meant any program could disable spoken alarms for the whole session by putting a < in its title. toSpokenText maps markup metacharacters and control characters to spaces, collapses whitespace, caps length, and falls back to terminal. It lives at the speech boundary rather than in deriveSessionLabel, because the pane header legitimately wants <idle> as a visual placeholder — a label that is safe to render is not automatically safe to speak.

The settings relay

Settings persist to dormouse:alert-settings and reach the host the same way the WATCHING rule set does, for the same reason: under VS Code the AlertManager lives in the extension host, and each webview has its own origin and therefore its own localStorage. First renderer seeds, host broadcasts canonical. The whole blob is relayed rather than just inactivityTimeoutMs (the only field the host consumes) so two webviews cannot disagree about whether alarms speak, and the host revalidates everything — these are renderer-supplied numbers that end up in setTimeout.

transport.md now records the per-store cost of this shape. Two stores is worth the directness; a third should collapse them into one keyed channel rather than pay the tax again.

Drive-by: the baseboard door-fitting budget

Adding a permanently-visible button to the baseboard surfaced a pre-existing bug. The budget never subtracted anything on the right: notice was unaccounted for entirely, and the overflow arrow and notice each carried their own ml-auto, so flexbox split the free space between them. They are now one right cluster measured by ResizeObserver.

The observer is not decoration. notice is a referentially stable element (<ConnectedUpdateBanner />, created once at root render), so when the update banner appears from its own internal state, Baseboard never re-renders — a render-time measurement would stay stale exactly in the idle window where an update banner shows up. The overflow arrow is deliberately left out of the measured element, since its presence is an output of the fit and measuring it would feed into its own input.

Shared vocabulary

OnOffSwitch and the numeric input moved into design.tsx (the repo has no checkbox anywhere and should not gain one for this). The watched-command list became WatchedCommandList, shared with the bell popover — one list rendered in two places. The id-keyed pane-label derivation became deriveSessionLabel, which had been hand-copied a third time for speech.

Follow-up commits

Two cleanups the above exposed, kept separate:

  • initAlertStateReceiver bookkeeping. It tracked each handler in a module let to deregister on a later call — load-bearing for exactly one of three, because the alert-state handler was rebuilt as a fresh closure every call. It captured nothing from the enclosing scope, so hoisting it to a module function removes the reason for the bookkeeping entirely. Worth noting what the old guard did not do: it called off() on a freshly-read getPlatform(), so it only worked when the adapter instance was unchanged.
  • offAlertState / offWatchedCommands / offAlertSettings deleted. Zero production callers, the last of which went away above. This breaks the on/off pairing the PTY listeners have, so the interface says why and what to do if a caller ever needs to unsubscribe.

Verification

pnpm test (1245 lib + 47 standalone + 56 website), pnpm lint:specs, lib and standalone tsc -b, and the VS Code extension bundle all pass. 40 of those tests are new.

Driven by hand in a real Wall in Storybook: the dialog opens from the baseboard button, Escape closes it, typing a timeout does not leak into command mode, and the value round-trips to localStorage. Spoken alarms were exercised end-to-end against the app's own module instance with speechSynthesis.speak stubbed. The ResizeObserver refit was checked by injecting a wide element into the cluster with no React re-render: doors 3→1, overflow 5→7, stable, no loop warnings. Checked in light and dark across five dialog stories plus a new Baseboard overflow-with-notice story.

Driven by hand in the real Tauri standalone app (pnpm dev:standalone) — this is where the WebKit bug above surfaced, and where the fix is confirmed audible:

  • an unattended OSC 9 ring speaks the pane label;
  • attending the pane, or dismissing via the bell, during the delay leaves it silent;
  • the inactivity timeout works in both directions — at 3s an idle pane rings and speaks; at 60s the same command stays silent, because attention has not expired.

Not exercised by hand: several panes ringing at once; a latched ring surviving a full app restart, which must not speak since a restored ring is not a fresh alarm (the unit tests cover that rule, but not standalone's restore path); the command-exit and WATCHING tracks, which reach ALERT_RINGING through different code than OSC 9; and VS Code with two webviews, the one host where the relay's first-seed + canonical-broadcast path actually does anything.

🤖 Generated with Claude Code

nedtwigg and others added 4 commits July 28, 2026 13:02
Adds an app-global Alarm settings dialog, opened from the far right of the
baseboard, and makes two of its settings real:

- Inactivity timeout — T_USER_ATTENTION moves from a module constant to
  instance state on AlertManager. Both of its uses follow the setting:
  attention expiry, and the minimum runtime a command needs before its exit
  may ring. Changing it re-arms a live timer from that moment, so shortening
  the window applies immediately.

- Spoken alarms — a ring left unattended for a configurable delay speaks the
  pane's name through speechSynthesis. Only the derived label is ever spoken,
  never ActivityNotification text: any program can emit OSC 9, and speaking
  its text would let it choose what the machine says out loud.

Push notifications render disabled; the design is staged in alert.md's new
Future section, and the fields already persist so landing it changes no
stored shape.

Settings persist to `dormouse:alert-settings` and reach the host the same way
the WATCHING rule set does. That shape is required, not incidental: under
VS Code the AlertManager lives in the extension host and each webview has its
own origin, hence its own localStorage. The whole blob is relayed so two
webviews cannot disagree about whether alarms speak, and the host revalidates
everything — these are renderer-supplied numbers that become host timers.

Also fixes the baseboard door-fitting budget, which never subtracted anything
on the right: `notice` was unaccounted for, and the overflow arrow and notice
each carried their own ml-auto, splitting the free space between them. They
are now one ResizeObserver-measured cluster, which also survives a notice that
appears from its own internal state without re-rendering the baseboard.

Shared vocabulary extracted along the way: OnOffSwitch and NumericInput into
design.tsx, the watched-command list into WatchedCommandList (shared with the
bell popover), and the id-keyed pane-label derivation into deriveSessionLabel
(shared with the dev-server chip).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
initAlertStateReceiver() tracked each handler it installed in a module-level
`let` so it could deregister on a later call. That was load-bearing for
exactly one of the three: the alert-state handler was rebuilt as a fresh
closure every call, so without the off/on dance repeat calls — Pocket and the
website playground call this from an effect — would stack distinct closures
and apply every host update N times.

The closure captured nothing from the enclosing scope, so hoisting it to a
module-level function removes the reason for the bookkeeping entirely. All
three handlers are now stable references and adapters hold handlers in a Set,
so re-registration is inherently a no-op and all three `let`s and guards go.

Worth noting what the old guard did not do: it read getPlatform() afresh and
called off() on the *new* adapter, so it only ever worked when the adapter
instance was unchanged. The structural fix has no such precondition.

Pins the invariant with a test that fails (3 deliveries, not 1) if a handler
is ever made per-call again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
offAlertState, offWatchedCommands, and offAlertSettings had no production
callers. The last one went away when initAlertStateReceiver stopped tracking
its handlers for deregistration — its handlers are stable module-level
functions registered once for the renderer's lifetime, so nothing ever
unsubscribes.

Removes them from PlatformAdapter and its five implementations, plus three
stale properties in test mock adapters that nothing would have flagged (test
files are excluded from tsconfig.app.json). The on* side keeps its Set, which
is now load-bearing for idempotent re-registration rather than for removal.

This breaks the on/off pairing the PTY listeners have, so the interface says
why and what to do if a caller ever does need to unsubscribe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WebKit silently drops an utterance containing angle brackets AND leaves the
synthesizer wedged, so every later utterance is dropped too until the page
reloads. Pane labels carry chrome like `<idle>`, so the first spoken alarm in
standalone on macOS was silent and permanently disabled every alarm after it.

Terminal-supplied OSC 0/2/9 titles also reach speech through normal label
derivation, so this was not only a broken feature: any program could
permanently disable spoken alarms for the session by putting a `<` in its
title. Sanitizing at the speech boundary rather than in `deriveSessionLabel`,
because the Pane header legitimately wants `<idle>` as a visual placeholder —
a label that is safe to render is not automatically safe to speak.

`toSpokenText` maps angle brackets, ampersands, and control characters to
spaces (spaces rather than deletion, so `a<b` reads as two words), collapses
whitespace, caps length, and falls back to `terminal` when nothing survives.

Found by hand in `pnpm dev:standalone`; the sanitizer is unit-tested and the
fix is confirmed audible there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 29, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: e6f3089
Status: ✅  Deploy successful!
Preview URL: https://1614c5a4.mouseterm.pages.dev
Branch Preview URL: https://speak-alarms.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanitization looks solid — the metacharacter set, whitespace collapse, cap, and terminal fallback all line up with the tests, and speak() is the single seam so every label goes through it. One inert-comment nit inline.

Comment thread lib/src/lib/alert-speech.ts Outdated
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
@nedtwigg
nedtwigg merged commit 0e6827f into main Jul 29, 2026
7 checks passed
@nedtwigg
nedtwigg deleted the speak-alarms branch July 29, 2026 03:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants