Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ const clientSettings: ClientSettings = {
sidebarThreadSortOrder: "created_at",
sidebarThreadPreviewCount: 6,
legacySidebarEnabled: false,
threadCompletionNotifications: true,
threadCompletionNotificationSound: false,
threadCompletionNotificationVolume: 70,
timestampFormat: "24-hour",
wordWrap: true,
};
Expand Down
106 changes: 106 additions & 0 deletions apps/web/src/components/ThreadCompletionNotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { useEffect, useMemo, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
import { projectKey } from "@t3tools/client-runtime/state/entities";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { useClientSettings } from "~/hooks/useSettings";
import { showNotification, useNotificationPermission } from "~/notificationPermission";
import { playNotificationChime, primeNotificationChime } from "~/notificationChime";
import { useProjects, useThreadShells } from "~/state/entities";
import { buildThreadRouteParams } from "~/threadRoutes";
import {
deriveThreadCompletionNotifications,
NO_OBSERVED_THREADS,
type ObservedThreads,
type ThreadCompletionNotification,
} from "~/threadCompletionNotifications.logic";

/**
* Raises an OS notification when a thread finishes its turn.
*
* Belongs in the app shell rather than the chat route: a turn that finishes
* while the user is in Settings should still reach them, and remounting would
* re-seed the watcher and swallow it.
*/
export function ThreadCompletionNotifications() {
const enabled = useClientSettings((settings) => settings.threadCompletionNotifications);
const { permission } = useNotificationPermission();

// Nothing below this line subscribes to thread updates until the user has
// both opted in and granted permission.
if (!enabled || permission !== "granted") return null;
return <ThreadCompletionWatcher />;
}

function ThreadCompletionWatcher() {
const soundEnabled = useClientSettings((settings) => settings.threadCompletionNotificationSound);
const volume = useClientSettings((settings) => settings.threadCompletionNotificationVolume);
const threads = useThreadShells();
const projects = useProjects();
const router = useRouter();
const observed = useRef<ObservedThreads>(NO_OBSERVED_THREADS);

const projectTitles = useMemo(() => {
const titles = new Map<string, string>();
for (const project of projects) {
titles.set(projectKey(scopeProjectRef(project.environmentId, project.id)), project.title);
}
return titles;
}, [projects]);

// A reload leaves the audio context locked until the page sees a gesture,
// and the user has no reason to open Settings again to unlock it.
useEffect(() => {
if (!soundEnabled) return;
const unlock = () => primeNotificationChime();
const options = { capture: true, once: true } as const;
window.addEventListener("pointerdown", unlock, options);
window.addEventListener("keydown", unlock, options);
return () => {
window.removeEventListener("pointerdown", unlock, options);
window.removeEventListener("keydown", unlock, options);
};
}, [soundEnabled]);

useEffect(() => {
const result = deriveThreadCompletionNotifications({ threads, observed: observed.current });
observed.current = result.observed;

let delivered = 0;
for (const notification of result.notifications) {
if (show(notification, projectTitles, router)) delivered += 1;
}

// One chime for the batch: two threads settling in the same snapshot
// should not stack two sounds.
if (soundEnabled && delivered > 0) {
playNotificationChime(volume);
}
}, [projectTitles, router, soundEnabled, threads, volume]);

return null;
}

function show(
notification: ThreadCompletionNotification,
projectTitles: ReadonlyMap<string, string>,
router: ReturnType<typeof useRouter>,
): boolean {
const threadRef = scopeThreadRef(notification.environmentId, notification.threadId);
const projectTitle = projectTitles.get(
projectKey(scopeProjectRef(notification.environmentId, notification.projectId)),
);

return showNotification({
title: notification.title,
body: projectTitle ? `${projectTitle} · Turn finished` : "Turn finished",
// Turn-scoped: replacing a thread's earlier card would re-render it
// without re-alerting, which is the one thing this must not do.
tag: `t3code-thread-complete:${notification.turnId}`,
onClick: () => {
void router.navigate({
to: "/$environmentId/$threadId",
params: buildThreadRouteParams(threadRef),
});
},
});
}
204 changes: 204 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ import {
MAX_CODE_FONT_SIZE,
MAX_GLASS_OPACITY,
MAX_INTERFACE_FONT_SIZE,
MAX_NOTIFICATION_VOLUME,
MAX_PROMPT_FONT_SIZE,
MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MAX_TERMINAL_FONT_SIZE,
MIN_CODE_FONT_SIZE,
MIN_GLASS_OPACITY,
MIN_INTERFACE_FONT_SIZE,
MIN_NOTIFICATION_VOLUME,
MIN_PROMPT_FONT_SIZE,
MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
MIN_TERMINAL_FONT_SIZE,
type UnifiedSettings,
} from "@t3tools/contracts/settings";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { createModelSelection } from "@t3tools/shared/model";
Expand Down Expand Up @@ -64,6 +67,12 @@ import {
import { useLocalStorage } from "../../hooks/useLocalStorage";
import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import { useThreadActions } from "../../hooks/useThreadActions";
import {
type NotificationPermissionState,
showNotification,
useNotificationPermission,
} from "../../notificationPermission";
import { playNotificationChime, primeNotificationChime } from "../../notificationChime";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Restore skips notification settings

Medium Severity

The new General settings threadCompletionNotifications, threadCompletionNotificationSound, and threadCompletionNotificationVolume are omitted from changedSettingLabels and the restoreDefaults updateSettings payload. Changing them never dirties Restore defaults, and Restore leaves them unchanged while resetting neighboring General rows.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6c24b9c89ad9718b207759950851abf0a40e9561. Configure here.

import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getCustomModelOptionsByInstance,
Expand Down Expand Up @@ -1763,6 +1772,199 @@ function LegacyFeaturesSection() {
);
}

const NOTIFICATION_PERMISSION_STATUS: Partial<Record<NotificationPermissionState, string>> = {
default: "Notification permission was reset. Use Test to ask for it again.",
denied:
"Notifications are blocked for this site. Allow them in your browser's site settings to turn this on.",
unsupported: "This browser cannot show system notifications.",
};

function ThreadCompletionNotificationVolumeRow({
volume,
updateSettings,
}: {
volume: number;
updateSettings: ReturnType<typeof useUpdatePrimarySettings>;
}) {
const previewTimerRef = useRef<number | null>(null);
useEffect(
() => () => {
if (previewTimerRef.current !== null) window.clearTimeout(previewTimerRef.current);
},
[],
);

const volumeRatio =
(volume - MIN_NOTIFICATION_VOLUME) / (MAX_NOTIFICATION_VOLUME - MIN_NOTIFICATION_VOLUME);
const volumeSliderStyle = {
"--settings-slider-progress": `${volumeRatio * 100}%`,
"--settings-slider-fill-offset": `${0.5 - volumeRatio}rem`,
} as CSSProperties;

return (
<SettingsRow
{...searchableSetting("thread-completion-notification-volume")}
description="Set the volume of the completion notification chime."
control={
<div className="flex w-full items-center gap-3 sm:w-52">
<output
className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
htmlFor="thread-completion-notification-volume-slider"
>
{volume}%
</output>
<input
aria-label="Notification volume"
className="settings-slider min-w-0 flex-1"
id="thread-completion-notification-volume-slider"
max={MAX_NOTIFICATION_VOLUME}
min={MIN_NOTIFICATION_VOLUME}
onChange={(event) => {
const nextVolume = Number(event.currentTarget.value);
if (
!Number.isInteger(nextVolume) ||
nextVolume < MIN_NOTIFICATION_VOLUME ||
nextVolume > MAX_NOTIFICATION_VOLUME
) {
return;
}

primeNotificationChime();
updateSettings({ threadCompletionNotificationVolume: nextVolume });
if (previewTimerRef.current !== null) {
window.clearTimeout(previewTimerRef.current);
}
previewTimerRef.current = window.setTimeout(() => {
previewTimerRef.current = null;
playNotificationChime(nextVolume);
}, 400);
}}
step={10}
style={volumeSliderStyle}
type="range"
value={volume}
/>
Comment on lines +1809 to +1846

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This reconstructs the glass-opacity slider row (lines 1031-1058): identical output badge classes, identical settings-slider min-w-0 flex-1 input, and a second copy of the --settings-slider-progress / --settings-slider-fill-offset math, which encodes the half-thumb offset the .settings-slider CSS contract in index.css depends on. With two copies, a change to that fill math or to the badge geometry only lands on one slider. Consider extracting a named control next to SettingsRow in settingsLayout.tsx (value, min, max, step, aria-label, id, onChange) and rendering both rows through it, keeping call-site-only concerns such as the chime preview timer here.

Posted via Macroscope — UI Consistency

</div>
}
/>
);
}

function ThreadCompletionNotificationRows({
settings,
updateSettings,
}: {
settings: UnifiedSettings;
updateSettings: ReturnType<typeof useUpdatePrimarySettings>;
}) {
const { permission, request } = useNotificationPermission();
const enabled = settings.threadCompletionNotifications;

// The stored preference is what the switch reflects, so a permission the
// user revokes later still leaves them a switch they can turn off.
const enable = useCallback(() => {
if (permission === "denied" || permission === "unsupported") return;
// Synchronously, while the click is still the current task: an audio
// context created after awaiting the permission prompt starts suspended
// and stays that way.
if (settings.threadCompletionNotificationSound) {
primeNotificationChime();
}
void request().then((next) => {
if (next === "granted") {
updateSettings({ threadCompletionNotifications: true });
}
});
}, [permission, request, settings.threadCompletionNotificationSound, updateSettings]);

const testNotification = useCallback(() => {
const soundEnabled = settings.threadCompletionNotificationSound;
if (soundEnabled) {
primeNotificationChime();
}

void (async () => {
const nextPermission = permission === "granted" ? permission : await request();
if (nextPermission !== "granted") return;

const delivered = showNotification({
title: "Test notification",
body: "Notifications are working.",
tag: "t3code-thread-complete:test",
});
if (delivered && soundEnabled) {
playNotificationChime(settings.threadCompletionNotificationVolume);
}
})();
}, [
permission,
request,
settings.threadCompletionNotificationSound,
settings.threadCompletionNotificationVolume,
]);

return (
<>
<SettingsRow
{...searchableSetting("thread-completion-notifications")}
description="Show a system notification when a thread finishes its turn. Only fires while T3 Code is open in a tab."
status={
enabled || permission === "denied" || permission === "unsupported"
? NOTIFICATION_PERMISSION_STATUS[permission]
: null
}
control={
<div className="flex items-center gap-2">
{enabled ? (
<Button type="button" size="xs" variant="outline" onClick={testNotification}>
Test
</Button>
) : null}
<Switch
checked={enabled}
onCheckedChange={(checked) => {
if (checked) {
enable();
return;
}
updateSettings({ threadCompletionNotifications: false });
}}
aria-label="Thread completion notifications"
/>
Comment on lines +1923 to +1933

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When permission is denied or unsupported, enable() returns early, so this switch accepts the click, reports no state change, and snaps back — an interactive-looking control that cannot do anything. Switch already carries a disabled contract (data-disabled:cursor-not-allowed, reduced opacity, no keyboard activation), and the row's status text already explains why. Consider disabling it only while it is off and permission cannot be granted, so the documented case (permission revoked after opting in) still leaves a switch the user can turn off.

Suggested change
<Switch
checked={enabled}
onCheckedChange={(checked) => {
if (checked) {
enable();
return;
}
updateSettings({ threadCompletionNotifications: false });
}}
aria-label="Thread completion notifications"
/>
<Switch
checked={enabled}
disabled={!enabled && (permission === "denied" || permission === "unsupported")}
onCheckedChange={(checked) => {
if (checked) {
enable();
return;
}
updateSettings({ threadCompletionNotifications: false });
}}
aria-label="Thread completion notifications"
/>

Posted via Macroscope — UI Consistency

</div>
}
/>

{enabled ? (
<SettingsRow
{...searchableSetting("thread-completion-notification-sound")}
description="Play a short chime with each completion notification."
control={
<Switch
checked={settings.threadCompletionNotificationSound}
onCheckedChange={(checked) => {
const next = Boolean(checked);
if (next) {
primeNotificationChime();
}
updateSettings({ threadCompletionNotificationSound: next });
}}
aria-label="Thread completion notification sound"
/>
}
/>
) : null}

{enabled && settings.threadCompletionNotificationSound ? (
<ThreadCompletionNotificationVolumeRow
volume={settings.threadCompletionNotificationVolume}
updateSettings={updateSettings}
/>
) : null}
</>
);
}

export function GeneralSettingsPanel() {
const settings = usePrimarySettings();
const updateSettings = useUpdatePrimarySettings();
Expand Down Expand Up @@ -2256,6 +2458,8 @@ export function GeneralSettingsPanel() {
}
/>

<ThreadCompletionNotificationRows settings={settings} updateSettings={updateSettings} />

{isElectron ? (
<SettingsRow
{...searchableSetting("quit-confirmation")}
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,25 @@ export const SETTINGS_SEARCH_ITEMS = [
to: "/settings/general",
desktopOnly: true,
},
{
id: "thread-completion-notifications",
title: "Completion notifications",
to: "/settings/general",
},
{
id: "thread-completion-notification-sound",
title: "Notification sound",
to: "/settings/general",
// Its row only exists once notifications are on; the toggle that turns
// them on is the stable destination.
targetId: "thread-completion-notifications",
},
{
id: "thread-completion-notification-volume",
title: "Notification volume",
to: "/settings/general",
targetId: "thread-completion-notifications",
},
{
id: "text-generation-model",
title: "Text generation model",
Expand Down
Loading
Loading