Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ Reached from any of the controls at the far right of the baseboard; placement an
- Lists every watched command with a remove control, and **cannot add one**. WATCHING is keyed on a running command's name, so creating a rule stays a bell click / `a` press in the tab running it; the empty state says so. This dialog and the bell dialog are the two places a rule set on a since-closed Pane can be found and removed — they render the same `WatchedCommandList`, so the list has one implementation.
- Delays are shown in seconds and committed on blur or `Enter`, never per keystroke — typing `3` on the way to `30` must not briefly install a 3-second timer. An out-of-range or empty entry snaps back to whatever the store clamped it to.
- The push group's device line names every device a push would reach, and otherwise states why there is none — no Host enrolled, nothing subscribed yet, or the server could not be asked. A push that silently goes nowhere is indistinguishable from a broken one.
- Each alarm sink carries a **try it now** control — **Play test sound** and **Send test push** — because an alarm is otherwise unobservable until it fires unattended, which is the moment its being wrong costs the most. Source of truth: `lib/src/components/AlarmTestButtons.tsx`. Both sit outside the switch's dimming and stay enabled while the sink is off: checking that the speakers work, or that the phone buzzes, is most useful *before* committing to the alarm. Each reports its own outcome inline and clears it after a few seconds, because for both sinks a working path and a broken one produce the same observation — silence.
- **Play test sound** speaks a fixed phrase through the same sanitizer as a real alarm, but deliberately not through `speak()`: that publishes the transient per-Session `speaking` / `spoken` state Panes and Doors render, and no Session rang. It reports a webview with no speech backend rather than degrading silently the way the alarm path correctly does.
- **Send test push** goes through the real Host, ACL and server, so what it proves is what the alarm will do. It is the one caller of the push path that must **not** swallow failures — the ring path's rule that a failed push never breaks the alert path would make a test button report success over a fan-out that reached nobody. It distinguishes four outcomes: no devices targeted (the ordinary answer on a freshly enrolled machine, and not a failure), nothing delivered, a partial fan-out, and success. The button is hidden entirely where no Host service exists, matching the Remote control section ([server.md](./server.md)).

## Workspace union

Expand Down Expand Up @@ -282,5 +285,6 @@ Alert-specific robustness requirements: multiple Sessions ring independently; mi
| `lib/src/components/wall/AlertSpeechIndicator.tsx` | Whole-Pane `SPEAKING` / `SPOKEN` treatment |
| `lib/src/components/TodoAlertDialog.tsx` | TODO + WATCHING-rule switches, notification detail, watched-command list |
| `lib/src/components/SettingsDialog.tsx` | App-global Settings dialog: theme row (see [theme.md](./theme.md)), shell row (standalone, see [standalone.md](./standalone.md)), rule list, inactivity timeout, spoken alarms, push notifications, remote control (see [server.md](./server.md)) |
| `lib/src/components/AlarmTestButtons.tsx` | The two alarm sinks' "try it now" controls: Play test sound, Send test push |
| `lib/src/components/WatchedCommandList.tsx` | The WATCHING rule set with per-rule remove, shared by both dialogs |
| `lib/src/components/Door.tsx` | Door bell + TODO display |
144 changes: 144 additions & 0 deletions lib/src/components/AlarmTestButtons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

let platform: { remoteHost?: unknown } = {};

vi.mock('../lib/platform', () => ({
IS_MAC: false,
getPlatform: () => platform,
}));

import { PushTestButton, SpeakTestButton } from './AlarmTestButtons';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

let container: HTMLDivElement;
let root: Root;

function text(): string {
return container.textContent ?? '';
}

function button(): HTMLButtonElement {
const found = container.querySelector('button');
if (!found) throw new Error('no button rendered');
return found;
}

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
platform = {};
vi.unstubAllGlobals();
vi.clearAllMocks();
});

describe('SpeakTestButton', () => {
it('speaks and says so when the webview has a speech engine', async () => {
const speak = vi.fn();
vi.stubGlobal('speechSynthesis', { speak, cancel: vi.fn() });
vi.stubGlobal(
'SpeechSynthesisUtterance',
class {
constructor(public text: string) {}
},
);

await act(async () => root.render(<SpeakTestButton />));
await act(async () => button().click());

expect(speak).toHaveBeenCalledTimes(1);
expect(text()).toContain('Speaking now');
});

it('says there is no speech engine rather than looking like it worked', async () => {
// A webview with no backend and one with the volume down produce the same
// observation, so silence has to be reported.
vi.stubGlobal('speechSynthesis', undefined);

await act(async () => root.render(<SpeakTestButton />));
await act(async () => button().click());

expect(text()).toContain('no speech engine');
});
});

describe('PushTestButton', () => {
it('renders nothing where there is no Host service', async () => {
platform = {};
await act(async () => root.render(<PushTestButton />));
expect(container.innerHTML).toBe('');
});

it('reports a delivered push', async () => {
platform = {
remoteHost: {
command: vi.fn(async () => ({ targeted: 2, delivered: 2, failed: 0 })),
on: () => () => {},
respond: () => {},
notify: () => {},
},
};
await act(async () => root.render(<PushTestButton />));
await act(async () => button().click());

expect(text()).toContain('Sent to 2 devices');
});

it('distinguishes "nowhere to send it" from a failure', async () => {
platform = {
remoteHost: {
command: vi.fn(async () => ({ targeted: 0, delivered: 0, failed: 0 })),
on: () => () => {},
respond: () => {},
notify: () => {},
},
};
await act(async () => root.render(<PushTestButton />));
await act(async () => button().click());

expect(text()).toContain('No paired phone has enabled alerts yet');
});

it('reports a fan-out that reached nobody', async () => {
platform = {
remoteHost: {
command: vi.fn(async () => ({ targeted: 2, delivered: 0, failed: 2 })),
on: () => () => {},
respond: () => {},
notify: () => {},
},
};
await act(async () => root.render(<PushTestButton />));
await act(async () => button().click());

expect(text()).toContain('No device accepted the push');
});

it('surfaces the service error', async () => {
platform = {
remoteHost: {
command: vi.fn(async () => {
throw new Error('This machine is not connected to a Dormouse server.');
}),
on: () => () => {},
respond: () => {},
notify: () => {},
},
};
await act(async () => root.render(<PushTestButton />));
await act(async () => button().click());

expect(text()).toContain('not connected to a Dormouse server');
});
});
130 changes: 130 additions & 0 deletions lib/src/components/AlarmTestButtons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { modalActionButton } from './design';
import { speakTestUtterance } from '../lib/alert-speech';
import { getPlatform } from '../lib/platform';
import { sendTestPush } from '../remote/host/host-status-store';

/**
* "Try it now" controls for the two alarm sinks
* (`docs/specs/alert.md` -> Alarm settings).
*
* Both answer the same question — will this actually reach me? — which is
* otherwise unanswerable until an alarm fires at 3am. Each reports its own
* outcome inline rather than relying on the effect being observable: a silent
* webview and a working one look identical, and a push that reached nobody
* looks exactly like one that did.
*
* The result line clears itself, so the dialog does not accumulate stale
* verdicts from earlier presses.
*/

/** How long a result line stays before the button returns to its resting state. */
const RESULT_LINGER_MS = 6000;

function useTransientResult() {
const [result, setResult] = useState<{ text: string; tone: 'ok' | 'bad' } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

const show = useCallback((text: string, tone: 'ok' | 'bad') => {
if (timer.current) clearTimeout(timer.current);
setResult({ text, tone });
timer.current = setTimeout(() => setResult(null), RESULT_LINGER_MS);
}, []);

// A dialog closed while a result is showing must not leave a timer holding a
// setState on an unmounted tree.
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), []);

return [result, show] as const;
}

function ResultLine({ result }: { result: { text: string; tone: 'ok' | 'bad' } | null }) {
if (!result) return null;
return (
<div className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}>

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.

The one thing this control exists to communicate is never announced to a screen reader — the result line just appears. The alert layer's own AlertSpeechIndicator.tsx uses role="status" + aria-live="polite" for the same job.

Suggested change
<div className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}>
<div
role="status"
aria-live="polite"
className={`mt-1 text-sm leading-relaxed ${result.tone === 'bad' ? 'text-error' : 'text-muted'}`}
>

{result.text}
</div>
);
}

/**
* Speak a fixed phrase now. Synchronous and local — there is no server in this
* path — so the only failure worth reporting is a webview with no speech
* backend at all, which would otherwise be indistinguishable from a working one
* with the volume down.
*/
export function SpeakTestButton() {
const [result, show] = useTransientResult();

return (
<div>
<button
type="button"
className={modalActionButton()}
onClick={() => {
if (speakTestUtterance()) show('Speaking now.', 'ok');
else show('This app has no speech engine available.', 'bad');
}}
>
Play test sound
</button>
<ResultLine result={result} />
</div>
);
}

/**
* Send a real push through the real path — same Host, same ACL, same server —
* so what it proves is what the alarm will do.
*
* Hidden entirely where no Host service exists, matching the Remote control
* section: there is nothing to test and nothing the user could do about it.
*/
export function PushTestButton() {
const [result, show] = useTransientResult();
const [busy, setBusy] = useState(false);

let hasService = false;
try {
hasService = !!getPlatform().remoteHost;
} catch {
hasService = false;
}
if (!hasService) return null;

return (
<div>
<button
type="button"
disabled={busy}
className={modalActionButton()}
onClick={() => {
setBusy(true);
void sendTestPush()
.then((outcome) => {
if (outcome.targeted === 0) {
// Not a failure: the Host is fine, nothing has opted in yet.
show('No paired phone has enabled alerts yet, so there was nowhere to send it.', 'bad');
} else if (outcome.delivered === 0) {
show(`No device accepted the push (${outcome.failed} failed).`, 'bad');
} else if (outcome.failed > 0) {
show(`Sent to ${outcome.delivered}; ${outcome.failed} failed.`, 'bad');
} else {
show(
`Sent to ${outcome.delivered} ${outcome.delivered === 1 ? 'device' : 'devices'}.`,
'ok',
);
}
})
.catch((error: unknown) => {
show(error instanceof Error ? error.message : String(error), 'bad');
})
.finally(() => setBusy(false));
}}
>
{busy ? 'Sending…' : 'Send test push'}
</button>
<ResultLine result={result} />
</div>
);
}
34 changes: 24 additions & 10 deletions lib/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { ThemePicker } from './ThemePicker';
import { ShellPicker } from './ShellPicker';
import { WatchedCommandList } from './WatchedCommandList';
import { RemoteControlSection } from './RemoteControlSection';
import { PushTestButton, SpeakTestButton } from './AlarmTestButtons';
import { getPlatform } from '../lib/platform';
import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store';
import {
Expand Down Expand Up @@ -173,6 +174,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
delayMs={settings.speakDelayMs}
onToggle={(speakEnabled) => updateAlertSettings({ speakEnabled })}
onCommitDelay={(speakDelayMs) => updateAlertSettings({ speakDelayMs })}
action={<SpeakTestButton />}
/>

<AlarmSinkSection
Expand All @@ -182,6 +184,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
delayMs={settings.pushDelayMs}
onToggle={(pushEnabled) => updateAlertSettings({ pushEnabled })}
onCommitDelay={(pushDelayMs) => updateAlertSettings({ pushDelayMs })}
action={<PushTestButton />}
>
{describePushTargets(push)}
</AlarmSinkSection>
Expand All @@ -207,6 +210,7 @@ function AlarmSinkSection({
onToggle,
onCommitDelay,
children,
action,
}: {
switchLabel: string;
delayLabel: string;
Expand All @@ -215,20 +219,30 @@ function AlarmSinkSection({
onToggle: (next: boolean) => void;
onCommitDelay: (ms: number) => void;
children?: React.ReactNode;
/**
* A "try it now" control. Rendered *outside* the dimming below, and never
* disabled by the switch: checking that the speakers work — or that the phone
* buzzes — is most useful before committing to the alarm, and an alarm you
* cannot observe until 3am is one you cannot trust.
*/
action?: React.ReactNode;
}) {
return (
<section className={SECTION}>
<SwitchRow label={switchLabel} on={enabled} onChange={onToggle} />
<div className={`mt-2 ${UNDER_SWITCH_INDENT} ${enabled ? '' : 'opacity-50'}`}>
<SecondsField
label={delayLabel}
valueMs={delayMs}
disabled={!enabled}
onCommit={onCommitDelay}
/>
{children ? (
<div className="mt-1 text-sm leading-relaxed text-muted">{children}</div>
) : null}
<div className={UNDER_SWITCH_INDENT}>
<div className={`mt-2 ${enabled ? '' : 'opacity-50'}`}>
<SecondsField
label={delayLabel}
valueMs={delayMs}
disabled={!enabled}
onCommit={onCommitDelay}
/>
{children ? (
<div className="mt-1 text-sm leading-relaxed text-muted">{children}</div>
) : null}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
</section>
);
Expand Down
Loading