| author | |
| committer | |
| log | 0e0ddaeedf30698bec131caf040a8e8d7b2e3f37 |
| tree | 957de2fc8b0c96e0711623d2d79caa66dc409c1b |
| parent | af0657e3b36d8fc9d2652e83e6e9032272629631 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>12 files changed, 333 insertions(+), 0 deletions(-)
apps/desktop/src/settings/DesktopClientSettings.test.ts+1| ... | @@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; | ... | @@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; |
| 18 | 18 | ||
| 19 | const clientSettings: ClientSettings = { | 19 | const clientSettings: ClientSettings = { |
| 20 | ...DEFAULT_CLIENT_SETTINGS, | 20 | ...DEFAULT_CLIENT_SETTINGS, |
| 21 | notificationMode: "notifications-and-sound", | ||
| 21 | appearanceContrast: 100, | 22 | appearanceContrast: 100, |
| 22 | browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, | 23 | browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, |
| 23 | browserDefaultZoomFactor: 1.25, | 24 | browserDefaultZoomFactor: 1.25, |
apps/web/src/assets/notification-completion.mp3 created| Binary files /dev/null and b/apps/web/src/assets/notification-completion.mp3 differ | |||
apps/web/src/assets/notification-input.mp3 created| Binary files /dev/null and b/apps/web/src/assets/notification-input.mp3 differ | |||
apps/web/src/components/ThreadNotificationCoordinator.tsx created+113| ... | @@ -0,0 +1,113 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | ||
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import { useEffect, useRef } from "react"; | ||
| 6 | |||
| 7 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; | ||
| 8 | import { useEnvironments } from "../state/environments"; | ||
| 9 | import { environmentShell } from "../state/shell"; | ||
| 10 | import { | ||
| 11 | hasDesktopNotifications, | ||
| 12 | hasNotificationSound, | ||
| 13 | playNotificationSound, | ||
| 14 | unlockNotificationAudio, | ||
| 15 | } from "../threadNotifications"; | ||
| 16 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; | ||
| 17 | |||
| 18 | export function ThreadNotificationCoordinator() { | ||
| 19 | const { environments } = useEnvironments(); | ||
| 20 | const mode = useClientSettings((settings) => settings.notificationMode); | ||
| 21 | |||
| 22 | useEffect(() => { | ||
| 23 | if (!hasNotificationSound(mode)) return; | ||
| 24 | document.addEventListener("pointerdown", unlockNotificationAudio); | ||
| 25 | document.addEventListener("keydown", unlockNotificationAudio); | ||
| 26 | return () => { | ||
| 27 | document.removeEventListener("pointerdown", unlockNotificationAudio); | ||
| 28 | document.removeEventListener("keydown", unlockNotificationAudio); | ||
| 29 | }; | ||
| 30 | }, [mode]); | ||
| 31 | |||
| 32 | if (mode === "off") return null; | ||
| 33 | |||
| 34 | return environments.map((environment) => ( | ||
| 35 | <EnvironmentNotifications | ||
| 36 | key={environment.environmentId} | ||
| 37 | environmentId={environment.environmentId} | ||
| 38 | /> | ||
| 39 | )); | ||
| 40 | } | ||
| 41 | |||
| 42 | function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { | ||
| 43 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); | ||
| 44 | const mode = useClientSettings((settings) => settings.notificationMode); | ||
| 45 | const navigate = useNavigate(); | ||
| 46 | const previous = useRef(new Map<ThreadId, { input: string | null; completion: number | null }>()); | ||
| 47 | |||
| 48 | useEffect(() => { | ||
| 49 | if (shell.status !== "live" || Option.isNone(shell.snapshot)) { | ||
| 50 | previous.current.clear(); | ||
| 51 | return; | ||
| 52 | } | ||
| 53 | const next = new Map<ThreadId, { input: string | null; completion: number | null }>(); | ||
| 54 | for (const thread of shell.snapshot.value.threads) { | ||
| 55 | const status = resolveSidebarThreadStatus(thread); | ||
| 56 | const prior = previous.current.get(thread.id); | ||
| 57 | const input = | ||
| 58 | status === "input" || status === "approval" | ||
| 59 | ? `${thread.latestTurn?.turnId ?? ""}:${status}` | ||
| 60 | : null; | ||
| 61 | const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); | ||
| 62 | const completion = | ||
| 63 | status === "ready" && | ||
| 64 | thread.latestTurn?.state === "completed" && | ||
| 65 | Number.isFinite(completedAt) | ||
| 66 | ? completedAt | ||
| 67 | : (prior?.completion ?? null); | ||
| 68 | next.set(thread.id, { input, completion }); | ||
| 69 | if (!prior || mode === "off" || thread.archivedAt !== null) continue; | ||
| 70 | const kind = | ||
| 71 | input && input !== prior.input | ||
| 72 | ? "input" | ||
| 73 | : completion !== null && (prior.completion === null || completion > prior.completion) | ||
| 74 | ? "completion" | ||
| 75 | : null; | ||
| 76 | if (!kind) continue; | ||
| 77 | if (hasNotificationSound(mode)) { | ||
| 78 | void playNotificationSound(kind, () => | ||
| 79 | hasNotificationSound(getClientSettings().notificationMode), | ||
| 80 | ); | ||
| 81 | } | ||
| 82 | if ( | ||
| 83 | !hasDesktopNotifications(mode) || | ||
| 84 | typeof Notification === "undefined" || | ||
| 85 | Notification.permission !== "granted" | ||
| 86 | ) | ||
| 87 | continue; | ||
| 88 | try { | ||
| 89 | const notification = new Notification( | ||
| 90 | kind === "completion" | ||
| 91 | ? "Thread completed" | ||
| 92 | : status === "approval" | ||
| 93 | ? "Approval needed" | ||
| 94 | : "Input needed", | ||
| 95 | { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, | ||
| 96 | ); | ||
| 97 | notification.addEventListener("click", () => { | ||
| 98 | notification.close(); | ||
| 99 | window.focus(); | ||
| 100 | void navigate({ | ||
| 101 | to: "/$environmentId/$threadId", | ||
| 102 | params: { environmentId, threadId: thread.id }, | ||
| 103 | }); | ||
| 104 | }); | ||
| 105 | } catch { | ||
| 106 | // Some browsers expose Notification but reject desktop presentation. | ||
| 107 | } | ||
| 108 | } | ||
| 109 | previous.current = next; | ||
| 110 | }, [environmentId, mode, navigate, shell]); | ||
| 111 | |||
| 112 | return null; | ||
| 113 | } | ||
apps/web/src/components/settings/NotificationSettings.tsx created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | import { useState } from "react"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | hasDesktopNotifications, | ||
| 5 | hasNotificationSound, | ||
| 6 | NOTIFICATION_MODE_LABELS, | ||
| 7 | unlockNotificationAudio, | ||
| 8 | } from "../../threadNotifications"; | ||
| 9 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 10 | import { SettingsRow } from "./settingsLayout"; | ||
| 11 | import { searchableSetting } from "./settingsSearch"; | ||
| 12 | import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; | ||
| 13 | |||
| 14 | export function NotificationSettings() { | ||
| 15 | const mode = useScopedSettings((settings) => settings.notificationMode); | ||
| 16 | const updateSettings = useUpdateScopedSettings(); | ||
| 17 | const [permissionMessage, setPermissionMessage] = useState<string | null>(null); | ||
| 18 | const [requesting, setRequesting] = useState(false); | ||
| 19 | |||
| 20 | return ( | ||
| 21 | <SettingsRow | ||
| 22 | {...searchableSetting("thread-notifications")} | ||
| 23 | description={ | ||
| 24 | permissionMessage ?? | ||
| 25 | "Alert when a thread finishes or needs input. Applies to this device while T3 Code is open." | ||
| 26 | } | ||
| 27 | control={ | ||
| 28 | <Select | ||
| 29 | value={mode} | ||
| 30 | disabled={requesting} | ||
| 31 | onValueChange={async (value) => { | ||
| 32 | if ( | ||
| 33 | value !== "off" && | ||
| 34 | value !== "notifications" && | ||
| 35 | value !== "sound" && | ||
| 36 | value !== "notifications-and-sound" | ||
| 37 | ) | ||
| 38 | return; | ||
| 39 | setPermissionMessage(null); | ||
| 40 | if (hasNotificationSound(value)) unlockNotificationAudio(); | ||
| 41 | if (hasDesktopNotifications(value)) { | ||
| 42 | if (typeof Notification === "undefined" || !window.isSecureContext) { | ||
| 43 | setPermissionMessage( | ||
| 44 | "Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.", | ||
| 45 | ); | ||
| 46 | return; | ||
| 47 | } | ||
| 48 | setRequesting(true); | ||
| 49 | try { | ||
| 50 | const permission = await Notification.requestPermission(); | ||
| 51 | if (permission !== "granted") { | ||
| 52 | setPermissionMessage( | ||
| 53 | "Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.", | ||
| 54 | ); | ||
| 55 | return; | ||
| 56 | } | ||
| 57 | } catch { | ||
| 58 | setPermissionMessage( | ||
| 59 | "Notifications are unavailable in this browser. Sound only is still available.", | ||
| 60 | ); | ||
| 61 | return; | ||
| 62 | } finally { | ||
| 63 | setRequesting(false); | ||
| 64 | } | ||
| 65 | } | ||
| 66 | updateSettings({ notificationMode: value }); | ||
| 67 | }} | ||
| 68 | > | ||
| 69 | <SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Thread notifications"> | ||
| 70 | <SelectValue>{NOTIFICATION_MODE_LABELS[mode]}</SelectValue> | ||
| 71 | </SelectTrigger> | ||
| 72 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 73 | {Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => ( | ||
| 74 | <SelectItem key={value} hideIndicator value={value}> | ||
| 75 | {label} | ||
| 76 | </SelectItem> | ||
| 77 | ))} | ||
| 78 | </SelectPopup> | ||
| 79 | </Select> | ||
| 80 | } | ||
| 81 | /> | ||
| 82 | ); | ||
| 83 | } | ||
apps/web/src/components/settings/SettingsPanels.tsx+7| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { Spinner } from "~/components/ui/spinner"; | 1 | import { Spinner } from "~/components/ui/spinner"; |
| 2 | import { NotificationSettings } from "./NotificationSettings"; | ||
| 2 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; | 3 | import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; |
| 3 | import { Link, useNavigate } from "@tanstack/react-router"; | 4 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 4 | import type { CSSProperties, ReactNode } from "react"; | 5 | import type { CSSProperties, ReactNode } from "react"; |
| ... | @@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 517 | ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat | 518 | ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat |
| 518 | ? ["Time format"] | 519 | ? ["Time format"] |
| 519 | : []), | 520 | : []), |
| 521 | ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode | ||
| 522 | ? ["Thread notifications"] | ||
| 523 | : []), | ||
| 520 | ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount | 524 | ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount |
| 521 | ? ["Visible threads"] | 525 | ? ["Visible threads"] |
| 522 | : []), | 526 | : []), |
| ... | @@ -636,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -636,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 636 | settings.sidebarThreadPreviewCount, | 640 | settings.sidebarThreadPreviewCount, |
| 637 | settings.showSkillsInSlashMenu, | 641 | settings.showSkillsInSlashMenu, |
| 638 | settings.timestampFormat, | 642 | settings.timestampFormat, |
| 643 | settings.notificationMode, | ||
| 639 | settings.wordWrap, | 644 | settings.wordWrap, |
| 640 | followSystem, | 645 | followSystem, |
| 641 | theme, | 646 | theme, |
| ... | @@ -709,6 +714,7 @@ export function useSettingsRestore(onRestored?: () => void) { | ... | @@ -709,6 +714,7 @@ export function useSettingsRestore(onRestored?: () => void) { |
| 709 | appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, | 714 | appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, |
| 710 | diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, | 715 | diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, |
| 711 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, | 716 | timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, |
| 717 | notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, | ||
| 712 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, | 718 | wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, |
| 713 | diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, | 719 | diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, |
| 714 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, | 720 | diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, |
| ... | @@ -2243,6 +2249,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2243,6 +2249,7 @@ export function GeneralSettingsPanel() { |
| 2243 | </SettingsSection> | 2249 | </SettingsSection> |
| 2244 | 2250 | ||
| 2245 | <SettingsSection id="behavior" title="Behavior"> | 2251 | <SettingsSection id="behavior" title="Behavior"> |
| 2252 | <NotificationSettings /> | ||
| 2246 | <SettingsRow | 2253 | <SettingsRow |
| 2247 | {...searchableSetting("time-format")} | 2254 | {...searchableSetting("time-format")} |
| 2248 | description="System default follows your browser or OS clock preference." | 2255 | description="System default follows your browser or OS clock preference." |
apps/web/src/components/settings/settingsSearch.ts+6| ... | @@ -235,6 +235,12 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -235,6 +235,12 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 235 | requiresThreadAutoSettlement: true, | 235 | requiresThreadAutoSettlement: true, |
| 236 | scope: "project-defaults", | 236 | scope: "project-defaults", |
| 237 | }, | 237 | }, |
| 238 | { | ||
| 239 | id: "thread-notifications", | ||
| 240 | title: "Thread notifications", | ||
| 241 | to: "/settings/general", | ||
| 242 | searchTerms: ["notification sound alert completion input approval desktop"], | ||
| 243 | }, | ||
| 238 | { | 244 | { |
| 239 | id: "time-format", | 245 | id: "time-format", |
| 240 | title: "Time format", | 246 | title: "Time format", |
apps/web/src/routes/__root.tsx+2| ... | @@ -24,6 +24,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt | ... | @@ -24,6 +24,7 @@ import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPrompt |
| 24 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; | 24 | import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; |
| 25 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; | 25 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 26 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 26 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 27 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; | ||
| 27 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; | 28 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 28 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; | 29 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 29 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | 30 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| ... | @@ -199,6 +200,7 @@ function RootRouteView() { | ... | @@ -199,6 +200,7 @@ function RootRouteView() { |
| 199 | <ConnectOnboardingDialog /> | 200 | <ConnectOnboardingDialog /> |
| 200 | <SshPasswordPromptDialog /> | 201 | <SshPasswordPromptDialog /> |
| 201 | <SnapShotCoordinator /> | 202 | <SnapShotCoordinator /> |
| 203 | <ThreadNotificationCoordinator /> | ||
| 202 | <ConfirmDialogHost /> | 204 | <ConfirmDialogHost /> |
| 203 | <SlowRpcRequestToastCoordinator /> | 205 | <SlowRpcRequestToastCoordinator /> |
| 204 | <HostedStaticEnvironmentBootstrap /> | 206 | <HostedStaticEnvironmentBootstrap /> |
apps/web/src/threadNotifications.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import type { ClientSettings } from "@t3tools/contracts/settings"; | ||
| 2 | |||
| 3 | import completionUrl from "./assets/notification-completion.mp3"; | ||
| 4 | import inputUrl from "./assets/notification-input.mp3"; | ||
| 5 | |||
| 6 | type NotificationMode = ClientSettings["notificationMode"]; | ||
| 7 | export const NOTIFICATION_MODE_LABELS = { | ||
| 8 | off: "Off", | ||
| 9 | notifications: "Notifications only", | ||
| 10 | sound: "Sound only", | ||
| 11 | "notifications-and-sound": "Notifications with sound", | ||
| 12 | } satisfies Record<NotificationMode, string>; | ||
| 13 | |||
| 14 | export function hasNotificationSound(mode: NotificationMode) { | ||
| 15 | return mode === "sound" || mode === "notifications-and-sound"; | ||
| 16 | } | ||
| 17 | |||
| 18 | export function hasDesktopNotifications(mode: NotificationMode) { | ||
| 19 | return mode === "notifications" || mode === "notifications-and-sound"; | ||
| 20 | } | ||
| 21 | |||
| 22 | let audioContext: AudioContext | undefined; | ||
| 23 | const buffers = new Map<string, Promise<AudioBuffer>>(); | ||
| 24 | |||
| 25 | /** Called from a gesture so browsers allow later background playback. */ | ||
| 26 | export function unlockNotificationAudio() { | ||
| 27 | audioContext ??= new AudioContext(); | ||
| 28 | void audioContext.resume().catch(() => undefined); | ||
| 29 | } | ||
| 30 | |||
| 31 | export async function playNotificationSound( | ||
| 32 | kind: "completion" | "input", | ||
| 33 | shouldPlay: () => boolean, | ||
| 34 | ) { | ||
| 35 | if (!audioContext || audioContext.state !== "running") return; | ||
| 36 | const context = audioContext; | ||
| 37 | const url = kind === "completion" ? completionUrl : inputUrl; | ||
| 38 | try { | ||
| 39 | let buffer = buffers.get(url); | ||
| 40 | if (!buffer) { | ||
| 41 | buffer = fetch(url) | ||
| 42 | .then((response) => response.arrayBuffer()) | ||
| 43 | .then((data) => context.decodeAudioData(data)); | ||
| 44 | buffers.set(url, buffer); | ||
| 45 | } | ||
| 46 | const decoded = await buffer; | ||
| 47 | if (!shouldPlay() || context.state !== "running") return; | ||
| 48 | const source = context.createBufferSource(); | ||
| 49 | source.buffer = decoded; | ||
| 50 | source.connect(context.destination); | ||
| 51 | source.start(); | ||
| 52 | } catch { | ||
| 53 | buffers.delete(url); | ||
| 54 | } | ||
| 55 | } | ||
packages/contracts/src/settings.test.ts+26| ... | @@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => { | ... | @@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => { |
| 164 | }); | 164 | }); |
| 165 | }); | 165 | }); |
| 166 | 166 | ||
| 167 | describe("ClientSettings notifications", () => { | ||
| 168 | it("requires opt-in when existing settings omit notification preferences", () => { | ||
| 169 | expect(decodeClientSettings({}).notificationMode).toBe("off"); | ||
| 170 | expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode"); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it.each(["off", "notifications", "sound", "notifications-and-sound"])( | ||
| 174 | "round-trips the %s mode", | ||
| 175 | (notificationMode) => { | ||
| 176 | const settings = decodeClientSettings({ notificationMode }); | ||
| 177 | expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode); | ||
| 178 | expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe( | ||
| 179 | notificationMode, | ||
| 180 | ); | ||
| 181 | }, | ||
| 182 | ); | ||
| 183 | |||
| 184 | it.each(["always", true, null])( | ||
| 185 | "rejects unsupported notification mode %s", | ||
| 186 | (notificationMode) => { | ||
| 187 | expect(() => decodeClientSettings({ notificationMode })).toThrow(); | ||
| 188 | expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow(); | ||
| 189 | }, | ||
| 190 | ); | ||
| 191 | }); | ||
| 192 | |||
| 167 | describe("ClientSettings default diff file state", () => { | 193 | describe("ClientSettings default diff file state", () => { |
| 168 | it("keeps files expanded when existing settings omit the preference", () => { | 194 | it("keeps files expanded when existing settings omit the preference", () => { |
| 169 | expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false); | 195 | expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false); |
packages/contracts/src/settings.ts+12| ... | @@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = { | ... | @@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = { |
| 214 | kind: "both-shift-keys", | 214 | kind: "both-shift-keys", |
| 215 | }; | 215 | }; |
| 216 | 216 | ||
| 217 | export const NotificationMode = Schema.Literals([ | ||
| 218 | "off", | ||
| 219 | "notifications", | ||
| 220 | "sound", | ||
| 221 | "notifications-and-sound", | ||
| 222 | ]); | ||
| 223 | export type NotificationMode = typeof NotificationMode.Type; | ||
| 224 | |||
| 217 | export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); | 225 | export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); |
| 218 | export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; | 226 | export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; |
| 219 | const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; | 227 | const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; |
| ... | @@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record( | ... | @@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record( |
| 280 | export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); | 288 | export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); |
| 281 | 289 | ||
| 282 | export const ClientSettingsSchema = Schema.Struct({ | 290 | export const ClientSettingsSchema = Schema.Struct({ |
| 291 | notificationMode: NotificationMode.pipe( | ||
| 292 | Schema.withDecodingDefault(Effect.succeed("off" as const)), | ||
| 293 | ), | ||
| 283 | diffColorScheme: DiffColorScheme.pipe( | 294 | diffColorScheme: DiffColorScheme.pipe( |
| 284 | Schema.withDecodingDefault(Effect.succeed("red-green" as const)), | 295 | Schema.withDecodingDefault(Effect.succeed("red-green" as const)), |
| 285 | ), | 296 | ), |
| ... | @@ -1416,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1416,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1416 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; | 1427 | export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; |
| 1417 | 1428 | ||
| 1418 | export const ClientSettingsPatch = Schema.Struct({ | 1429 | export const ClientSettingsPatch = Schema.Struct({ |
| 1430 | notificationMode: Schema.optionalKey(NotificationMode), | ||
| 1419 | diffColorScheme: Schema.optionalKey(DiffColorScheme), | 1431 | diffColorScheme: Schema.optionalKey(DiffColorScheme), |
| 1420 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), | 1432 | loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), |
| 1421 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), | 1433 | loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), |
third-party-licenses.config.json+28| ... | @@ -1,5 +1,33 @@ | ... | @@ -1,5 +1,33 @@ |
| 1 | { | 1 | { |
| 2 | "customNotices": [ | 2 | "customNotices": [ |
| 3 | { | ||
| 4 | "bundles": ["assets", "desktop", "web"], | ||
| 5 | "license": "CC0-1.0", | ||
| 6 | "name": "Notification Sound 1 by deadrobotmusic", | ||
| 7 | "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750607/", | ||
| 8 | "generatedNotices": [ | ||
| 9 | { | ||
| 10 | "licenseId": "CC0-1.0", | ||
| 11 | "preamble": [ | ||
| 12 | "The bundled notification-completion.mp3 is derived from Notification Sound 1 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750607/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." | ||
| 13 | ] | ||
| 14 | } | ||
| 15 | ] | ||
| 16 | }, | ||
| 17 | { | ||
| 18 | "bundles": ["assets", "desktop", "web"], | ||
| 19 | "license": "CC0-1.0", | ||
| 20 | "name": "Notification Sound 3 by deadrobotmusic", | ||
| 21 | "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750609/", | ||
| 22 | "generatedNotices": [ | ||
| 23 | { | ||
| 24 | "licenseId": "CC0-1.0", | ||
| 25 | "preamble": [ | ||
| 26 | "The bundled notification-input.mp3 is derived from Notification Sound 3 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750609/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." | ||
| 27 | ] | ||
| 28 | } | ||
| 29 | ] | ||
| 30 | }, | ||
| 3 | { | 31 | { |
| 4 | "bundles": ["assets", "desktop", "web"], | 32 | "bundles": ["assets", "desktop", "web"], |
| 5 | "license": "CC0-1.0", | 33 | "license": "CC0-1.0", |