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
5 changes: 5 additions & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const clientSettings: ClientSettings = {
confirmThreadDelete: false,
diffWordWrap: true,
favorites: [],
notificationSoundEnabled: false,
notificationSoundOnTurnEnd: false,
notificationSoundOnApproval: false,
notificationSoundOnQuestion: false,
notificationSoundFocusRule: "unfocused-or-different-thread",
providerModelPreferences: {},
sidebarProjectGroupingMode: "repository_path",
sidebarProjectGroupingOverrides: {
Expand Down
Binary file added apps/web/public/sounds/notification.mp3
Binary file not shown.
218 changes: 218 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
sortProviderInstanceEntries,
} from "../../providerInstances";
import { ensureLocalApi, readLocalApi } from "../../localApi";
import { notificationSoundManager } from "../../notificationSound";
import { useShallow } from "zustand/react/shallow";
import {
selectProjectsAcrossEnvironments,
Expand Down Expand Up @@ -95,6 +96,12 @@ const TIMESTAMP_FORMAT_LABELS = {
"24-hour": "24-hour",
} as const;

const NOTIFICATION_FOCUS_RULE_LABELS = {
always: "Always",
"unfocused-only": "Window not focused",
"unfocused-or-different-thread": "Window not focused or viewing a different thread",
} as const;

const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");

function withoutProviderInstanceKey<V>(
Expand Down Expand Up @@ -404,6 +411,25 @@ export function useSettingsRestore(onRestored?: () => void) {
...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
? ["Delete confirmation"]
: []),
...(settings.notificationSoundEnabled !== DEFAULT_UNIFIED_SETTINGS.notificationSoundEnabled
? ["Notification sound"]
: []),
...(settings.notificationSoundOnTurnEnd !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnTurnEnd
? ["Notification on agent finish"]
: []),
...(settings.notificationSoundOnApproval !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnApproval
? ["Notification on approval"]
: []),
...(settings.notificationSoundOnQuestion !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnQuestion
? ["Notification on question"]
: []),
...(settings.notificationSoundFocusRule !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundFocusRule
? ["Notification focus rule"]
: []),
...(isGitWritingModelDirty ? ["Git writing model"] : []),
...(areProviderSettingsDirty ? ["Providers"] : []),
],
Expand All @@ -417,6 +443,11 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.defaultThreadEnvMode,
settings.diffWordWrap,
settings.enableAssistantStreaming,
settings.notificationSoundEnabled,
settings.notificationSoundOnTurnEnd,
settings.notificationSoundOnApproval,
settings.notificationSoundOnQuestion,
settings.notificationSoundFocusRule,
settings.timestampFormat,
theme,
],
Expand Down Expand Up @@ -1138,6 +1169,193 @@ export function GeneralSettingsPanel() {
/>
</SettingsSection>

<SettingsSection title="Notifications">
<SettingsRow
title="Notification sound"
description="Play a sound when an agent needs your attention."
resetAction={
settings.notificationSoundEnabled !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundEnabled ? (
<SettingResetButton
label="notification sound"
onClick={() =>
updateSettings({
notificationSoundEnabled: DEFAULT_UNIFIED_SETTINGS.notificationSoundEnabled,
})
}
/>
) : null
}
control={
<>
<Button
variant="outline"
size="xs"
onClick={() => {
void notificationSoundManager.playTest().catch((error) => {
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not play sound",
description:
error instanceof Error
? error.message
: "Audio playback was blocked by the browser.",
}),
);
});
}}
>
Play sound
</Button>
<Switch
checked={settings.notificationSoundEnabled}
onCheckedChange={(checked) =>
updateSettings({ notificationSoundEnabled: Boolean(checked) })
}
aria-label="Enable notification sound"
/>
</>
}
/>

<SettingsRow
title="When agent finishes"
description="Play when a turn completes, errors, or is interrupted."
resetAction={
settings.notificationSoundOnTurnEnd !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnTurnEnd ? (
<SettingResetButton
label="notification on agent finish"
onClick={() =>
updateSettings({
notificationSoundOnTurnEnd: DEFAULT_UNIFIED_SETTINGS.notificationSoundOnTurnEnd,
})
}
/>
) : null
}
control={
<Switch
checked={settings.notificationSoundOnTurnEnd}
disabled={!settings.notificationSoundEnabled}
onCheckedChange={(checked) =>
updateSettings({ notificationSoundOnTurnEnd: Boolean(checked) })
}
aria-label="Play sound when an agent finishes"
/>
}
/>

<SettingsRow
title="When approval requested"
description="Play when the agent asks to run a command, edit a file, or proposes a plan."
resetAction={
settings.notificationSoundOnApproval !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnApproval ? (
<SettingResetButton
label="notification on approval"
onClick={() =>
updateSettings({
notificationSoundOnApproval:
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnApproval,
})
}
/>
) : null
}
control={
<Switch
checked={settings.notificationSoundOnApproval}
disabled={!settings.notificationSoundEnabled}
onCheckedChange={(checked) =>
updateSettings({ notificationSoundOnApproval: Boolean(checked) })
}
aria-label="Play sound when an approval is requested"
/>
}
/>

<SettingsRow
title="When question asked"
description="Play when the agent asks a clarifying question."
resetAction={
settings.notificationSoundOnQuestion !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnQuestion ? (
<SettingResetButton
label="notification on question"
onClick={() =>
updateSettings({
notificationSoundOnQuestion:
DEFAULT_UNIFIED_SETTINGS.notificationSoundOnQuestion,
})
}
/>
) : null
}
control={
<Switch
checked={settings.notificationSoundOnQuestion}
disabled={!settings.notificationSoundEnabled}
onCheckedChange={(checked) =>
updateSettings({ notificationSoundOnQuestion: Boolean(checked) })
}
aria-label="Play sound when a question is asked"
/>
}
/>

<SettingsRow
title="Play sound when"
description="Choose when sounds are allowed to play."
resetAction={
settings.notificationSoundFocusRule !==
DEFAULT_UNIFIED_SETTINGS.notificationSoundFocusRule ? (
<SettingResetButton
label="notification focus rule"
onClick={() =>
updateSettings({
notificationSoundFocusRule: DEFAULT_UNIFIED_SETTINGS.notificationSoundFocusRule,
})
}
/>
) : null
}
control={
<Select
value={settings.notificationSoundFocusRule}
disabled={!settings.notificationSoundEnabled}
onValueChange={(value) => {
if (
value === "always" ||
value === "unfocused-only" ||
value === "unfocused-or-different-thread"
) {
updateSettings({ notificationSoundFocusRule: value });
}
}}
>
<SelectTrigger className="w-full sm:w-72" aria-label="Notification focus rule">
<SelectValue>
{NOTIFICATION_FOCUS_RULE_LABELS[settings.notificationSoundFocusRule]}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="always">
{NOTIFICATION_FOCUS_RULE_LABELS.always}
</SelectItem>
<SelectItem hideIndicator value="unfocused-only">
{NOTIFICATION_FOCUS_RULE_LABELS["unfocused-only"]}
</SelectItem>
<SelectItem hideIndicator value="unfocused-or-different-thread">
{NOTIFICATION_FOCUS_RULE_LABELS["unfocused-or-different-thread"]}
</SelectItem>
</SelectPopup>
</Select>
}
/>
</SettingsSection>

<SettingsSection
title="Providers"
headerAction={
Expand Down
55 changes: 55 additions & 0 deletions apps/web/src/environments/runtime/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import {
type TerminalEvent,
ThreadId,
} from "@t3tools/contracts";
import {
deriveNotificationTriggers,
notificationSoundManager,
type NotificationThreadShellLike,
type ThreadShellMap,
} from "~/notificationSound";
import type { SidebarThreadSummary } from "~/types";
import { type QueryClient } from "@tanstack/react-query";
import { Throttler } from "@tanstack/react-pacer";
import {
Expand Down Expand Up @@ -621,6 +628,28 @@ export function shouldApplyTerminalEvent(input: {
return input.hasDraftThread;
}

function summaryToNotificationShell(summary: SidebarThreadSummary): NotificationThreadShellLike {
return {
archivedAt: summary.archivedAt,
session: summary.session ? { orchestrationStatus: summary.session.orchestrationStatus } : null,
hasPendingApprovals: summary.hasPendingApprovals,
hasPendingUserInput: summary.hasPendingUserInput,
hasActionableProposedPlan: summary.hasActionableProposedPlan,
};
}

function snapshotNotificationShells(environmentId: EnvironmentId): ThreadShellMap {
const environmentState = useStore.getState().environmentStateById[environmentId];
if (!environmentState) {
return new Map();
}
const map = new Map<ThreadId, NotificationThreadShellLike>();
for (const [threadId, summary] of Object.entries(environmentState.sidebarThreadSummaryById)) {
map.set(threadId as ThreadId, summaryToNotificationShell(summary));
}
return map;
}

function applyRecoveredEventBatch(
events: ReadonlyArray<OrchestrationEvent>,
environmentId: EnvironmentId,
Expand All @@ -629,6 +658,8 @@ function applyRecoveredEventBatch(
return;
}

const previousNotificationShells = snapshotNotificationShells(environmentId);

const batchEffects = deriveOrchestrationBatchEffects(events);
const uiEvents = coalesceOrchestrationUiEvents(events);
const needsProjectUiSync = events.some(
Expand Down Expand Up @@ -689,6 +720,16 @@ function applyRecoveredEventBatch(
}

reconcileThreadDetailSubscriptionEvictionForEnvironment(environmentId);

const nextNotificationShells = snapshotNotificationShells(environmentId);
const notificationTriggers = deriveNotificationTriggers(
previousNotificationShells,
nextNotificationShells,
events,
);
if (notificationTriggers.length > 0) {
notificationSoundManager.maybePlay(notificationTriggers, getClientSettings());
}
}

export function applyEnvironmentThreadDetailEvent(
Expand Down Expand Up @@ -716,10 +757,24 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En
: null;
const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null;
const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined;
const previousNotificationShells =
event.kind === "thread-upserted" ? snapshotNotificationShells(environmentId) : null;

useStore.getState().applyShellEvent(event, environmentId);
markAppliedProjectionEvent(environmentId, event.sequence);

if (event.kind === "thread-upserted" && previousNotificationShells !== null) {
const nextNotificationShells = snapshotNotificationShells(environmentId);
const notificationTriggers = deriveNotificationTriggers(
previousNotificationShells,
nextNotificationShells,
[],
);
if (notificationTriggers.length > 0) {
notificationSoundManager.maybePlay(notificationTriggers, getClientSettings());
}
}

switch (event.kind) {
case "project-upserted":
case "project-removed":
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/localApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,11 @@ describe("wsApi", () => {
sidebarProjectSortOrder: "manual" as const,
sidebarThreadSortOrder: "created_at" as const,
timestampFormat: "24-hour" as const,
notificationSoundEnabled: false,
notificationSoundOnTurnEnd: false,
notificationSoundOnApproval: false,
notificationSoundOnQuestion: false,
notificationSoundFocusRule: "unfocused-or-different-thread" as const,
};
const getClientSettings = vi.fn().mockResolvedValue({
...clientSettings,
Expand Down Expand Up @@ -607,6 +612,11 @@ describe("wsApi", () => {
sidebarProjectSortOrder: "manual" as const,
sidebarThreadSortOrder: "created_at" as const,
timestampFormat: "24-hour" as const,
notificationSoundEnabled: false,
notificationSoundOnTurnEnd: false,
notificationSoundOnApproval: false,
notificationSoundOnQuestion: false,
notificationSoundFocusRule: "unfocused-or-different-thread" as const,
};

await api.persistence.setClientSettings(clientSettings);
Expand Down
Loading
Loading