Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/specs/alert.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ The Settings dialog's Notifications topic is this spec's; Network's content is `
- **Must show only application-wide settings**, excluding Workspace overrides.
- A baseboard alarm button toggles only its own setting, as an override for that Workspace; components without a Workspace scope edit application defaults. **Must open a separate Workspace alert dialog from either button's right-click or focused `Shift+F10`/`ContextMenu`**, only with a Workspace scope, preserving per-field inheritance and reset-all.
- The watched-command list removes rules and cannot add one — creating a rule stays the terminal context of a Pane running the command. It is the only place a rule set on a since-closed Pane can be removed.
- **The push group's device line must name every device a push would reach**, and otherwise say why there is none.
- **The push group's device line must name every device a push would reach**, and otherwise say why there is none; a refusing Relay's reason comes from the Burrow's status (`relayRefuses`), which the device list cannot tell, with the plans where the account has none.
- **The Managed voice group is shown wherever the adapter has `managedVoice`**, which a self-host build's omits; there the speech row names managed voice instead of linking the `/hosted` preview ([website-docs.md](./website-docs.md) -> `/hosted`). **Must state what is sent to Hosted, and that ElevenLabs' copy has no guaranteed deletion time, before the enrollment that enables it.** Managed voice has its own approval and Disconnect, independent of remote control; under Nowhere it explains and links Network instead, changing no policy. Enabled, it offers the curated voices and Disconnect, or, where speak says the plan lapsed, the plans. **No control takes a token, and no copy names one.**
- Each sink carries a test control, usable while the sink is off: the test sound goes through the shared speech queue and selected voice, reporting admission or an unavailable backend, never Session delivery state; the test push takes the real Burrow→ACL→Relay path and distinguishes no targets, zero, partial, and full delivery, hidden without a Burrow service.

Expand Down
7 changes: 6 additions & 1 deletion lib/src/components/HostedEnrollment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export const HOSTED_PRICING_URL = 'https://dormouse.sh/hosted/#pricing';
/** The Hosted page's voice section, the spoken-alarm row's link where the build has no managed voice. */
export const HOSTED_VOICE_URL = 'https://dormouse.sh/hosted/#voice';

/** The link to {@link HOSTED_PRICING_URL}, the one way every no-plan line names the plans. */
export function HostedPlansLink() {
return <ExternalTextLink href={HOSTED_PRICING_URL}>See Hosted plans</ExternalTextLink>;
}

/** The one field the offer card and the typed form both ask for. */
export function BurrowNameField({
value,
Expand Down Expand Up @@ -114,7 +119,7 @@ export function HostedEnrollmentEnded({
{ended.reason === 'failed' && ended.message ? <div className="mt-1 text-error">{ended.message}</div> : null}
{ended.reason === 'not-entitled' ? (
<div className="mt-1">
<ExternalTextLink href={HOSTED_PRICING_URL}>See Hosted plans</ExternalTextLink>
<HostedPlansLink />
</div>
) : null}
{page ? (
Expand Down
5 changes: 2 additions & 3 deletions lib/src/components/ManagedVoiceSection.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useCallback, useState, useSyncExternalStore } from 'react';
import { INLINE_ACTION_CLASS, SELECT_CLASS, modalActionButton } from './design';
import { ExternalTextLink } from './ExternalTextLink';
import { HOSTED_PRICING_URL, HostedApprovalProgress } from './HostedEnrollment';
import { HostedApprovalProgress, HostedPlansLink } from './HostedEnrollment';
import { FIELD_LABEL, useBusyAction, useNetworkPolicy } from './remote-control-shared';
import { getPlatform } from '../lib/platform';
import {
Expand Down Expand Up @@ -82,7 +81,7 @@ export function ManagedVoiceSection({ onShowNetwork }: { onShowNetwork?: () => v
<>
<div className="mt-2 text-sm leading-relaxed text-foreground">
{voice.notEntitled ? (
<>{NO_PLAN_COPY} <ExternalTextLink href={HOSTED_PRICING_URL}>See Hosted plans</ExternalTextLink></>
<>{NO_PLAN_COPY} <HostedPlansLink /></>
) : 'Natural Voice is on.'}
</div>
<label className="mt-2 flex items-center gap-2">
Expand Down
64 changes: 61 additions & 3 deletions lib/src/components/SettingsDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setPlatform } from '../lib/platform';
import { FakePtyAdapter } from '../lib/platform/fake-adapter';
import { SettingsDialog, SETTINGS_SCROLL_MS } from './SettingsDialog';
import { makeStubBurrowLink, UNENROLLED_STATUS } from '../host/remote/test-burrow-link';
import { AlarmSettingsSection, SettingsDialog, SETTINGS_SCROLL_MS } from './SettingsDialog';
import { HOSTED_PRICING_URL } from './HostedEnrollment';
import { enrolledStatus, makeStubBurrowLink, UNENROLLED_STATUS } from '../host/remote/test-burrow-link';
import type { BurrowConsoleStatus } from '../host/remote/service-protocol';
import { makeStubManagedVoicePort } from '../lib/platform/test-ports';
import { networkPolicyResult, nothingPolicy } from '../remote/network-policy';
import { stubResizeObserver } from './wall/wall-test-utils';
import { setNativeFieldValue } from '../lib/dom';
import { applyAlertSettingsFromHost, DEFAULT_ALERT_SETTINGS, getAlertSettings } from '../lib/alert-settings';
import { setCommandWatched, getWatchedCommands } from '../lib/terminal-registry';
import { resetPushDevices, setCommandWatched, getWatchedCommands, setPushDevices } from '../lib/terminal-registry';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

Expand Down Expand Up @@ -267,6 +269,62 @@ describe('SettingsDialog under Network → Nothing', () => {
});
});

describe('SettingsDialog push line', () => {
/** An enrolled Hosted build whose relay socket is in `connection`. */
const hosted = (connection: BurrowConsoleStatus['connection']) =>
({ ...UNENROLLED_STATUS, enrolled: true, serving: true, burrowId: 'burrow-6f1c2a90', connection });

const pushLine = () => document.querySelector('[data-setting="push"]')?.textContent ?? '';

afterEach(() => resetPushDevices());

it('says an account with no plan sends no push, linking the plans, where the list fetch failed', async () => {
platform.burrow = makeStubBurrowLink({ status: hosted('not-entitled') });
setPushDevices({ status: 'error', devices: [] });
const openExternal = vi.fn();
Object.assign(platform, { openExternal });
await render();
expect(pushLine()).toContain('Your account has no Hosted plan, so no push is sent.');
expect(pushLine()).not.toContain('Could not reach the Relay');
const plans = [...document.querySelectorAll<HTMLButtonElement>('[data-setting="push"] button')]
.find((button) => button.textContent === 'See Hosted plans')!;
await act(async () => plans.click());
expect(openExternal).toHaveBeenCalledWith(HOSTED_PRICING_URL);
});

it('points a computer removed from its Hosted account at enrolling again below', async () => {
platform.burrow = makeStubBurrowLink({ status: hosted('removed') });
setPushDevices({ status: 'no-burrow', devices: [] });
await render();
expect(pushLine()).toContain(
'This computer was removed from your Hosted account, so no push is sent. Enroll it again under Hosted Relay below.',
);
expect(pushLine()).not.toContain('Connect this machine');
});

it('names the self-host Relay that refused, and never Hosted', async () => {
platform.burrow = makeStubBurrowLink({ status: enrolledStatus({ connection: 'removed' }) });
setPushDevices({ status: 'no-burrow', devices: [] });
await render();
expect(pushLine()).toMatch(/no longer accepts this computer, so no push is sent\. Disconnect and enroll it again under Persistent Relay below\./);
expect(pushLine()).not.toContain('Hosted');
});

it('keeps the network failure for a Relay that has not refused', async () => {
platform.burrow = makeStubBurrowLink({ status: hosted('connected') });
setPushDevices({ status: 'error', devices: [] });
await render();
expect(pushLine()).toContain('Could not reach the Relay to list phones.');
});

it('points the brief preview, which has no Remote control below it, at Settings → Network', async () => {
platform.burrow = makeStubBurrowLink({ status: hosted('removed') });
await act(async () => root.render(<AlarmSettingsSection sink="push" preview />));
await act(async () => {});
expect(text()).toContain('Enroll it again under Hosted Relay in Settings → Network.');
});
});

describe('SettingsDialog theme row', () => {
it.each([[true, true], [undefined, false]] as const)(
'offers the OpenVSX store only on a platform that does (offersThemeStore %s)',
Expand Down
53 changes: 46 additions & 7 deletions lib/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SecondsField, SwitchRow } from './AlarmSettingsControls';
import { ScrollFades } from './ScrollFades';
import type { AlertSink } from '../lib/alert-delivery-model';
import { useWorkspaceAlertPolicy } from './wall/use-workspace-alert-policy';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
import {
ELEVATED_PANE_SHADOW,
MODAL_OVERLAY_INSET,
Expand All @@ -20,9 +20,11 @@ import { ThemePicker } from './ThemePicker';
import { ShellPicker } from './ShellPicker';
import { WatchedCommandList } from './WatchedCommandList';
import { NetworkPhones, NetworkSettings, NetworkUpdates } from './NetworkSettings';
import { useNetworkPolicy } from './remote-control-shared';
import { hostOf, useNetworkPolicy } from './remote-control-shared';
import { PushTestButton, SpeakTestButton } from './AlarmTestButtons';
import { HOSTED_VOICE_URL } from './HostedEnrollment';
import { HOSTED_VOICE_URL, HostedPlansLink } from './HostedEnrollment';
import { relayRefuses, type BurrowConsoleStatus } from '../host/remote/service-protocol';
import { getBurrowStatusSnapshot, subscribeToBurrowStatus } from '../remote/burrow/burrow-status-store';
import { ManagedVoiceSection, NetworkTopicLink, useManagedVoiceOffered } from './ManagedVoiceSection';
import { getPlatform } from '../lib/platform';
import { getShellsSnapshot, subscribeToShells } from '../lib/shell-store';
Expand Down Expand Up @@ -52,12 +54,22 @@ const PICKER_ROW = 'flex items-center gap-1.5 text-sm text-foreground [&>div]:mi
* separate argument — see the comment on that branch below. The network
* policy's Nothing, which sends none, is the caller's line (`AlarmSettingsSection`).
*
* A Relay that refuses this Burrow is checked first, from the Burrow's own
* status: the Burrow then asks it nothing (`relayRefuses`), so the list reads
* `no-burrow` — or `error`, from a fetch that met the refusal before it
* latched — and neither names why no push is sent.
*
* The list is deliberately scoped to *this* machine, not the account: the ACL
* that authorizes these devices lives on the Burrow and never on the Relay
* (`docs/specs/remote-security-model.md`), so there is no account-wide device
* list to show and the copy must not imply one.
*/
function describePushTargets(push: PushDevicesState, remoteControlBelow: boolean): string {
function describePushTargets(
push: PushDevicesState,
burrow: BurrowConsoleStatus | null,
remoteControlBelow: boolean,
): ReactNode {
if (burrow && relayRefuses(burrow.connection)) return describeRefusal(burrow, remoteControlBelow);
if (push.status === 'loading') return 'Looking for phones…';
if (push.status === 'error') return 'Could not reach the Relay to list phones.';
// The preview never shows Remote control, so it also omits "below".
Expand All @@ -83,6 +95,24 @@ function describePushTargets(push: PushDevicesState, remoteControlBelow: boolean
return `Push will be sent to ${push.devices.map((device) => device.label).join(', ')}`;
}

/**
* Why a refusing Relay sends no push, and the way back: the plans for an
* account with none, otherwise enrolling again under the persistent Relay's
* heading in Remote control. A self-host build's line never names Hosted.
*/
function describeRefusal(status: BurrowConsoleStatus, remoteControlBelow: boolean): ReactNode {
const where = remoteControlBelow ? 'below' : 'in Settings → Network';
if (status.relayMode === 'self-host') {
return `${hostOf(status.relayOrigin)} no longer accepts this computer, so no push is sent. ` +
`Disconnect and enroll it again under Persistent Relay ${where}.`;
}
if (status.connection === 'not-entitled') {
return <>Your account has no Hosted plan, so no push is sent. <HostedPlansLink /></>;
}
return `This computer was removed from your Hosted account, so no push is sent. ` +
`Enroll it again under Hosted Relay ${where}.`;
}

const TOPICS = [
{ id: 'general', label: 'General', icon: GearIcon, groups: ['theme', 'shell'] },
{ id: 'notifications', label: 'Notifications', icon: BellIcon, groups: ['speech', 'push', 'watcher', 'inactivity'] },
Expand Down Expand Up @@ -440,7 +470,7 @@ export function SettingsDialog({ onClose }: { onClose: () => void }) {
</div>
</section>
</TopicSection>
{/* Network stays below push, whose `no-burrow` copy says "below". */}
{/* Network stays below push, whose `no-burrow` and refusal copy say "below". */}
<TopicSection id="network" hidden={!visible('network')}>
<div data-setting="network" hidden={!matches('network')}>
<NetworkSettings />
Expand Down Expand Up @@ -488,7 +518,6 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: {
}) {
// The dialog edits application defaults; only the baseboard preview shows the Workspace's effective value.
const { policy: settings } = useWorkspaceAlertPolicy(preview);
const push = useSyncExternalStore(subscribeToPushDevices, getPushDevices);
const hasBurrowService = getPlatform().burrow !== undefined;
const managedVoiceOffered = useManagedVoiceOffered();
// Under Nothing the service sends no push and managed voice asks nothing
Expand Down Expand Up @@ -549,12 +578,22 @@ export function AlarmSettingsSection({ sink, preview = false, onShowNetwork }: {
{networkOff ? (
<>Push is off while <NetworkTopicLink onShow={onShowNetwork} /> is set to Nowhere.</>
) : (
describePushTargets(push, hasBurrowService && !preview)
<PushTargets remoteControlBelow={hasBurrowService && !preview} />
)}
</AlarmSinkSection>
);
}

/**
* {@link describePushTargets} over the live stores: its own component so only
* the push sink, not speech, keeps the Burrow status polled.
*/
function PushTargets({ remoteControlBelow }: { remoteControlBelow: boolean }) {
const push = useSyncExternalStore(subscribeToPushDevices, getPushDevices);
const burrow = useSyncExternalStore(subscribeToBurrowStatus, getBurrowStatusSnapshot);
return describePushTargets(push, burrow.kind === 'ready' ? burrow.status : null, remoteControlBelow);
}

/**
* One alarm sink: a switch that gates an indented delay field, with optional
* explanatory text under it. Speech and push are the same shape, so the layout
Expand Down
53 changes: 53 additions & 0 deletions lib/src/stories/SettingsDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,59 @@ export const PushNotEnrolled: Story = {
},
};

/** An enrolled Hosted build whose relay socket is in `connection`. */
function hostedEnrolled(connection: BurrowConsoleStatus['connection']): BurrowConsoleStatus {
return { ...UNENROLLED_STATUS, enrolled: true, serving: true, burrowId: 'burrow-6f1c2a90', connection };
}

/**
* The Relay refused this Burrow because its account has no Hosted plan. The
* Burrow asks a refusing Relay nothing, so the device list is no help; the
* line says why from the Burrow's status and links the plans.
*/
export const PushNotEntitled: Story = {
parameters: {
primedWatchedCommands: ['claude'],
primedAlertSettings: { pushEnabled: true },
primedPushDevices: { status: 'no-burrow', devices: [] },
primedBurrow: { status: hostedEnrolled('not-entitled') },
},
play: async ({ canvasElement }) => {
await selectTopic('Notifications')({ canvasElement });
const body = dialog(canvasElement);
await body.findByText(/no Hosted plan, so no push is sent/);
await expect(within(body.getByRole('region', { name: 'Notifications' })).getByRole('button', { name: 'See Hosted plans' })).toBeVisible();
},
};

/** This computer was removed from its Hosted account: enrolling again, below, is the way back. */
export const PushRemoved: Story = {
parameters: {
primedWatchedCommands: ['claude'],
primedAlertSettings: { pushEnabled: true },
primedPushDevices: { status: 'no-burrow', devices: [] },
primedBurrow: { status: hostedEnrolled('removed') },
},
play: async ({ canvasElement }) => {
await selectTopic('Notifications')({ canvasElement });
await dialog(canvasElement).findByText(/Enroll it again under Hosted Relay below/);
},
};

/** The self-host Relay refused: the line names it, and never Hosted. */
export const PushSelfHostRemoved: Story = {
parameters: {
primedWatchedCommands: ['claude'],
primedAlertSettings: { pushEnabled: true },
primedPushDevices: { status: 'no-burrow', devices: [] },
primedBurrow: { status: enrolledStatus({ connection: 'removed' }) },
},
play: async ({ canvasElement }) => {
await selectTopic('Notifications')({ canvasElement });
await dialog(canvasElement).findByText(/no longer accepts this computer/);
},
};

/**
* Non-default timings, proving every number field renders the stored value
* rather than a hardcoded one.
Expand Down
Loading