Alerts: alarm settings, configurable inactivity timeout, spoken alarms - #327
Merged
Conversation
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>
dormouse-bot
approved these changes
Jul 28, 2026
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>
Deploying mouseterm with
|
| Latest commit: |
e6f3089
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1614c5a4.mouseterm.pages.dev |
| Branch Preview URL: | https://speak-alarms.mouseterm.pages.dev |
dormouse-bot
approved these changes
Jul 29, 2026
dormouse-bot
left a comment
Collaborator
There was a problem hiding this comment.
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.
Co-authored-by: dormouse-bot <ned.twigg+dormouse-bot@diffplug.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_ATTENTIONmoves from a module constant to instance state onAlertManager. 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 intoALERT_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 wherespeechSynthesisis 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 legacyOSC 9text 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
ActivityNotificationtitle/body as a separate payload —OSC 9text reaches speech only through its existing second job as a pane-title candidate.alert.mdstates 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.toSpokenTextmaps markup metacharacters and control characters to spaces, collapses whitespace, caps length, and falls back toterminal. It lives at the speech boundary rather than inderiveSessionLabel, 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-settingsand reach the host the same way the WATCHING rule set does, for the same reason: under VS Code theAlertManagerlives in the extension host, and each webview has its own origin and therefore its ownlocalStorage. First renderer seeds, host broadcasts canonical. The whole blob is relayed rather than justinactivityTimeoutMs(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 insetTimeout.transport.mdnow 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:
noticewas unaccounted for entirely, and the overflow arrow and notice each carried their ownml-auto, so flexbox split the free space between them. They are now one right cluster measured byResizeObserver.The observer is not decoration.
noticeis a referentially stable element (<ConnectedUpdateBanner />, created once at root render), so when the update banner appears from its own internal state,Baseboardnever 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
OnOffSwitchand the numeric input moved intodesign.tsx(the repo has no checkbox anywhere and should not gain one for this). The watched-command list becameWatchedCommandList, shared with the bell popover — one list rendered in two places. The id-keyed pane-label derivation becamederiveSessionLabel, which had been hand-copied a third time for speech.Follow-up commits
Two cleanups the above exposed, kept separate:
initAlertStateReceiverbookkeeping. It tracked each handler in a moduleletto 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 calledoff()on a freshly-readgetPlatform(), so it only worked when the adapter instance was unchanged.offAlertState/offWatchedCommands/offAlertSettingsdeleted. Zero production callers, the last of which went away above. This breaks theon/offpairing 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 standalonetsc -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 withspeechSynthesis.speakstubbed. TheResizeObserverrefit 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:OSC 9ring speaks the pane label;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_RINGINGthrough different code thanOSC 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