Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts";

const clientSettings: ClientSettings = {
...DEFAULT_CLIENT_SETTINGS,
notificationMode: "notifications-and-sound",
appearanceContrast: 100,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
Expand Down
Binary file added apps/web/src/assets/notification-completion.mp3
Binary file not shown.
Binary file added apps/web/src/assets/notification-input.mp3
Binary file not shown.
111 changes: 111 additions & 0 deletions apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { useAtomValue } from "@effect/atom-react";
import { useNavigate } from "@tanstack/react-router";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { useEffect, useRef } from "react";

import { getClientSettings, useClientSettings } from "../hooks/useSettings";
import { useEnvironments } from "../state/environments";
import { environmentShell } from "../state/shell";
import {
hasDesktopNotifications,
hasNotificationSound,
playNotificationSound,
unlockNotificationAudio,
} from "../threadNotifications";
import { resolveSidebarThreadStatus } from "./Sidebar.logic";

export function ThreadNotificationCoordinator() {
const { environments } = useEnvironments();
const mode = useClientSettings((settings) => settings.notificationMode);

useEffect(() => {
if (!hasNotificationSound(mode)) return;
document.addEventListener("pointerdown", unlockNotificationAudio);
document.addEventListener("keydown", unlockNotificationAudio);
return () => {
document.removeEventListener("pointerdown", unlockNotificationAudio);
document.removeEventListener("keydown", unlockNotificationAudio);
};
}, [mode]);

if (mode === "off") return null;

return environments.map((environment) => (
<EnvironmentNotifications
key={environment.environmentId}
environmentId={environment.environmentId}
/>
));
}

function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
const mode = useClientSettings((settings) => settings.notificationMode);
const navigate = useNavigate();
const previous = useRef(new Map<ThreadId, { input: string | null; completion: string | null }>());

useEffect(() => {
if (shell.status !== "live" || Option.isNone(shell.snapshot)) {
previous.current.clear();
return;
}
const next = new Map<ThreadId, { input: string | null; completion: string | null }>();
for (const thread of shell.snapshot.value.threads) {
const status = resolveSidebarThreadStatus(thread);
const prior = previous.current.get(thread.id);
const input =
status === "input" || status === "approval"
? `${thread.latestTurn?.turnId ?? ""}:${status}`
: null;
const completion =
status === "ready" && thread.latestTurn?.state === "completed"
? thread.latestTurn.completedAt
: (prior?.completion ?? null);
next.set(thread.id, { input, completion });
if (!prior || mode === "off" || thread.archivedAt !== null) continue;
const kind =
input && input !== prior.input
? "input"
: completion &&
(prior.completion === null || Date.parse(completion) > Date.parse(prior.completion))
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
? "completion"
: null;
if (!kind) continue;
if (hasNotificationSound(mode)) {
void playNotificationSound(kind, () =>
hasNotificationSound(getClientSettings().notificationMode),
);
}
if (
!hasDesktopNotifications(mode) ||
typeof Notification === "undefined" ||
Notification.permission !== "granted"
)
continue;
try {
const notification = new Notification(
kind === "completion"
? "Thread completed"
: status === "approval"
? "Approval needed"
: "Input needed",
{ body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true },
);
notification.addEventListener("click", () => {
notification.close();
window.focus();
void navigate({
to: "/$environmentId/$threadId",
params: { environmentId, threadId: thread.id },
});
});
} catch {
// Some browsers expose Notification but reject desktop presentation.
}
}
previous.current = next;
}, [environmentId, mode, navigate, shell]);

return null;
}
83 changes: 83 additions & 0 deletions apps/web/src/components/settings/NotificationSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from "react";

import {
hasDesktopNotifications,
hasNotificationSound,
NOTIFICATION_MODE_LABELS,
unlockNotificationAudio,
} from "../../threadNotifications";
import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
import { SettingsRow } from "./settingsLayout";
import { searchableSetting } from "./settingsSearch";
import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings";

export function NotificationSettings() {
const mode = useScopedSettings((settings) => settings.notificationMode);
const updateSettings = useUpdateScopedSettings();
const [permissionMessage, setPermissionMessage] = useState<string | null>(null);
const [requesting, setRequesting] = useState(false);

return (
<SettingsRow
{...searchableSetting("thread-notifications")}
description={
permissionMessage ??
"Alert when a thread finishes or needs input. Applies to this device while T3 Code is open."
}
control={
<Select
value={mode}
disabled={requesting}
onValueChange={async (value) => {
if (
value !== "off" &&
value !== "notifications" &&
value !== "sound" &&
value !== "notifications-and-sound"
)
return;
setPermissionMessage(null);
if (hasNotificationSound(value)) unlockNotificationAudio();
if (hasDesktopNotifications(value)) {
if (typeof Notification === "undefined" || !window.isSecureContext) {
setPermissionMessage(
"Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.",
);
return;
}
setRequesting(true);
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
setPermissionMessage(
"Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.",
);
return;
}
} catch {
setPermissionMessage(
"Notifications are unavailable in this browser. Sound only is still available.",
);
return;
} finally {
setRequesting(false);
}
}
updateSettings({ notificationMode: value });
}}
>
<SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Thread notifications">
<SelectValue>{NOTIFICATION_MODE_LABELS[mode]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => (
<SelectItem key={value} hideIndicator value={value}>
{label}
</SelectItem>
))}
</SelectPopup>
</Select>
}
/>
);
}
7 changes: 7 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Spinner } from "~/components/ui/spinner";
import { NotificationSettings } from "./NotificationSettings";
import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react";
import { Link, useNavigate } from "@tanstack/react-router";
import type { CSSProperties, ReactNode } from "react";
Expand Down Expand Up @@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
? ["Thread notifications"]
: []),
...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
? ["Visible threads"]
: []),
Expand Down Expand Up @@ -632,6 +636,7 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.sidebarThreadPreviewCount,
settings.showSkillsInSlashMenu,
settings.timestampFormat,
settings.notificationMode,
settings.wordWrap,
followSystem,
theme,
Expand Down Expand Up @@ -705,6 +710,7 @@ export function useSettingsRestore(onRestored?: () => void) {
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
Expand Down Expand Up @@ -2238,6 +2244,7 @@ export function GeneralSettingsPanel() {
</SettingsSection>

<SettingsSection id="behavior" title="Behavior">
<NotificationSettings />
<SettingsRow
{...searchableSetting("time-format")}
description="System default follows your browser or OS clock preference."
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ export const SETTINGS_SEARCH_ITEMS = [
requiresThreadAutoSettlement: true,
scope: "project-defaults",
},
{
id: "thread-notifications",
title: "Thread notifications",
to: "/settings/general",
searchTerms: ["notification sound alert completion input approval desktop"],
},
{
id: "time-format",
title: "Time format",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt
import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
Expand Down Expand Up @@ -197,6 +198,7 @@ function RootRouteView() {
<ConnectOnboardingDialog />
<SshPasswordPromptDialog />
<SnapShotCoordinator />
<ThreadNotificationCoordinator />
<ConfirmDialogHost />
<SlowRpcRequestToastCoordinator />
<HostedStaticEnvironmentBootstrap />
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/threadNotifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { ClientSettings } from "@t3tools/contracts/settings";

import completionUrl from "./assets/notification-completion.mp3";
import inputUrl from "./assets/notification-input.mp3";

type NotificationMode = ClientSettings["notificationMode"];
export const NOTIFICATION_MODE_LABELS = {
off: "Off",
notifications: "Notifications only",
sound: "Sound only",
"notifications-and-sound": "Notifications with sound",
} satisfies Record<NotificationMode, string>;

export function hasNotificationSound(mode: NotificationMode) {
return mode === "sound" || mode === "notifications-and-sound";
}

export function hasDesktopNotifications(mode: NotificationMode) {
return mode === "notifications" || mode === "notifications-and-sound";
}

let audioContext: AudioContext | undefined;
const buffers = new Map<string, Promise<AudioBuffer>>();

/** Called from a gesture so browsers allow later background playback. */
export function unlockNotificationAudio() {
audioContext ??= new AudioContext();
void audioContext.resume().catch(() => undefined);
}

export async function playNotificationSound(
kind: "completion" | "input",
shouldPlay: () => boolean,
) {
if (!audioContext || audioContext.state !== "running") return;
const context = audioContext;
const url = kind === "completion" ? completionUrl : inputUrl;
try {
let buffer = buffers.get(url);
if (!buffer) {
buffer = fetch(url)
.then((response) => response.arrayBuffer())
.then((data) => context.decodeAudioData(data));
buffers.set(url, buffer);
}
const decoded = await buffer;
if (!shouldPlay() || context.state !== "running") return;
const source = context.createBufferSource();
source.buffer = decoded;
source.connect(context.destination);
source.start();
} catch {
buffers.delete(url);
}
}
26 changes: 26 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => {
});
});

describe("ClientSettings notifications", () => {
it("requires opt-in when existing settings omit notification preferences", () => {
expect(decodeClientSettings({}).notificationMode).toBe("off");
expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode");
});

it.each(["off", "notifications", "sound", "notifications-and-sound"])(
"round-trips the %s mode",
(notificationMode) => {
const settings = decodeClientSettings({ notificationMode });
expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode);
expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe(
notificationMode,
);
},
);

it.each(["always", true, null])(
"rejects unsupported notification mode %s",
(notificationMode) => {
expect(() => decodeClientSettings({ notificationMode })).toThrow();
expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow();
},
);
});

describe("ClientSettings diff colors", () => {
it("keeps red and green for existing settings without a saved palette", () => {
expect(decodeClientSettings({}).diffColorScheme).toBe("red-green");
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = {
kind: "both-shift-keys",
};

export const NotificationMode = Schema.Literals([
"off",
"notifications",
"sound",
"notifications-and-sound",
]);
export type NotificationMode = typeof NotificationMode.Type;

export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]);
export type QuitConfirmationMode = typeof QuitConfirmationMode.Type;
const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold";
Expand Down Expand Up @@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record(
export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]);

export const ClientSettingsSchema = Schema.Struct({
notificationMode: NotificationMode.pipe(
Schema.withDecodingDefault(Effect.succeed("off" as const)),
),
diffColorScheme: DiffColorScheme.pipe(
Schema.withDecodingDefault(Effect.succeed("red-green" as const)),
),
Expand Down Expand Up @@ -1415,6 +1426,7 @@ export const ServerSettingsPatch = Schema.Struct({
export type ServerSettingsPatch = typeof ServerSettingsPatch.Type;

export const ClientSettingsPatch = Schema.Struct({
notificationMode: Schema.optionalKey(NotificationMode),
diffColorScheme: Schema.optionalKey(DiffColorScheme),
loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
Expand Down
Loading
Loading