@agent-native/core 0.168.9 → 0.168.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/changelog/2026-08-19-desktop-settings-are-tighter-with-a-new-advanced-tab.md +6 -0
  3. package/corpus/templates/clips/desktop/README.md +79 -1
  4. package/corpus/templates/clips/desktop/components.json +20 -0
  5. package/corpus/templates/clips/desktop/package.json +10 -1
  6. package/corpus/templates/clips/desktop/src/app.tsx +1812 -1994
  7. package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +23 -65
  8. package/corpus/templates/clips/desktop/src/components/settings/settings-ui.tsx +399 -0
  9. package/corpus/templates/clips/desktop/src/components/ui/alert-dialog.tsx +166 -0
  10. package/corpus/templates/clips/desktop/src/components/ui/button.tsx +57 -0
  11. package/corpus/templates/clips/desktop/src/components/ui/empty.tsx +104 -0
  12. package/corpus/templates/clips/desktop/src/components/ui/input.tsx +22 -0
  13. package/corpus/templates/clips/desktop/src/components/ui/popover.tsx +38 -0
  14. package/corpus/templates/clips/desktop/src/components/ui/select.tsx +164 -0
  15. package/corpus/templates/clips/desktop/src/components/ui/skeleton.tsx +15 -0
  16. package/corpus/templates/clips/desktop/src/components/ui/spinner.tsx +17 -0
  17. package/corpus/templates/clips/desktop/src/components/ui/switch.tsx +27 -0
  18. package/corpus/templates/clips/desktop/src/components/ui/textarea.tsx +22 -0
  19. package/corpus/templates/clips/desktop/src/dev/browser-preview.ts +65 -0
  20. package/corpus/templates/clips/desktop/src/hooks/useSystemAccessRows.ts +128 -0
  21. package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +0 -2
  22. package/corpus/templates/clips/desktop/src/lib/permission-status.ts +84 -0
  23. package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -5
  24. package/corpus/templates/clips/desktop/src/lib/settings-navigation.ts +18 -5
  25. package/corpus/templates/clips/desktop/src/lib/utils.ts +6 -0
  26. package/corpus/templates/clips/desktop/src/main.tsx +31 -4
  27. package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +2 -2
  28. package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +2 -2
  29. package/corpus/templates/clips/desktop/src/styles.css +75 -1427
  30. package/corpus/templates/clips/desktop/src/tailwind.css +282 -0
  31. package/corpus/templates/clips/desktop/tsconfig.json +4 -1
  32. package/corpus/templates/clips/desktop/vite.config.ts +7 -1
  33. package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +1 -0
  34. package/corpus/templates/content/app/global.css +5 -0
  35. package/corpus/templates/content/docs/solutions/2026-08-20-code-block-slash-production-regression-shape.md +492 -0
  36. package/corpus/templates/content/docs/solutions/2026-08-20-code-block-surface-background-shape.md +273 -0
  37. package/dist/deploy/build.js +1 -1
  38. package/dist/observability/routes.d.ts +3 -3
  39. package/dist/progress/routes.d.ts +1 -1
  40. package/dist/secrets/routes.d.ts +6 -6
  41. package/dist/server/action-routes.js +3 -2
  42. package/dist/server/realtime-token.d.ts +1 -1
  43. package/dist/server/request-origin.d.ts +6 -0
  44. package/dist/server/request-origin.js +12 -0
  45. package/dist/server/transcribe-voice.d.ts +1 -1
  46. package/dist/shared/mcp-embed-headers.d.ts +1 -1
  47. package/dist/shared/mcp-embed-headers.js +10 -3
  48. package/package.json +1 -1
@@ -1,25 +1,26 @@
1
- import * as PopoverPrimitive from "@radix-ui/react-popover";
2
1
  import {
2
+ IconAdjustmentsHorizontal,
3
3
  IconAlertTriangle,
4
4
  IconArrowLeft,
5
+ IconCalendar,
5
6
  IconCalendarEvent,
6
- IconCheck,
7
- IconChevronDown,
8
7
  IconCircleCheck,
9
- IconCopy,
10
8
  IconDownload,
11
9
  IconExternalLink,
12
10
  IconFolderOpen,
13
11
  IconPencil,
14
12
  IconInfoCircle,
15
13
  IconHistory,
14
+ IconMicrophone,
16
15
  IconMicrophone2,
17
16
  IconPlayerPlay,
18
17
  IconRefresh,
19
18
  IconSearch,
20
19
  IconShieldLock,
20
+ IconTool,
21
21
  IconTrash,
22
22
  IconUpload,
23
+ IconVideo,
23
24
  IconX,
24
25
  } from "@tabler/icons-react";
25
26
  import { invoke } from "@tauri-apps/api/core";
@@ -27,7 +28,6 @@ import { emit, listen } from "@tauri-apps/api/event";
27
28
  import { getCurrentWindow } from "@tauri-apps/api/window";
28
29
  import { open as openExternal } from "@tauri-apps/plugin-shell";
29
30
  import {
30
- type ReactElement,
31
31
  type ReactNode,
32
32
  type RefObject,
33
33
  useCallback,
@@ -37,6 +37,43 @@ import {
37
37
  useState,
38
38
  } from "react";
39
39
 
40
+ import {
41
+ SettingsActionButton,
42
+ SettingsChoicePopover,
43
+ SettingsGroup,
44
+ SettingsKeycap,
45
+ SettingsPopover,
46
+ SettingsRow,
47
+ SettingsSelect,
48
+ SettingsValueTrigger,
49
+ } from "@/components/settings/settings-ui";
50
+ // Aliased `Ui*` for symmetry with `UiSwitch`: the tray historically had a
51
+ // plain-CSS AlertDialog adapter under the bare names.
52
+ import {
53
+ AlertDialog as UiAlertDialog,
54
+ AlertDialogAction as UiAlertDialogAction,
55
+ AlertDialogCancel as UiAlertDialogCancel,
56
+ AlertDialogContent as UiAlertDialogContent,
57
+ AlertDialogDescription as UiAlertDialogDescription,
58
+ AlertDialogFooter as UiAlertDialogFooter,
59
+ AlertDialogHeader as UiAlertDialogHeader,
60
+ AlertDialogTitle as UiAlertDialogTitle,
61
+ } from "@/components/ui/alert-dialog";
62
+ import { Button } from "@/components/ui/button";
63
+ import {
64
+ Empty,
65
+ EmptyContent,
66
+ EmptyDescription,
67
+ EmptyHeader,
68
+ EmptyMedia,
69
+ EmptyTitle,
70
+ } from "@/components/ui/empty";
71
+ import { Input } from "@/components/ui/input";
72
+ import { Skeleton } from "@/components/ui/skeleton";
73
+ import { Spinner } from "@/components/ui/spinner";
74
+ import { Switch as UiSwitch } from "@/components/ui/switch";
75
+ import { Textarea } from "@/components/ui/textarea";
76
+
40
77
  import { FeedbackButton } from "./components/FeedbackButton";
41
78
  import {
42
79
  CamIcon,
@@ -57,10 +94,8 @@ import { UpdateBanner } from "./components/UpdateBanner";
57
94
  import { useMediaDevices } from "./hooks/useMediaDevices";
58
95
  import { useMeetingTranscription } from "./hooks/useMeetingTranscription";
59
96
  import { stopAllMicMeters } from "./hooks/useMicMeter";
60
- import {
61
- useWhisperSettings,
62
- type WhisperModelOption,
63
- } from "./hooks/useWhisperSettings";
97
+ import { useSystemAccessRows } from "./hooks/useSystemAccessRows";
98
+ import { useWhisperSettings } from "./hooks/useWhisperSettings";
64
99
  import { startBubbleFramePump } from "./lib/bubble-pump";
65
100
  import { shouldKeepBubbleSession } from "./lib/bubble-session";
66
101
  import {
@@ -73,6 +108,7 @@ import {
73
108
  } from "./lib/media-capture-constraints";
74
109
  import { sendNativeNotification } from "./lib/native-notification";
75
110
  import { openMeetingJoinUrl } from "./lib/open-meeting-join-url";
111
+ import type { MacosPrivacyPane } from "./lib/permission-status";
76
112
  import {
77
113
  DESKTOP_CAPTURE_PERMISSION_MESSAGE,
78
114
  isHardCapturePermissionError,
@@ -123,9 +159,9 @@ import {
123
159
  isUpdatePendingRestart,
124
160
  retryUpdateCheck,
125
161
  useUpdateStatus,
126
- type UpdateStatus,
127
162
  } from "./lib/updater";
128
163
  import { normalizeServerUrl } from "./lib/url";
164
+ import { cn } from "./lib/utils";
129
165
  import {
130
166
  installDesktopVoiceDictation,
131
167
  type VoiceMode,
@@ -133,9 +169,11 @@ import {
133
169
  type VoiceShortcutPreference,
134
170
  } from "./lib/voice-dictation";
135
171
  import { whisperModelOptionLabel } from "./lib/whisper-model-picker";
172
+ import { PillLogo } from "./overlays/pill-logo";
136
173
  import {
137
174
  useFeatureConfig,
138
175
  type FeatureConfig,
176
+ type RewindCaptureMode,
139
177
  type LocalRecordingMode,
140
178
  type ScreenMemoryStatus,
141
179
  } from "./shared/config";
@@ -163,13 +201,14 @@ type PendingDesktopUpload = PendingNativeUpload | PendingBrowserRecordingUpload;
163
201
  type PopoverView =
164
202
  | "recorder"
165
203
  | "memory"
166
- | "rewind-settings"
167
204
  | "settings"
168
205
  | "meetings"
169
206
  | "dictation";
170
207
 
171
208
  type SettingsTabId = DesktopSettingsTab;
172
209
 
210
+ declare const __CLIPS_DESKTOP_VERSION__: string;
211
+
173
212
  interface PopoverMeeting {
174
213
  id: string;
175
214
  title: string;
@@ -365,8 +404,11 @@ const CAM_ON_KEY = "clips:camera-on";
365
404
  const MIC_ON_KEY = "clips:mic-on";
366
405
  const SYSTEM_AUDIO_KEY = "clips:system-audio";
367
406
  const READINESS_REVIEWED_KEY = "clips:readiness-reviewed";
407
+ // The tray's rolling buffer, which the docs publish under its internal name.
408
+ // Not `#rewind-quick-save`: that section describes pulling a pre-roll from the
409
+ // third-party rewind.ai app, which this app does not integrate with at all.
368
410
  const REWIND_DOCS_URL =
369
- "https://www.agent-native.com/docs/template-clips-capture-everywhere#rewind-quick-save";
411
+ "https://www.agent-native.com/docs/template-clips-capture-everywhere#screen-memory";
370
412
 
371
413
  // Sensible defaults so the user never has to type a URL on first launch.
372
414
  // Dev builds point at the local dev server; production builds point at the
@@ -392,6 +434,10 @@ type FetchInput = Parameters<typeof fetch>[0];
392
434
  type FetchInit = Parameters<typeof fetch>[1];
393
435
 
394
436
  let authFetchInstalled = false;
437
+ // Seeded at module load, not from an effect. A child component's effect runs
438
+ // before its parent's, so anything fetching the server during the first commit
439
+ // (the sign-in screen's availability probe, for one) would otherwise find no
440
+ // server origin registered and get the un-adjusted request.
395
441
  let currentServerOrigin = "";
396
442
  let currentAuthToken = "";
397
443
 
@@ -531,9 +577,40 @@ function urlForFetchInput(input: FetchInput): string | null {
531
577
  return null;
532
578
  }
533
579
 
534
- function installAuthFetchInterceptor(): void {
580
+ /** True only for `tauri dev` / `pnpm vite:dev`, where the webview is served
581
+ * from Vite at http://localhost:1420. Protocol alone is NOT the test:
582
+ * packaged Windows builds load from http(s)://tauri.localhost — one of the
583
+ * exact origins the framework trusts with cookie credentials
584
+ * (TRUSTED_NATIVE_APP_ORIGIN_RE) — and classifying those as dev would strip
585
+ * `credentials: "include"` from every production request on Windows. */
586
+ function isDevOriginWebview(): boolean {
587
+ return (
588
+ import.meta.env.DEV &&
589
+ typeof window !== "undefined" &&
590
+ window.location.protocol.startsWith("http") &&
591
+ window.location.hostname === "localhost"
592
+ );
593
+ }
594
+
595
+ function seedDesktopAuthContextFromStorage(): void {
596
+ if (currentServerOrigin || typeof window === "undefined") return;
597
+ const storedServerUrl = loadString(STORAGE_KEY, DEFAULT_URL).replace(
598
+ /\/+$/,
599
+ "",
600
+ );
601
+ setDesktopAuthContext(storedServerUrl, loadDesktopAuthToken(storedServerUrl));
602
+ }
603
+
604
+ /**
605
+ * Installed from `main.tsx` before the first render, not from an effect: a
606
+ * component effect runs too late to cover fetches issued during the same
607
+ * commit, and an un-intercepted desktop request goes out without its
608
+ * X-Request-Source marker or the dev-origin credential policy below.
609
+ */
610
+ export function installAuthFetchInterceptor(): void {
535
611
  if (authFetchInstalled || typeof window === "undefined") return;
536
612
  authFetchInstalled = true;
613
+ seedDesktopAuthContextFromStorage();
537
614
  const nativeFetch = window.fetch.bind(window);
538
615
 
539
616
  window.fetch = (input: FetchInput, init?: FetchInit) => {
@@ -556,19 +633,24 @@ function installAuthFetchInterceptor(): void {
556
633
  if (currentAuthToken && !headers.has("Authorization")) {
557
634
  headers.set("Authorization", `Bearer ${currentAuthToken}`);
558
635
  }
559
- return nativeFetch(input, { ...init, headers });
636
+ // Cookies are only an option from the packaged app. `tauri dev` serves the
637
+ // webview from http://localhost:1420, and the framework deliberately keeps
638
+ // localhost off credentialed CORS ("only the configured browser allowlist
639
+ // and the framework's exact native app origins may receive cookies" —
640
+ // shouldAllowMcpEmbedCredentials). Since we always add X-Request-Source,
641
+ // the preflight is credentialed, so asking for cookies there fails the
642
+ // whole request rather than degrading. The bearer token above is the
643
+ // supported credential for this origin, and the server returns it in the
644
+ // login body precisely because localhost:1420 is on its token allowlist.
645
+ const credentials: RequestCredentials | undefined = isDevOriginWebview()
646
+ ? "omit"
647
+ : init?.credentials;
648
+ return nativeFetch(input, { ...init, headers, credentials });
560
649
  };
561
650
  }
562
651
 
563
652
  type ByokVoiceProvider = Extract<VoiceProvider, "gemini" | "groq">;
564
653
  type VoiceProviderMode = "native" | "whisper" | "builder" | "byok";
565
- type MacosPrivacyPane =
566
- | "camera"
567
- | "microphone"
568
- | "screen"
569
- | "speech"
570
- | "accessibility"
571
- | "input-monitoring";
572
654
 
573
655
  const MACOS_PRIVACY_URLS: Record<MacosPrivacyPane, string> = {
574
656
  camera:
@@ -736,6 +818,77 @@ function humanReadableShortcutLabel(shortcut: string): string {
736
818
  .join(" ");
737
819
  }
738
820
 
821
+ const MAC_MODIFIER_GLYPHS: Record<string, string> = {
822
+ Cmd: "⌘",
823
+ Ctrl: "⌃",
824
+ Alt: "⌥",
825
+ Shift: "⇧",
826
+ };
827
+
828
+ /** Keycap form for a settings row: `⌘⇧Space` on macOS, words elsewhere. */
829
+ function compactShortcutLabel(shortcut: string): string {
830
+ const tokens = shortcut
831
+ .split("+")
832
+ .map((token) => token.trim())
833
+ .filter(Boolean);
834
+ if (!isMacPlatform()) return tokens.join(" ");
835
+ return tokens.map((token) => MAC_MODIFIER_GLYPHS[token] ?? token).join("");
836
+ }
837
+
838
+ function compactVoiceShortcutLabel(
839
+ shortcut: VoiceShortcutPreference,
840
+ customShortcut: string,
841
+ ): string {
842
+ switch (shortcut) {
843
+ case "fn":
844
+ return "Fn";
845
+ case "cmd-shift-space":
846
+ return compactShortcutLabel("Cmd+Shift+Space");
847
+ case "ctrl-shift-space":
848
+ return compactShortcutLabel("Ctrl+Shift+Space");
849
+ case "custom":
850
+ return customShortcut ? compactShortcutLabel(customShortcut) : "Custom";
851
+ case "both":
852
+ return `Fn / ${compactShortcutLabel("Cmd+Shift+Space")}`;
853
+ }
854
+ }
855
+
856
+ /**
857
+ * The shadcn Switch, engaged in green rather than `bg-primary`. A menu-bar app
858
+ * sits next to macOS's own switches all day, and green is what that audience
859
+ * reads as "on" — the one accent on this surface, hence the `--success` token
860
+ * rather than a literal.
861
+ */
862
+ function SettingsSwitch({
863
+ checked,
864
+ onCheckedChange,
865
+ label,
866
+ disabled = false,
867
+ }: {
868
+ checked: boolean;
869
+ onCheckedChange: (value: boolean) => void;
870
+ label: string;
871
+ disabled?: boolean;
872
+ }) {
873
+ return (
874
+ <UiSwitch
875
+ checked={checked}
876
+ onCheckedChange={onCheckedChange}
877
+ disabled={disabled}
878
+ aria-label={label}
879
+ className="h-5 w-9 data-[state=checked]:bg-success [&>span]:size-4 [&>span]:data-[state=checked]:translate-x-4"
880
+ />
881
+ );
882
+ }
883
+
884
+ const VOICE_SHORTCUT_CHOICES: Array<{ value: string; label: string }> = [
885
+ { value: "cmd-shift-space", label: "Cmd Shift Space" },
886
+ { value: "ctrl-shift-space", label: "Ctrl Shift Space" },
887
+ { value: "custom", label: "Custom shortcut" },
888
+ { value: "fn", label: "Fn (globe)" },
889
+ { value: "both", label: "Any of them" },
890
+ ];
891
+
739
892
  function voiceShortcutLabel(
740
893
  shortcut: VoiceShortcutPreference,
741
894
  customShortcut: string,
@@ -803,6 +956,19 @@ function measurePopoverHeight(el: HTMLElement): number {
803
956
  // window size. Their content can scroll inside the layer without changing
804
957
  // the tray popover underneath it.
805
958
  if (child.closest('[data-popover-overlay="true"]')) continue;
959
+ // A marked scroll region owns its overflow: content scrolls inside it
960
+ // without growing the native window. Without this, the fixed-height
961
+ // Settings pane's scrollHeight would resize the tray to the full length
962
+ // of whichever tab is open.
963
+ const scrollRegion = child.closest<HTMLElement>(
964
+ "[data-popover-scroll-region]",
965
+ );
966
+ if (scrollRegion) {
967
+ if (scrollRegion !== child) continue;
968
+ const regionRect = child.getBoundingClientRect();
969
+ lowestBottom = Math.max(lowestBottom, regionRect.bottom);
970
+ continue;
971
+ }
806
972
  const childStyle = window.getComputedStyle(child);
807
973
  if (childStyle.display === "none") continue;
808
974
  const childRect = child.getBoundingClientRect();
@@ -899,7 +1065,14 @@ function usePopoverAutoSize(
899
1065
  }, [disabled, ref, width]);
900
1066
  }
901
1067
 
902
- export function App() {
1068
+ export function App({
1069
+ initialView,
1070
+ initialSettingsTab: initialSettingsTabProp,
1071
+ }: {
1072
+ /** Route-supplied starting surface, so `#settings` opens there directly. */
1073
+ initialView?: PopoverView;
1074
+ initialSettingsTab?: DesktopSettingsTab;
1075
+ } = {}) {
903
1076
  const featureConfig = useFeatureConfig();
904
1077
  const [serverUrl, setServerUrl] = useState<string>(() =>
905
1078
  loadString(STORAGE_KEY, DEFAULT_URL).replace(/\/+$/, ""),
@@ -977,18 +1150,21 @@ export function App() {
977
1150
  useState<LocalRecordingNotice | null>(null);
978
1151
  const [shareLinkNotice, setShareLinkNotice] =
979
1152
  useState<ShareLinkNotice | null>(null);
980
- const [popoverView, setPopoverView] = useState<PopoverView>("recorder");
981
- const [initialSettingsTab, setInitialSettingsTab] =
982
- useState<SettingsTabId>("general");
1153
+ const [popoverView, setPopoverView] = useState<PopoverView>(
1154
+ initialView ?? "recorder",
1155
+ );
1156
+ const [initialSettingsTab, setInitialSettingsTab] = useState<SettingsTabId>(
1157
+ initialSettingsTabProp ?? "general",
1158
+ );
1159
+ // A staged update is announced by a dot on Settings, not by a banner that
1160
+ // pushes the recording controls down.
1161
+ const updateReadyToInstall = useUpdateStatus().state === "downloaded";
983
1162
 
984
1163
  function openSettings(tab: SettingsTabId = "general") {
985
1164
  setInitialSettingsTab(tab);
986
1165
  setPopoverView("settings");
987
1166
  }
988
1167
 
989
- const [rewindSettingsReturnView] = useState<"recorder" | "settings">(
990
- "recorder",
991
- );
992
1168
  const [rewindAgentPromptCopied, setRewindAgentPromptCopied] = useState(false);
993
1169
  const [agentHandoff, setAgentHandoff] =
994
1170
  useState<RewindAgentHandoffRequest | null>(null);
@@ -999,7 +1175,6 @@ export function App() {
999
1175
  const [agentHandoffPreviewError, setAgentHandoffPreviewError] = useState<
1000
1176
  string | null
1001
1177
  >(null);
1002
- const [promptRewindEnable, setPromptRewindEnable] = useState(false);
1003
1178
  const [meetings, setMeetings] = useState<PopoverMeeting[]>([]);
1004
1179
  const [meetingsLoading, setMeetingsLoading] = useState(false);
1005
1180
  const [meetingsError, setMeetingsError] = useState<string | null>(null);
@@ -1030,6 +1205,12 @@ export function App() {
1030
1205
  const [authStatus, setAuthStatus] = useState<"unknown" | "authed" | "anon">(
1031
1206
  "unknown",
1032
1207
  );
1208
+ // "Could not reach the server" is not the same state as "signed out", and the
1209
+ // fix is different: one needs a correct server URL, the other needs sign-in.
1210
+ const [serverReachable, setServerReachable] = useState(true);
1211
+ const serverHostForSignIn = serverUrl
1212
+ .replace(/^https?:\/\//, "")
1213
+ .replace(/\/+$/, "");
1033
1214
  const [videoStorageStatus, setVideoStorageStatus] =
1034
1215
  useState<VideoStorageStatus>("checking");
1035
1216
  const [signedInAs, setSignedInAs] = useState<string | null>(null);
@@ -1222,6 +1403,8 @@ export function App() {
1222
1403
  `${serverUrl.replace(/\/+$/, "")}/_agent-native/auth/session`,
1223
1404
  { credentials: "include", cache: "no-store" },
1224
1405
  );
1406
+ // Any HTTP answer, including 401, means the server is there.
1407
+ setServerReachable(true);
1225
1408
  if (!res.ok) {
1226
1409
  if (res.status === 401 || res.status === 403) {
1227
1410
  clearDesktopAuthToken(serverUrl);
@@ -1246,6 +1429,9 @@ export function App() {
1246
1429
  clearDesktopAuthToken(serverUrl);
1247
1430
  return false;
1248
1431
  } catch {
1432
+ // Network-level failure: nothing answered, so we know nothing about the
1433
+ // session. Record that separately so the UI can offer the right fix.
1434
+ setServerReachable(false);
1249
1435
  setAuthStatus("anon");
1250
1436
  setSignedInAs(null);
1251
1437
  return false;
@@ -2037,12 +2223,12 @@ export function App() {
2037
2223
  const POLL_ABORT_MS = Math.max(10_000, 1500 * 4);
2038
2224
  const timeoutMessage =
2039
2225
  kind === "magic-link"
2040
- ? "The sign-in link timed out. Please request a new one."
2041
- : "Google sign-in timed out. Please try again.";
2226
+ ? "That sign-in link expired. Request a new one."
2227
+ : "Google sign-in timed out. Try again.";
2042
2228
  const exchangeErrorMessage =
2043
2229
  kind === "magic-link"
2044
- ? "We couldn't complete sign-in with that link. Please request a new one."
2045
- : "Google sign-in failed. Please try again.";
2230
+ ? "That link didn't sign you in. Request a new one."
2231
+ : "Google sign-in didn't go through. Try again.";
2046
2232
 
2047
2233
  const tick = async () => {
2048
2234
  if (document.hidden || tickInFlight) return;
@@ -2099,8 +2285,8 @@ export function App() {
2099
2285
  if (!ok) {
2100
2286
  setSignInError(
2101
2287
  kind === "magic-link"
2102
- ? "Sign-in completed, but Clips could not save the session. Please try again."
2103
- : "Google sign-in completed, but Clips could not save the session. Please try again.",
2288
+ ? "Signed in, but Clips couldn't keep the session. Try again."
2289
+ : "Signed in with Google, but Clips couldn't keep the session. Try again.",
2104
2290
  );
2105
2291
  }
2106
2292
  } else if (Date.now() - start > TIMEOUT_MS) {
@@ -2203,7 +2389,7 @@ export function App() {
2203
2389
  setSignInError(
2204
2390
  err instanceof Error
2205
2391
  ? err.message
2206
- : "Could not open Google sign-in. Please try again.",
2392
+ : "Couldn't open Google sign-in. Try again.",
2207
2393
  );
2208
2394
  }
2209
2395
  }
@@ -2230,13 +2416,12 @@ export function App() {
2230
2416
  } | null;
2231
2417
  if (!res.ok) {
2232
2418
  throw new Error(
2233
- json?.error || `Could not send sign-in link (${res.status})`,
2419
+ json?.error ||
2420
+ "Couldn't send the link. Check the email address and try again.",
2234
2421
  );
2235
2422
  }
2236
2423
  if (!json?.flowId || !json.verifier) {
2237
- throw new Error(
2238
- "The sign-in flow was not initialized. Please request a new link.",
2239
- );
2424
+ throw new Error("That link is no longer active. Request a new one.");
2240
2425
  }
2241
2426
  setMagicLinkEmail(email.trim());
2242
2427
  setSignInPending("magic-link");
@@ -2280,6 +2465,20 @@ export function App() {
2280
2465
  if (e.key === "Escape") {
2281
2466
  // Don't close mid-recording — user would lose the recorder handle.
2282
2467
  if (isRecording) return;
2468
+ // Escape belongs to the topmost layer. Radix handles it on `document`,
2469
+ // which then bubbles to this window listener, so a single press would
2470
+ // otherwise both dismiss the layer AND hide the whole tray window —
2471
+ // backing out of a select or the Rewind consent dialog would take the
2472
+ // window the user was working in with it. Matching only `open` layers
2473
+ // keeps a closing one (select and popover still animate out) from
2474
+ // swallowing the next press.
2475
+ if (
2476
+ document.querySelector(
2477
+ '[data-radix-popper-content-wrapper] [data-state="open"], [role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]',
2478
+ )
2479
+ ) {
2480
+ return;
2481
+ }
2283
2482
  // Reset nested views before hide so the next tray open lands on the
2284
2483
  // main recorder UI instead of resuming scrolled settings/meetings.
2285
2484
  setPopoverView("recorder");
@@ -2699,12 +2898,11 @@ export function App() {
2699
2898
  const appRef = useRef<HTMLDivElement | null>(null);
2700
2899
  usePopoverAutoSize(appRef, {
2701
2900
  disabled:
2702
- popoverView === "rewind-settings" ||
2703
2901
  (popoverView !== "settings" && !popoverVisible) ||
2704
2902
  isRecording ||
2705
2903
  recordingFlowActive,
2706
2904
  width:
2707
- popoverView === "settings" ? 920 : popoverView === "memory" ? 440 : 320,
2905
+ popoverView === "settings" ? 720 : popoverView === "memory" ? 440 : 320,
2708
2906
  });
2709
2907
 
2710
2908
  const loadPendingUploads = useCallback(async () => {
@@ -3684,7 +3882,7 @@ export function App() {
3684
3882
  ? "Making a private Clip"
3685
3883
  : agentHandoff.status === "ready"
3686
3884
  ? "Clip sent to your agent"
3687
- : "Clip handoff needs attention"}
3885
+ : "Couldn't send this Clip"}
3688
3886
  </h2>
3689
3887
  </div>
3690
3888
  <div className="rewind-agent-guide">
@@ -3703,11 +3901,11 @@ export function App() {
3703
3901
  <div className="rewind-local-promise">
3704
3902
  <IconShieldLock size={17} stroke={1.8} />
3705
3903
  <p>
3706
- <strong>Only this interval becomes a private Clip.</strong> The
3707
- rolling Rewind archive and its local paths stay on this Mac.
3904
+ <strong>Only this range becomes a private Clip.</strong> The
3905
+ rolling Rewind archive stays on this device.
3708
3906
  {agentHandoff.agentClipRetention === "forever"
3709
3907
  ? " This Clip will be kept in your Library."
3710
- : ` It uses your ${agentHandoff.agentClipRetention.replace("-", " ")} agent-Clip retention setting.`}
3908
+ : ` It follows your ${agentHandoff.agentClipRetention.replace("-", " ")} retention setting.`}
3711
3909
  </p>
3712
3910
  </div>
3713
3911
  {agentHandoff.status === "pending" ? (
@@ -3724,13 +3922,13 @@ export function App() {
3724
3922
  />
3725
3923
  </div>
3726
3924
  <div className="setup-mini-field">
3727
- <span>Mac audio</span>
3925
+ <span>System audio</span>
3728
3926
  <Switch
3729
3927
  on={agentHandoff.includeSystemAudio}
3730
3928
  onChange={(includeSystemAudio) =>
3731
3929
  setAgentHandoff({ ...agentHandoff, includeSystemAudio })
3732
3930
  }
3733
- label="Include Mac audio"
3931
+ label="Include system audio"
3734
3932
  />
3735
3933
  </div>
3736
3934
  </div>
@@ -3754,7 +3952,7 @@ export function App() {
3754
3952
  className="primary rewind-consent-primary"
3755
3953
  onClick={() => void processAgentHandoff(agentHandoff)}
3756
3954
  >
3757
- Send selected range to agent
3955
+ Send to agent
3758
3956
  </button>
3759
3957
  <button
3760
3958
  type="button"
@@ -3769,15 +3967,14 @@ export function App() {
3769
3967
  </>
3770
3968
  ) : agentHandoff.status === "processing" ? (
3771
3969
  <p className="setup-hint" role="status">
3772
- Materializing the bounded range, uploading it privately, and
3773
- preparing transcript and frame access for your agent…
3970
+ Making a private Clip of this range and preparing transcript and
3971
+ frame access for your agent…
3774
3972
  </p>
3775
3973
  ) : agentHandoff.status === "ready" ? (
3776
3974
  <>
3777
3975
  <p className="setup-hint" role="status">
3778
- The agent received a temporary access link. The private Clip
3779
- itself remains in your Library according to your retention
3780
- setting.
3976
+ Your agent received a temporary link. The private Clip stays in
3977
+ your Library under your retention setting.
3781
3978
  </p>
3782
3979
  <button
3783
3980
  type="button"
@@ -3802,7 +3999,7 @@ export function App() {
3802
3999
  ) : (
3803
4000
  <>
3804
4001
  <p className="setup-error" role="alert">
3805
- {agentHandoff.error || "The bounded Clip could not be sent."}
4002
+ {agentHandoff.error || "This Clip couldn't be sent. Try again."}
3806
4003
  </p>
3807
4004
  <button
3808
4005
  type="button"
@@ -3818,15 +4015,14 @@ export function App() {
3818
4015
  );
3819
4016
  }
3820
4017
 
3821
- if (popoverView === "memory" || popoverView === "rewind-settings") {
4018
+ if (popoverView === "memory") {
3822
4019
  return (
3823
4020
  <div className="app app-settings" ref={appRef}>
3824
4021
  {pendingUploadBanner}
3825
4022
  {isRecording ? <ActiveRecordingBanner /> : null}
3826
4023
  <Setup
3827
- surface={popoverView === "memory" ? "memory" : "rewind"}
4024
+ surface="memory"
3828
4025
  recordingActive={isRecording || recordingFlowActive}
3829
- promptRewindEnable={promptRewindEnable}
3830
4026
  initial={serverUrl}
3831
4027
  serverUrl={serverUrl}
3832
4028
  signedInAs={signedInAs}
@@ -3854,14 +4050,7 @@ export function App() {
3854
4050
  rewindAgentPromptCopied={rewindAgentPromptCopied}
3855
4051
  onCopyRewindAgentPrompt={copyRewindAgentPrompt}
3856
4052
  onOpenRewindDocs={openRewindDocs}
3857
- onCancel={() => {
3858
- setPromptRewindEnable(false);
3859
- setPopoverView(
3860
- popoverView === "memory"
3861
- ? "rewind-settings"
3862
- : rewindSettingsReturnView,
3863
- );
3864
- }}
4053
+ onCancel={() => openSettings("rewind")}
3865
4054
  />
3866
4055
  </div>
3867
4056
  );
@@ -3902,6 +4091,7 @@ export function App() {
3902
4091
  rewindAgentPromptCopied={rewindAgentPromptCopied}
3903
4092
  onCopyRewindAgentPrompt={copyRewindAgentPrompt}
3904
4093
  onOpenRewindDocs={openRewindDocs}
4094
+ onOpenMemory={() => setPopoverView("memory")}
3905
4095
  onCancel={() => setPopoverView("recorder")}
3906
4096
  />
3907
4097
  </div>
@@ -3955,6 +4145,35 @@ export function App() {
3955
4145
  );
3956
4146
  }
3957
4147
 
4148
+ // The session check has not answered yet. "unknown" must not fall through
4149
+ // to the recorder: that shows a signed-out user the full recording UI for
4150
+ // as long as /auth/session takes — indefinitely, if it hangs. Render the
4151
+ // popover's shape instead until the check resolves either way.
4152
+ if (authStatus === "unknown") {
4153
+ return (
4154
+ <div className="app" ref={appRef}>
4155
+ <div
4156
+ className="header header-centered"
4157
+ onMouseDown={handlePopoverHeaderMouseDown}
4158
+ >
4159
+ <button
4160
+ className="icon-button header-close"
4161
+ onClick={hidePopover}
4162
+ aria-label="Close"
4163
+ title="Close"
4164
+ >
4165
+ <CloseIcon />
4166
+ </button>
4167
+ </div>
4168
+ <div data-tw-surface className="grid gap-2.5 px-4 pb-4 pt-1">
4169
+ <Skeleton className="h-14 w-full rounded-xl" />
4170
+ <Skeleton className="h-14 w-full rounded-xl" />
4171
+ <Skeleton className="mt-2 h-12 w-full rounded-full" />
4172
+ </div>
4173
+ </div>
4174
+ );
4175
+ }
4176
+
3958
4177
  // When unauthenticated, render the sign-in form INLINE in the popover
3959
4178
  // (not a separate Tauri window). This avoids Tauri 2's separate-WebKit-
3960
4179
  // data-store-per-WebviewWindow cookie-jar issue — the cookie is set in
@@ -3964,24 +4183,51 @@ export function App() {
3964
4183
  if (authStatus === "anon") {
3965
4184
  return (
3966
4185
  <div className="app" ref={appRef}>
3967
- <Header
3968
- mode={mode}
3969
- onModeChange={setMode}
3970
- submitterEmail={signedInAs}
3971
- />
3972
- <UpdateBanner />
4186
+ {/* Signed out, the only job on this screen is signing in. Capture
4187
+ modes, Feedback, and Settings all act on an account that does not
4188
+ exist yet, so they appear after auth rather than competing with it.
4189
+ Only the window's own close control stays. */}
4190
+ <div
4191
+ className="header header-centered"
4192
+ onMouseDown={handlePopoverHeaderMouseDown}
4193
+ >
4194
+ <button
4195
+ className="icon-button header-close"
4196
+ onClick={hidePopover}
4197
+ aria-label="Close"
4198
+ title="Close"
4199
+ >
4200
+ <CloseIcon />
4201
+ </button>
4202
+ </div>
3973
4203
  {pendingUploadBanner}
3974
4204
  {signInPending === "google" ? (
3975
- <div className="signin-pending">
3976
- <div className="signin-pending-spinner" />
3977
- <p className="signin-pending-text">Waiting for browser sign-in…</p>
3978
- <button
3979
- type="button"
3980
- className="signin-pending-cancel"
3981
- onClick={cancelSignIn}
3982
- >
3983
- Cancel
3984
- </button>
4205
+ /* `data-tw-surface` marks only this subtree: the sign-in form beside
4206
+ it is still hand-written CSS that the scoped preflight would
4207
+ strip. */
4208
+ <div data-tw-surface>
4209
+ <Empty className="w-full border-none">
4210
+ <EmptyHeader>
4211
+ <EmptyMedia variant="icon">
4212
+ <Spinner />
4213
+ </EmptyMedia>
4214
+ <EmptyTitle>Sign in from your browser</EmptyTitle>
4215
+ <EmptyDescription>
4216
+ We opened a tab for {serverHostForSignIn}. Approve access
4217
+ there and Clips picks it up from here.
4218
+ </EmptyDescription>
4219
+ </EmptyHeader>
4220
+ <EmptyContent>
4221
+ <Button
4222
+ variant="outline"
4223
+ size="sm"
4224
+ className="h-8 rounded-md px-3 text-sm"
4225
+ onClick={cancelSignIn}
4226
+ >
4227
+ Cancel
4228
+ </Button>
4229
+ </EmptyContent>
4230
+ </Empty>
3985
4231
  </div>
3986
4232
  ) : (
3987
4233
  <>
@@ -4001,13 +4247,26 @@ export function App() {
4001
4247
  }
4002
4248
  onMagicLinkBack={cancelSignIn}
4003
4249
  />
4250
+ {/* The escape hatches. An unreachable server names itself; a
4251
+ reachable-but-WRONG server (packaged default when the user
4252
+ wants self-hosted, dev build on the wrong port) answers 401
4253
+ forever, and without this link the only way into Settings ›
4254
+ Advanced is signing in to a server the user never wanted. */}
4255
+ <div className="footer">
4256
+ <span>
4257
+ {serverReachable
4258
+ ? serverHostForSignIn
4259
+ : `Can’t reach ${serverHostForSignIn}`}
4260
+ </span>
4261
+ <a
4262
+ className="footer-link"
4263
+ onClick={() => openSettings("advanced")}
4264
+ >
4265
+ Change server
4266
+ </a>
4267
+ </div>
4004
4268
  </>
4005
4269
  )}
4006
- <div className="footer">
4007
- <a className="footer-link" onClick={() => openSettings()}>
4008
- Settings
4009
- </a>
4010
- </div>
4011
4270
  </div>
4012
4271
  );
4013
4272
  }
@@ -4209,6 +4468,7 @@ export function App() {
4209
4468
  <BottomButton
4210
4469
  icon="settings"
4211
4470
  label="Settings"
4471
+ alert={updateReadyToInstall}
4212
4472
  onClick={() => openSettings()}
4213
4473
  />
4214
4474
  </div>
@@ -4798,13 +5058,119 @@ function SignInForm({
4798
5058
  const [submitting, setSubmitting] = useState(false);
4799
5059
  const [error, setError] = useState<string | null>(null);
4800
5060
  const emailRef = useRef<HTMLInputElement | null>(null);
5061
+ const passwordRef = useRef<HTMLInputElement | null>(null);
5062
+ const [fieldErrors, setFieldErrors] = useState<{
5063
+ email?: string;
5064
+ password?: string;
5065
+ }>({});
4801
5066
  useEffect(() => {
4802
5067
  if (!magicLinkSentEmail) emailRef.current?.focus();
4803
5068
  }, [magicLinkSentEmail]);
4804
5069
 
5070
+ /**
5071
+ * The framework exposes a first-class local-dev sign-in that creates or
5072
+ * reuses an auto-managed dev account. Whether to offer it is the server's
5073
+ * call, not ours: it answers `available: true` only in development with no
5074
+ * other users, so there is no env sniffing or invented affordance here.
5075
+ */
5076
+ const [devSignInAvailable, setDevSignInAvailable] = useState(false);
5077
+ useEffect(() => {
5078
+ let cancelled = false;
5079
+ fetch(`${serverUrl.replace(/\/+$/, "")}/_agent-native/auth/local-dev`, {
5080
+ credentials: "include",
5081
+ })
5082
+ .then(async (res) => {
5083
+ const raw = res.ok ? await res.text() : "";
5084
+ let available: boolean | null = null;
5085
+ let parseError: string | null = null;
5086
+ if (raw) {
5087
+ try {
5088
+ available =
5089
+ (JSON.parse(raw) as { available?: boolean }).available === true;
5090
+ } catch (err) {
5091
+ // coercion-ok: recorded and logged below as its own outcome, so an
5092
+ // unreadable body never passes for "not available".
5093
+ parseError = err instanceof Error ? err.message : String(err);
5094
+ }
5095
+ }
5096
+ if (cancelled) return;
5097
+ setDevSignInAvailable(available === true);
5098
+ // Hidden-because-unavailable, hidden-because-unreadable, and
5099
+ // hidden-because-the-probe-broke look identical on screen. Say which.
5100
+ console.info("[clips-tray] local-dev sign-in probe", {
5101
+ server: serverUrl,
5102
+ status: res.status,
5103
+ available,
5104
+ parseError,
5105
+ });
5106
+ })
5107
+ .catch((err) => {
5108
+ if (cancelled) return;
5109
+ setDevSignInAvailable(false);
5110
+ console.warn("[clips-tray] local-dev sign-in probe failed:", err);
5111
+ });
5112
+ return () => {
5113
+ cancelled = true;
5114
+ };
5115
+ }, [serverUrl]);
5116
+
5117
+ async function signInAsLocalDev() {
5118
+ if (submitting) return;
5119
+ setError(null);
5120
+ setSubmitting(true);
5121
+ try {
5122
+ const res = await fetch(
5123
+ `${serverUrl.replace(/\/+$/, "")}/_agent-native/auth/local-dev`,
5124
+ { method: "POST", credentials: "include" },
5125
+ );
5126
+ const raw = await res.text();
5127
+ let json: { error?: string; token?: string } | null = null;
5128
+ try {
5129
+ json = raw
5130
+ ? (JSON.parse(raw) as { error?: string; token?: string })
5131
+ : null;
5132
+ } catch {
5133
+ // coercion-ok: the unparsed body is still reported in the error below,
5134
+ // so a non-JSON response surfaces instead of reading as an empty one.
5135
+ }
5136
+ if (!res.ok) {
5137
+ throw new Error(
5138
+ json?.error ||
5139
+ raw.slice(0, 200) ||
5140
+ `Dev sign-in didn't work (${res.status})`,
5141
+ );
5142
+ }
5143
+ // A missing token is legitimate: the server only puts it in the body for
5144
+ // desktop-marked requests, and the session cookie covers the rest.
5145
+ // `onSignedIn` re-checks the session either way.
5146
+ if (json?.token) saveDesktopAuthToken(serverUrl, json.token);
5147
+ await onSignedIn();
5148
+ } catch (err) {
5149
+ setError(err instanceof Error ? err.message : String(err));
5150
+ } finally {
5151
+ setSubmitting(false);
5152
+ }
5153
+ }
5154
+
4805
5155
  async function onSubmit(e: React.FormEvent) {
4806
5156
  e.preventDefault();
4807
5157
  if (submitting) return;
5158
+ // Native constraint validation is off (noValidate): the UA bubble is
5159
+ // unstyleable and vanishes on blur. Field errors render inline instead,
5160
+ // in the "Enter a [noun]" shape.
5161
+ const trimmedEmail = email.trim();
5162
+ const nextFieldErrors: { email?: string; password?: string } = {};
5163
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
5164
+ nextFieldErrors.email = "Enter an email like you@example.com";
5165
+ }
5166
+ if (authMode === "password" && !password) {
5167
+ nextFieldErrors.password = "Enter a password";
5168
+ }
5169
+ setFieldErrors(nextFieldErrors);
5170
+ if (nextFieldErrors.email || nextFieldErrors.password) {
5171
+ (nextFieldErrors.email ? emailRef : passwordRef).current?.focus();
5172
+ return;
5173
+ }
4808
5174
  setError(null);
4809
5175
  setSubmitting(true);
4810
5176
  try {
@@ -4830,7 +5196,9 @@ function SignInForm({
4830
5196
  token?: string;
4831
5197
  } | null;
4832
5198
  if (!res.ok) {
4833
- throw new Error(json?.error || `Sign in failed (${res.status})`);
5199
+ throw new Error(
5200
+ json?.error || "Couldn't sign you in. Check your email and password.",
5201
+ );
4834
5202
  }
4835
5203
  if (json?.token) saveDesktopAuthToken(serverUrl, json.token);
4836
5204
  await onSignedIn();
@@ -4846,7 +5214,7 @@ function SignInForm({
4846
5214
  <div className="signin signin-success" aria-live="polite">
4847
5215
  <div className="signin-title">Check your email</div>
4848
5216
  <p className="signin-success-copy">
4849
- {"We sent a secure sign-in link to "}
5217
+ {"We sent a sign-in link to "}
4850
5218
  <strong>{magicLinkSentEmail}</strong>
4851
5219
  {"."}
4852
5220
  </p>
@@ -4862,14 +5230,17 @@ function SignInForm({
4862
5230
  }
4863
5231
 
4864
5232
  return (
4865
- <form className="signin" onSubmit={onSubmit}>
4866
- <div className="signin-title">Welcome</div>
4867
- <div className="signin-subtitle">Create an account or sign in</div>
5233
+ <form className="signin" onSubmit={onSubmit} noValidate>
5234
+ <PillLogo className="signin-mark" />
5235
+ <div className="signin-title">Welcome to Clips</div>
5236
+ <div className="signin-subtitle">
5237
+ Record your screen, camera, and mic. Share a link the moment you stop.
5238
+ </div>
4868
5239
  <button
4869
5240
  type="button"
4870
5241
  className="signin-google"
4871
5242
  onClick={onUseBrowser}
4872
- title="Returns to Clips to complete Google sign-in"
5243
+ title="Comes back to Clips to finish sign-in"
4873
5244
  >
4874
5245
  <GoogleIcon />
4875
5246
  Sign in with Google
@@ -4877,31 +5248,49 @@ function SignInForm({
4877
5248
  <div className="signin-divider">
4878
5249
  <span>or</span>
4879
5250
  </div>
4880
- <input
4881
- ref={emailRef}
4882
- type="email"
4883
- autoComplete="email"
4884
- placeholder="you@example.com"
4885
- value={email}
4886
- onChange={(e) => {
4887
- setEmail(e.target.value);
4888
- setError(null);
4889
- }}
4890
- required
4891
- />
4892
- {authMode === "password" ? (
4893
- <input
4894
- type="password"
4895
- autoComplete="current-password"
4896
- placeholder="Password"
4897
- value={password}
5251
+ <div data-tw-surface className="grid w-full gap-2">
5252
+ <Input
5253
+ ref={emailRef}
5254
+ type="email"
5255
+ autoComplete="email"
5256
+ placeholder="you@example.com"
5257
+ className="h-9 text-sm"
5258
+ value={email}
5259
+ aria-invalid={fieldErrors.email ? true : undefined}
4898
5260
  onChange={(e) => {
4899
- setPassword(e.target.value);
5261
+ setEmail(e.target.value);
4900
5262
  setError(null);
5263
+ setFieldErrors((current) => ({ ...current, email: undefined }));
4901
5264
  }}
4902
- required
4903
5265
  />
4904
- ) : null}
5266
+ {fieldErrors.email ? (
5267
+ <p className="text-xs text-destructive">{fieldErrors.email}</p>
5268
+ ) : null}
5269
+ {authMode === "password" ? (
5270
+ <>
5271
+ <Input
5272
+ ref={passwordRef}
5273
+ type="password"
5274
+ autoComplete="current-password"
5275
+ placeholder="Password"
5276
+ className="h-9 text-sm"
5277
+ value={password}
5278
+ aria-invalid={fieldErrors.password ? true : undefined}
5279
+ onChange={(e) => {
5280
+ setPassword(e.target.value);
5281
+ setError(null);
5282
+ setFieldErrors((current) => ({
5283
+ ...current,
5284
+ password: undefined,
5285
+ }));
5286
+ }}
5287
+ />
5288
+ {fieldErrors.password ? (
5289
+ <p className="text-xs text-destructive">{fieldErrors.password}</p>
5290
+ ) : null}
5291
+ </>
5292
+ ) : null}
5293
+ </div>
4905
5294
  {error ? <div className="error-banner">{error}</div> : null}
4906
5295
  <button
4907
5296
  type="submit"
@@ -4932,6 +5321,16 @@ function SignInForm({
4932
5321
  ? "Use a password instead"
4933
5322
  : "Use a sign-in link instead"}
4934
5323
  </button>
5324
+ {devSignInAvailable ? (
5325
+ <button
5326
+ type="button"
5327
+ className="signin-alt signin-mode-link"
5328
+ onClick={signInAsLocalDev}
5329
+ disabled={submitting}
5330
+ >
5331
+ Continue as the dev account
5332
+ </button>
5333
+ ) : null}
4935
5334
  </form>
4936
5335
  );
4937
5336
  }
@@ -5243,11 +5642,13 @@ function BottomButton({
5243
5642
  icon,
5244
5643
  label,
5245
5644
  badge,
5645
+ alert = false,
5246
5646
  onClick,
5247
5647
  }: {
5248
5648
  icon: "library" | "settings" | "meetings" | "dictation";
5249
5649
  label: string;
5250
5650
  badge?: string;
5651
+ alert?: boolean;
5251
5652
  onClick: () => void;
5252
5653
  }) {
5253
5654
  return (
@@ -5263,6 +5664,9 @@ function BottomButton({
5263
5664
  <IconMicrophone2 size={18} stroke={1.75} />
5264
5665
  )}
5265
5666
  {badge ? <span className="badge">{badge}</span> : null}
5667
+ {alert && !badge ? (
5668
+ <span className="bottom-dot" role="img" aria-label="Update ready" />
5669
+ ) : null}
5266
5670
  </span>
5267
5671
  <span className="bottom-label">{label}</span>
5268
5672
  </button>
@@ -5330,29 +5734,9 @@ function formatStorageBytes(bytes: number): string {
5330
5734
  return `${Math.max(1, Math.round(mb))} MB`;
5331
5735
  }
5332
5736
 
5333
- function desktopUpdateStatusText(status: UpdateStatus): string {
5334
- switch (status.state) {
5335
- case "idle":
5336
- return "Clips checks automatically after launch, every hour, and when you return.";
5337
- case "checking":
5338
- return "Checking for updates...";
5339
- case "not-available":
5340
- return "Clips is up to date.";
5341
- case "available":
5342
- return `Update ${status.version} found. Downloading now...`;
5343
- case "downloading":
5344
- return `Downloading update ${status.version}... ${status.percent}%`;
5345
- case "downloaded":
5346
- return `Update ${status.version} is ready. Restart to install it.`;
5347
- case "error":
5348
- return `Update check failed: ${status.message}`;
5349
- }
5350
- }
5351
-
5352
5737
  function Setup({
5353
5738
  surface = "settings",
5354
5739
  initialSettingsTab,
5355
- promptRewindEnable = false,
5356
5740
  recordingActive = false,
5357
5741
  initial,
5358
5742
  serverUrl,
@@ -5376,12 +5760,12 @@ function Setup({
5376
5760
  rewindAgentPromptCopied,
5377
5761
  onCopyRewindAgentPrompt,
5378
5762
  onOpenRewindDocs,
5763
+ onOpenMemory,
5379
5764
  onCancel,
5380
5765
  onSignOut,
5381
5766
  }: {
5382
- surface?: "settings" | "memory" | "rewind";
5767
+ surface?: "settings" | "memory";
5383
5768
  initialSettingsTab?: SettingsTabId;
5384
- promptRewindEnable?: boolean;
5385
5769
  recordingActive?: boolean;
5386
5770
  initial?: string | null;
5387
5771
  serverUrl?: string;
@@ -5405,21 +5789,17 @@ function Setup({
5405
5789
  rewindAgentPromptCopied: boolean;
5406
5790
  onCopyRewindAgentPrompt: () => void;
5407
5791
  onOpenRewindDocs: () => void;
5792
+ /** Opens the Rewind manual-search surface. Only the settings instance gets
5793
+ * it — the memory surface itself must not offer a loop back into itself. */
5794
+ onOpenMemory?: () => void;
5408
5795
  onCancel?: () => void;
5409
5796
  onSignOut?: () => void;
5410
5797
  }) {
5411
5798
  const [url, setUrl] = useState(initial ?? DEFAULT_URL);
5412
- const [readinessOpen, setReadinessOpen] = useState(false);
5413
5799
  const [settingsTab, setSettingsTab] = useState<SettingsTabId>(() =>
5414
5800
  initialDesktopSettingsTab(initialSettingsTab),
5415
5801
  );
5416
- const [rewindSettingsOpen, setRewindSettingsOpen] = useState(false);
5417
- const [rewindActivityOpen, setRewindActivityOpen] = useState(false);
5418
- const [rewindMemoryOpen, setRewindMemoryOpen] = useState(false);
5419
- const [rewindSearchOpen, setRewindSearchOpen] = useState(false);
5420
- const [rewindPrivacyOpen, setRewindPrivacyOpen] = useState(false);
5421
- const [rewindAgentSetupOpen, setRewindAgentSetupOpen] = useState(false);
5422
- const [rewindHandoffOpen, setRewindHandoffOpen] = useState(false);
5802
+ const [serverUrlOpen, setServerUrlOpen] = useState(false);
5423
5803
 
5424
5804
  useEffect(() => {
5425
5805
  if (surface !== "settings") return;
@@ -5431,6 +5811,8 @@ function Setup({
5431
5811
  const voiceEnabled = featureConfig?.voiceEnabled !== false;
5432
5812
  const voiceCleanupEnabled = featureConfig?.voiceCleanupEnabled !== false;
5433
5813
  const meetingsEnabled = featureConfig?.meetingsEnabled !== false;
5814
+ const showMeetingWidgetEnabled =
5815
+ featureConfig?.showMeetingWidgetEnabled !== false;
5434
5816
  const launchAtLoginEnabled = featureConfig?.launchAtLoginEnabled !== false;
5435
5817
  const autoHidePopoverEnabled = featureConfig?.autoHidePopoverEnabled === true;
5436
5818
  const showInScreenCapture = featureConfig?.showInScreenCapture === true;
@@ -5438,12 +5820,7 @@ function Setup({
5438
5820
  const observedScreenMemory =
5439
5821
  featureConfig?.screenMemory ?? DEFAULT_SCREEN_MEMORY_CONFIG;
5440
5822
  const [screenMemory, setScreenMemory] = useState(observedScreenMemory);
5441
- const [rewindConsentOpen, setRewindConsentOpen] = useState(
5442
- promptRewindEnable && observedScreenMemory.enabled !== true,
5443
- );
5444
- const [rewindConsentMode, setRewindConsentMode] = useState<
5445
- "visuals" | "visuals-audio"
5446
- >(observedScreenMemory.captureMode ?? "visuals");
5823
+ const [rewindConsentOpen, setRewindConsentOpen] = useState(false);
5447
5824
  const screenMemoryRef = useRef(observedScreenMemory);
5448
5825
  const screenMemoryMutationRef = useRef(0);
5449
5826
  const screenMemoryMutationVersionRef = useRef(0);
@@ -5465,8 +5842,6 @@ function Setup({
5465
5842
  const regionGuidesAlwaysVisible = regionGuides.alwaysVisible === true;
5466
5843
  const meetingTranscriptionMode: MeetingTranscriptionMode =
5467
5844
  featureConfig?.meetingTranscriptionMode ?? "ask";
5468
- const showMeetingWidgetEnabled =
5469
- featureConfig?.showMeetingWidgetEnabled !== false;
5470
5845
  const whisper = useWhisperSettings(
5471
5846
  featureConfig,
5472
5847
  voiceProvider,
@@ -5478,7 +5853,6 @@ function Setup({
5478
5853
  status: whisperStatus,
5479
5854
  enabled: whisperModelEnabled,
5480
5855
  modelId: whisperModelId,
5481
- selectedModel: selectedWhisperModel,
5482
5856
  deletableModels,
5483
5857
  } = whisper;
5484
5858
  const [screenMemoryStatus, setScreenMemoryStatus] =
@@ -5488,20 +5862,17 @@ function Setup({
5488
5862
  kind: "ok" | "error";
5489
5863
  text: string;
5490
5864
  } | null>(null);
5491
- const [screenMemoryExportResult, setScreenMemoryExportResult] =
5492
- useState<ScreenMemoryExportResult | null>(null);
5493
5865
  const [clipDraftsError, setClipDraftsError] = useState<string | null>(null);
5494
5866
  const [screenMemoryBusy, setScreenMemoryBusy] = useState(false);
5495
- const [rewindEgressEvents, setRewindEgressEvents] = useState<
5496
- RewindEgressEvent[]
5497
- >([]);
5498
5867
  const [rewindLocalQuery, setRewindLocalQuery] = useState("");
5499
5868
  const [rewindLocalResult, setRewindLocalResult] =
5500
5869
  useState<RewindLocalAskResult | null>(null);
5501
5870
  const [rewindLocalBusy, setRewindLocalBusy] = useState(false);
5502
5871
  const [rewindLocalError, setRewindLocalError] = useState<string | null>(null);
5503
5872
  const [rewindReplayId, setRewindReplayId] = useState<string | null>(null);
5504
- const [, setExcludedBundleIdsInput] = useState("");
5873
+ const [rewindEgressEvents, setRewindEgressEvents] = useState<
5874
+ RewindEgressEvent[]
5875
+ >([]);
5505
5876
  const [excludedApps, setExcludedApps] = useState<RewindExcludedApplication[]>(
5506
5877
  [],
5507
5878
  );
@@ -5526,9 +5897,6 @@ function Setup({
5526
5897
  const captureControlsLocked = recordingActive;
5527
5898
 
5528
5899
  useEffect(() => {
5529
- setExcludedBundleIdsInput(
5530
- (screenMemory.excludedBundleIds ?? []).join(", "),
5531
- );
5532
5900
  invoke<RewindExcludedApplication[]>("resolve_rewind_excluded_apps", {
5533
5901
  bundleIds: screenMemory.excludedBundleIds ?? [],
5534
5902
  })
@@ -5588,6 +5956,10 @@ function Setup({
5588
5956
  );
5589
5957
  }
5590
5958
 
5959
+ // Deliberately does NOT write showMeetingWidgetEnabled: widget-off with
5960
+ // notes-on is a stored configuration real users hold, and folding the two
5961
+ // into one switch silently re-enabled the widget the next time they touched
5962
+ // notes. The widget keeps its own sub-toggle below.
5591
5963
  function setMeetingsEnabled(enabled: boolean) {
5592
5964
  if (!featureConfig) return;
5593
5965
  invoke("set_feature_config", {
@@ -5597,6 +5969,15 @@ function Setup({
5597
5969
  );
5598
5970
  }
5599
5971
 
5972
+ function setShowMeetingWidgetEnabled(enabled: boolean) {
5973
+ if (!featureConfig) return;
5974
+ invoke("set_feature_config", {
5975
+ config: { ...featureConfig, showMeetingWidgetEnabled: enabled },
5976
+ }).catch((err) =>
5977
+ console.error("[settings] set_feature_config failed", err),
5978
+ );
5979
+ }
5980
+
5600
5981
  function setLaunchAtLoginEnabled(enabled: boolean) {
5601
5982
  if (!featureConfig) return;
5602
5983
  invoke("set_feature_config", {
@@ -5629,23 +6010,33 @@ function Setup({
5629
6010
  ) {
5630
6011
  if (!featureConfig) return;
5631
6012
  setScreenMemoryMessage(null);
5632
- const next = {
6013
+ const previous = screenMemoryRef.current;
6014
+ // Optimistic UI only — the persisted payload is built inside the queued
6015
+ // callback from the on-disk config plus THIS call's patch, so a failed
6016
+ // earlier mutation's values can never ride along in a later write.
6017
+ const optimistic = {
5633
6018
  ...DEFAULT_SCREEN_MEMORY_CONFIG,
5634
- ...screenMemoryRef.current,
6019
+ ...previous,
5635
6020
  ...patch,
5636
6021
  };
5637
6022
  const version = ++screenMemoryMutationVersionRef.current;
5638
6023
  screenMemoryMutationRef.current += 1;
5639
- screenMemoryRef.current = next;
5640
- setScreenMemory(next);
6024
+ screenMemoryRef.current = optimistic;
6025
+ setScreenMemory(optimistic);
5641
6026
  setScreenMemoryConfigBusy(true);
5642
6027
  let operation!: Promise<void>;
5643
6028
  operation = screenMemoryMutationTailRef.current
5644
6029
  .catch(() => {})
5645
6030
  .then(async () => {
5646
6031
  // Read the latest complete config at execution time so a queued Rewind
5647
- // edit cannot overwrite an unrelated setting changed while it waited.
6032
+ // edit cannot overwrite an unrelated setting changed while it waited
6033
+ // and cannot re-apply a predecessor's failed patch.
5648
6034
  const current = await invoke<FeatureConfig>("get_feature_config");
6035
+ const next = {
6036
+ ...DEFAULT_SCREEN_MEMORY_CONFIG,
6037
+ ...current.screenMemory,
6038
+ ...patch,
6039
+ };
5649
6040
  await invoke("set_feature_config", {
5650
6041
  config: { ...current, screenMemory: next },
5651
6042
  });
@@ -5664,14 +6055,17 @@ function Setup({
5664
6055
  const committed = await invoke<FeatureConfig>(
5665
6056
  "get_feature_config",
5666
6057
  ).catch(() => null);
5667
- if (committed) {
5668
- screenMemoryRef.current = committed.screenMemory;
5669
- setScreenMemory(committed.screenMemory);
5670
- }
6058
+ // When the read-back fails too, roll back to the pre-mutation value —
6059
+ // keeping the optimistic `next` would leave the switch claiming a
6060
+ // state the backend never entered, with only an error line to hint
6061
+ // that the two disagree.
6062
+ const restored = committed ? committed.screenMemory : previous;
6063
+ screenMemoryRef.current = restored;
6064
+ setScreenMemory(restored);
5671
6065
  }
5672
6066
  setScreenMemoryMessage({
5673
6067
  kind: "error",
5674
- text: (err as Error)?.message ?? "Could not update Rewind.",
6068
+ text: (err as Error)?.message ?? "Couldn't update Rewind. Try again.",
5675
6069
  });
5676
6070
  } finally {
5677
6071
  screenMemoryMutationRef.current = Math.max(
@@ -5694,7 +6088,7 @@ function Setup({
5694
6088
  );
5695
6089
  setAgentConnectionMessage({
5696
6090
  kind: "ok",
5697
- text: `${client === "codex" ? "Codex" : "Claude Code"} is connected to this Rewind store. Restart the agent app once to load it.`,
6091
+ text: `${client === "codex" ? "Codex" : "Claude Code"} is connected. Restart it once to load Rewind.`,
5698
6092
  });
5699
6093
  console.info(
5700
6094
  `[clips-tray] configured ${status.client} Screen Memory MCP at ${status.configPath}`,
@@ -5720,18 +6114,6 @@ function Setup({
5720
6114
  .catch(() => {});
5721
6115
  }, []);
5722
6116
 
5723
- function refreshRewindEgressLog() {
5724
- invoke<RewindEgressEvent[]>("rewind_list_evidence_egress", { limit: 20 })
5725
- .then(setRewindEgressEvents)
5726
- .catch((err) => {
5727
- setScreenMemoryMessage({
5728
- kind: "error",
5729
- text:
5730
- (err as Error)?.message ?? "Could not read the Rewind access log.",
5731
- });
5732
- });
5733
- }
5734
-
5735
6117
  async function askRewindLocally() {
5736
6118
  const query = rewindLocalQuery.trim();
5737
6119
  if (!query) return;
@@ -5745,7 +6127,8 @@ function Setup({
5745
6127
  setRewindLocalResult(result);
5746
6128
  } catch (err) {
5747
6129
  setRewindLocalError(
5748
- (err as Error)?.message ?? "Could not search local Rewind evidence.",
6130
+ (err as Error)?.message ??
6131
+ "Couldn't search this device's memory. Try again.",
5749
6132
  );
5750
6133
  } finally {
5751
6134
  setRewindLocalBusy(false);
@@ -5764,7 +6147,8 @@ function Setup({
5764
6147
  });
5765
6148
  } catch (err) {
5766
6149
  setRewindLocalError(
5767
- (err as Error)?.message ?? "Could not replay this local moment.",
6150
+ (err as Error)?.message ??
6151
+ "Couldn't replay this moment. Try another result.",
5768
6152
  );
5769
6153
  } finally {
5770
6154
  setRewindReplayId(null);
@@ -5774,17 +6158,21 @@ function Setup({
5774
6158
  async function exportScreenMemoryRecent() {
5775
6159
  setScreenMemoryBusy(true);
5776
6160
  setScreenMemoryMessage(null);
5777
- setScreenMemoryExportResult(null);
5778
6161
  try {
5779
6162
  const result = await invoke<ScreenMemoryExportResult>(
5780
6163
  "screen_memory_export_recent",
5781
6164
  { minutes: 5 },
5782
6165
  );
5783
- setScreenMemoryExportResult(result);
6166
+ setScreenMemoryMessage({
6167
+ kind: "ok",
6168
+ text: `Saved ${result.files.length === 1 ? "a video" : `${result.files.length} videos`} to ${result.folderPath}`,
6169
+ });
5784
6170
  } catch (err) {
5785
6171
  setScreenMemoryMessage({
5786
6172
  kind: "error",
5787
- text: err instanceof Error ? err.message : String(err),
6173
+ text:
6174
+ (err as Error)?.message ??
6175
+ "Couldn't save the last 5 minutes. Try again.",
5788
6176
  });
5789
6177
  } finally {
5790
6178
  setScreenMemoryBusy(false);
@@ -5792,6 +6180,19 @@ function Setup({
5792
6180
  }
5793
6181
  }
5794
6182
 
6183
+ function refreshRewindEgressLog() {
6184
+ invoke<RewindEgressEvent[]>("rewind_list_evidence_egress", { limit: 20 })
6185
+ .then(setRewindEgressEvents)
6186
+ .catch((err) => {
6187
+ setScreenMemoryMessage({
6188
+ kind: "error",
6189
+ text:
6190
+ (err as Error)?.message ??
6191
+ "Couldn't read the access log. Try again.",
6192
+ });
6193
+ });
6194
+ }
6195
+
5795
6196
  async function clearScreenMemory() {
5796
6197
  setScreenMemoryBusy(true);
5797
6198
  setScreenMemoryMessage(null);
@@ -5801,11 +6202,11 @@ function Setup({
5801
6202
  );
5802
6203
  screenMemoryStatusRefreshVersionRef.current += 1;
5803
6204
  setScreenMemoryStatus(status);
5804
- setScreenMemoryMessage({ kind: "ok", text: "Rewind cleared." });
6205
+ setScreenMemoryMessage({ kind: "ok", text: "Rewind cleared" });
5805
6206
  } catch (err) {
5806
6207
  setScreenMemoryMessage({
5807
6208
  kind: "error",
5808
- text: (err as Error)?.message ?? "Could not clear Rewind.",
6209
+ text: (err as Error)?.message ?? "Couldn't clear Rewind. Try again.",
5809
6210
  });
5810
6211
  } finally {
5811
6212
  setScreenMemoryBusy(false);
@@ -5816,7 +6217,7 @@ function Setup({
5816
6217
  invoke("screen_memory_open_folder").catch((err) => {
5817
6218
  setScreenMemoryMessage({
5818
6219
  kind: "error",
5819
- text: (err as Error)?.message ?? "Could not open Rewind folder.",
6220
+ text: (err as Error)?.message ?? "Couldn't open the Rewind folder.",
5820
6221
  });
5821
6222
  });
5822
6223
  }
@@ -5839,7 +6240,8 @@ function Setup({
5839
6240
  } catch (err) {
5840
6241
  setScreenMemoryMessage({
5841
6242
  kind: "error",
5842
- text: (err as Error)?.message ?? "Could not choose applications.",
6243
+ text:
6244
+ (err as Error)?.message ?? "Couldn't open the app picker. Try again.",
5843
6245
  });
5844
6246
  } finally {
5845
6247
  setExcludedAppsBusy(false);
@@ -5859,7 +6261,7 @@ function Setup({
5859
6261
  setClipDraftsError(null);
5860
6262
  invoke("native_fullscreen_open_drafts_folder").catch((err) => {
5861
6263
  setClipDraftsError(
5862
- (err as Error)?.message ?? "Could not open Clip Drafts.",
6264
+ (err as Error)?.message ?? "Couldn't open the drafts folder.",
5863
6265
  );
5864
6266
  });
5865
6267
  }
@@ -5955,15 +6357,6 @@ function Setup({
5955
6357
  );
5956
6358
  }
5957
6359
 
5958
- function setShowMeetingWidgetEnabled(enabled: boolean) {
5959
- if (!featureConfig) return;
5960
- invoke("set_feature_config", {
5961
- config: { ...featureConfig, showMeetingWidgetEnabled: enabled },
5962
- }).catch((err) =>
5963
- console.error("[settings] set_feature_config failed", err),
5964
- );
5965
- }
5966
-
5967
6360
  useEffect(() => {
5968
6361
  let cancelled = false;
5969
6362
  const refresh = () => {
@@ -6000,6 +6393,14 @@ function Setup({
6000
6393
  };
6001
6394
  }, [refreshScreenMemoryStatus]);
6002
6395
 
6396
+ // The agent-activity log loads when the Rewind tab is actually in front —
6397
+ // it is an audit read, not something to poll from every settings view.
6398
+ useEffect(() => {
6399
+ if (surface !== "settings" || settingsTab !== "rewind") return;
6400
+ if (screenMemory.enabled !== true) return;
6401
+ refreshRewindEgressLog();
6402
+ }, [surface, settingsTab, screenMemory.enabled]);
6403
+
6003
6404
  useEffect(() => {
6004
6405
  const base = (serverUrl ?? initial ?? DEFAULT_URL).replace(/\/+$/, "");
6005
6406
  let cancelled = false;
@@ -6046,9 +6447,29 @@ function Setup({
6046
6447
  };
6047
6448
  }, [serverUrl, initial]);
6048
6449
 
6450
+ const [serverUrlError, setServerUrlError] = useState<string | null>(null);
6451
+
6049
6452
  function handleConnect() {
6050
6453
  const trimmed = url.trim();
6051
- if (!trimmed) return;
6454
+ // Anything that parses as http(s) is allowed — reachability is the
6455
+ // sign-in screen's job — but junk must not be persisted as the server
6456
+ // URL, where it silently fails every request.
6457
+ let parsed: URL | null = null;
6458
+ try {
6459
+ parsed = new URL(trimmed);
6460
+ } catch {
6461
+ // coercion-ok: null is the typed "not a URL" outcome the next branch
6462
+ // reports to the user as an inline field error.
6463
+ parsed = null;
6464
+ }
6465
+ if (
6466
+ !parsed ||
6467
+ (parsed.protocol !== "http:" && parsed.protocol !== "https:")
6468
+ ) {
6469
+ setServerUrlError("Enter a URL like http://localhost:8080");
6470
+ return;
6471
+ }
6472
+ setServerUrlError(null);
6052
6473
  onConnect(trimmed);
6053
6474
  }
6054
6475
 
@@ -6056,28 +6477,16 @@ function Setup({
6056
6477
  const byokProvider: ByokVoiceProvider = isByokVoiceProvider(voiceProvider)
6057
6478
  ? voiceProvider
6058
6479
  : "gemini";
6059
- const providerHint: Record<VoiceProviderMode, string> = {
6060
- native: isMacPlatform()
6061
- ? "Uses macOS on-device speech recognition for the fastest free dictation."
6062
- : "Uses the browser's built-in speech recognition when available.",
6063
- whisper: "Uses the local Whisper model for offline AI transcription.",
6064
- builder:
6065
- "Uses Builder.io for fast cleanup. No separate provider key needed.",
6066
- byok: "Use your own provider key for cleanup.",
6067
- };
6068
- const shortcutHint: Record<VoiceShortcutPreference, string> = {
6069
- fn: "Press the Fn / globe key to dictate. macOS requires Input Monitoring for this one shortcut.",
6070
- "cmd-shift-space":
6071
- "Press Cmd Shift Space to dictate. This does not need Input Monitoring.",
6072
- "ctrl-shift-space": "Press Ctrl Shift Space to dictate.",
6073
- custom: `Press ${voiceCustomShortcut || "your recorded shortcut"} to dictate.`,
6074
- both: "Any of Fn, Cmd Shift Space, or Ctrl Shift Space. Includes Fn, so macOS may ask for Input Monitoring.",
6075
- };
6076
6480
  const fnShortcutSelected = voiceShortcut === "fn" || voiceShortcut === "both";
6077
- const modeHint: Record<VoiceMode, string> = {
6078
- "push-to-talk": "Hold the shortcut while speaking. Release to stop.",
6079
- toggle: "Press once to start, again to stop.",
6080
- };
6481
+ const canOpenPrivacySettings = isMacPlatform() || isWindowsPlatform();
6482
+ const systemAccessRows = useSystemAccessRows({
6483
+ includeVoicePaste: voiceEnabled,
6484
+ includeFnMonitoring: fnShortcutSelected,
6485
+ onOpenSettings: openPrivacySettings,
6486
+ });
6487
+ const serverHostLabel = (serverUrl ?? initial ?? DEFAULT_URL)
6488
+ .replace(/^https?:\/\//, "")
6489
+ .replace(/\/+$/, "");
6081
6490
 
6082
6491
  function selectProviderMode(mode: VoiceProviderMode) {
6083
6492
  setApiKeyMessage(null);
@@ -6131,7 +6540,9 @@ function Setup({
6131
6540
  const body = (await res.json().catch(() => null)) as {
6132
6541
  error?: string;
6133
6542
  } | null;
6134
- throw new Error(body?.error || `Save failed (${res.status})`);
6543
+ throw new Error(
6544
+ body?.error || `Couldn't save the key (${res.status}). Try again.`,
6545
+ );
6135
6546
  }
6136
6547
 
6137
6548
  setProviderStatus((prev) =>
@@ -6148,12 +6559,12 @@ function Setup({
6148
6559
  setApiKeyValue("");
6149
6560
  setApiKeyMessage({
6150
6561
  kind: "ok",
6151
- text: `${labelForByokProvider(byokProvider)} key saved.`,
6562
+ text: `${labelForByokProvider(byokProvider)} key saved`,
6152
6563
  });
6153
6564
  } catch (err) {
6154
6565
  setApiKeyMessage({
6155
6566
  kind: "error",
6156
- text: (err as Error)?.message ?? "Could not save key.",
6567
+ text: (err as Error)?.message ?? "Couldn't save the key. Try again.",
6157
6568
  });
6158
6569
  } finally {
6159
6570
  setApiKeySaving(false);
@@ -6165,7 +6576,7 @@ function Setup({
6165
6576
  openExternal(`${base}/_agent-native/builder/connect`).catch((err) => {
6166
6577
  setApiKeyMessage({
6167
6578
  kind: "error",
6168
- text: (err as Error)?.message ?? "Could not open Builder.io connect.",
6579
+ text: (err as Error)?.message ?? "Couldn't open Builder.io. Try again.",
6169
6580
  });
6170
6581
  });
6171
6582
  }
@@ -6178,10 +6589,10 @@ function Setup({
6178
6589
  if (selectedMode === "builder") {
6179
6590
  return providerStatus.builder
6180
6591
  ? null
6181
- : "Builder.io is not connected cleanup will fail until connected.";
6592
+ : "Cleanup is off until Builder.io is connected.";
6182
6593
  }
6183
6594
  if (providerStatus[byokProvider]) return null;
6184
- return `${keyForByokProvider(byokProvider)} is not set cleanup will fail until configured.`;
6595
+ return `Cleanup is off until you add ${keyForByokProvider(byokProvider)}.`;
6185
6596
  })();
6186
6597
  const updateChecksSupported = canCheckForUpdates();
6187
6598
  const updateBusy =
@@ -6189,13 +6600,32 @@ function Setup({
6189
6600
  updateStatus.state === "available" ||
6190
6601
  updateStatus.state === "downloading";
6191
6602
  const updateReady = updateStatus.state === "downloaded";
6192
- const updateCheckLabel = !updateChecksSupported
6193
- ? "Release builds only"
6194
- : updateStatus.state === "checking"
6195
- ? "Checking..."
6603
+ const updateRowLabel = !updateChecksSupported
6604
+ ? `Version ${__CLIPS_DESKTOP_VERSION__ || "0.0.0"}`
6605
+ : updateStatus.state === "downloaded"
6606
+ ? `Version ${updateStatus.version} ready`
6196
6607
  : updateStatus.state === "downloading"
6197
- ? `Downloading ${updateStatus.percent}%`
6198
- : "Check now";
6608
+ ? `Downloading ${updateStatus.version} · ${updateStatus.percent}%`
6609
+ : updateStatus.state === "available"
6610
+ ? `Version ${updateStatus.version} found`
6611
+ : updateStatus.state === "checking"
6612
+ ? "Checking for updates"
6613
+ : updateStatus.state === "error"
6614
+ ? "Couldn't check for updates"
6615
+ : `Version ${__CLIPS_DESKTOP_VERSION__ || "0.0.0"}`;
6616
+ // Dev/unsigned builds get the bare version — no subtitle, no control. The
6617
+ // updater states below are unreachable there, and real users never see this
6618
+ // branch.
6619
+ const updateRowDescription = !updateChecksSupported
6620
+ ? undefined
6621
+ : updateStatus.state === "downloaded"
6622
+ ? "Restart when you like. Nothing in progress is lost."
6623
+ : updateStatus.state === "downloading" ||
6624
+ updateStatus.state === "available"
6625
+ ? "Downloading in the background, so keep recording"
6626
+ : updateStatus.state === "error"
6627
+ ? "Clips retries after launch and every hour"
6628
+ : "Checks after launch, every hour, and when you come back";
6199
6629
 
6200
6630
  function checkForDesktopUpdate() {
6201
6631
  retryUpdateCheck().catch((err) => {
@@ -6203,96 +6633,25 @@ function Setup({
6203
6633
  });
6204
6634
  }
6205
6635
 
6206
- if (surface !== "settings" && rewindConsentOpen) {
6636
+ if (surface === "memory") {
6207
6637
  return (
6208
- <div className="setup rewind-consent">
6209
- <div className="rewind-consent-mark">
6210
- <IconHistory size={24} stroke={1.8} />
6211
- </div>
6212
- <p className="rewind-kicker">Rewind</p>
6213
- <h2>Turn on Rewind?</h2>
6214
- <p className="rewind-consent-copy">
6215
- Rewind remembers recent moments so you can ask an agent about them or
6216
- add earlier context to a Clip.
6217
- </p>
6218
- <div className="rewind-consent-choices">
6219
- <button
6220
- type="button"
6221
- className={rewindConsentMode === "visuals" ? "selected" : ""}
6222
- onClick={() => setRewindConsentMode("visuals")}
6223
- >
6224
- <strong>Visuals</strong>
6225
- <span>Screen, app, and readable text</span>
6226
- </button>
6638
+ <div className="setup popover-view rewind-memory-surface">
6639
+ <div className="setup-header">
6227
6640
  <button
6228
6641
  type="button"
6229
- className={rewindConsentMode === "visuals-audio" ? "selected" : ""}
6230
- onClick={() => setRewindConsentMode("visuals-audio")}
6642
+ className="setup-back"
6643
+ onClick={onCancel}
6644
+ aria-label="Back"
6231
6645
  >
6232
- <strong>Visuals + audio</strong>
6233
- <span>Also remember your microphone and sound from the Mac</span>
6646
+ <IconArrowLeft size={18} stroke={1.75} />
6234
6647
  </button>
6235
- </div>
6236
- <div className="rewind-local-promise">
6237
- <IconShieldLock size={17} stroke={1.8} />
6238
- <p>
6239
- <strong>Raw Rewind recordings stay on this Mac.</strong> When you
6240
- ask an agent to search, Clips returns bounded matching context. A
6241
- media range uploads only through a private Clip handoff.
6242
- </p>
6243
- </div>
6244
- <button
6245
- type="button"
6246
- className="primary rewind-consent-primary"
6247
- disabled={screenMemoryConfigBusy}
6248
- onClick={async () => {
6249
- await setScreenMemoryConfig({
6250
- enabled: true,
6251
- paused: false,
6252
- captureMode: rewindConsentMode,
6253
- retentionHours: 8,
6254
- maxBytes: 20 * 1024 * 1024 * 1024,
6255
- reviewBeforeSending: true,
6256
- agentClipRetention: "forever",
6257
- });
6258
- setRewindConsentOpen(false);
6259
- }}
6260
- >
6261
- {screenMemoryConfigBusy ? "Turning on…" : "Turn on Rewind"}
6262
- </button>
6263
- <button
6264
- type="button"
6265
- className="rewind-quiet-button"
6266
- onClick={() => {
6267
- setRewindConsentOpen(false);
6268
- onCancel?.();
6269
- }}
6270
- >
6271
- Not now
6272
- </button>
6273
- </div>
6274
- );
6275
- }
6276
-
6277
- if (surface === "memory") {
6278
- return (
6279
- <div className="setup popover-view rewind-memory-surface">
6280
- <div className="setup-header">
6281
- <button
6282
- type="button"
6283
- className="setup-back"
6284
- onClick={onCancel}
6285
- aria-label="Back"
6286
- >
6287
- <IconArrowLeft size={18} stroke={1.75} />
6288
- </button>
6289
- <h2>Manual search</h2>
6648
+ <h2>Search memory</h2>
6290
6649
  </div>
6291
6650
  {screenMemory.enabled ? (
6292
6651
  <>
6293
6652
  <p className="rewind-surface-lede">
6294
- This local fallback helps you verify what Rewind remembers. For
6295
- everyday retrieval, ask Codex or your connected agent instead.
6653
+ Search what Rewind remembers on this device. For everyday recall,
6654
+ ask your connected agent.
6296
6655
  </p>
6297
6656
  <form
6298
6657
  className="rewind-search-row"
@@ -6399,8 +6758,8 @@ function Setup({
6399
6758
  <IconSearch size={19} stroke={1.7} />
6400
6759
  <strong>Find the source moment</strong>
6401
6760
  <p>
6402
- Results stay grounded in retained local evidence and always
6403
- lead back to Replay.
6761
+ Every result comes from what's stored on this device and links
6762
+ back to a replay.
6404
6763
  </p>
6405
6764
  </div>
6406
6765
  )}
@@ -6409,16 +6768,13 @@ function Setup({
6409
6768
  <div className="popover-empty-card rewind-memory-empty">
6410
6769
  <IconHistory size={20} stroke={1.7} />
6411
6770
  <strong>Rewind is off</strong>
6412
- <p>
6413
- Turn on a private rolling memory before there is anything to
6414
- search.
6415
- </p>
6416
- <button
6417
- type="button"
6418
- className="secondary"
6419
- onClick={() => setRewindConsentOpen(true)}
6420
- >
6421
- Turn on Rewind
6771
+ <p>Turn on Rewind to start keeping moments you can search.</p>
6772
+ {/* The consent dialog renders only on the settings surface, so
6773
+ the switch (and its consent) live there — this button walks
6774
+ the user to it rather than toggling a dialog that cannot
6775
+ mount here. */}
6776
+ <button type="button" className="secondary" onClick={onCancel}>
6777
+ Open Rewind settings
6422
6778
  </button>
6423
6779
  </div>
6424
6780
  )}
@@ -6426,1437 +6782,1099 @@ function Setup({
6426
6782
  );
6427
6783
  }
6428
6784
 
6429
- if (surface === "rewind") {
6430
- const captureDisabled = screenMemoryConfigBusy || captureControlsLocked;
6431
- const excludedAppsSummary =
6432
- excludedAppGroups.length === 0
6433
- ? "No apps excluded"
6434
- : `${excludedAppGroups.length} app${excludedAppGroups.length === 1 ? "" : "s"} excluded`;
6435
- const storageLabel = formatStorageBytes(screenMemory.maxBytes);
6436
-
6785
+ function renderGeneralSettings() {
6437
6786
  return (
6438
- <div className="setup popover-view rewind-settings-surface">
6439
- <div className="setup-header">
6440
- <button
6441
- type="button"
6442
- className="setup-back"
6443
- onClick={onCancel}
6444
- aria-label="Back"
6445
- >
6446
- <IconArrowLeft size={18} stroke={1.75} />
6447
- </button>
6448
- <h2>Rewind settings</h2>
6449
- </div>
6450
- <div className="rewind-settings-section">
6451
- <div className="rewind-setting-row">
6452
- <SettingLabel
6453
- label="Rewind"
6454
- hint={
6455
- !screenMemory.enabled
6456
- ? "Off"
6457
- : screenMemory.paused
6458
- ? "Paused · existing local memory is still available"
6459
- : "Remembering locally"
6787
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
6788
+ <SettingsGroup label="App">
6789
+ <SettingsRow
6790
+ label="Open at login"
6791
+ description="Open Clips automatically on login"
6792
+ control={
6793
+ <SettingsSwitch
6794
+ checked={launchAtLoginEnabled}
6795
+ onCheckedChange={setLaunchAtLoginEnabled}
6796
+ label="Open at login"
6797
+ />
6798
+ }
6799
+ />
6800
+ <SettingsRow
6801
+ label="Hide when inactive"
6802
+ description="Close the Clips window when switching to another application"
6803
+ control={
6804
+ <SettingsSwitch
6805
+ checked={autoHidePopoverEnabled}
6806
+ onCheckedChange={setAutoHidePopoverEnabled}
6807
+ label="Hide when inactive"
6808
+ />
6809
+ }
6810
+ />
6811
+ </SettingsGroup>
6812
+
6813
+ <SettingsGroup label="System access">
6814
+ {systemAccessRows.map((row) => (
6815
+ <SettingsRow
6816
+ key={row.key}
6817
+ label={row.label}
6818
+ description={row.description}
6819
+ control={
6820
+ row.granted === true ? (
6821
+ <span className="flex items-center gap-1.5 text-sm text-muted-foreground">
6822
+ <IconCircleCheck
6823
+ className="size-4"
6824
+ stroke={1.8}
6825
+ aria-hidden="true"
6826
+ />
6827
+ Granted
6828
+ </span>
6829
+ ) : canOpenPrivacySettings ? (
6830
+ <SettingsActionButton onClick={row.onGrant}>
6831
+ {row.granted === false ? "Grant" : "Open settings"}
6832
+ </SettingsActionButton>
6833
+ ) : (
6834
+ <span className="text-sm text-muted-foreground">
6835
+ System prompt
6836
+ </span>
6837
+ )
6460
6838
  }
6461
6839
  />
6462
- <Switch
6463
- on={screenMemory.enabled}
6464
- disabled={captureDisabled}
6465
- onChange={(enabled) => {
6466
- if (enabled) setRewindConsentOpen(true);
6467
- else
6468
- void setScreenMemoryConfig({ enabled: false, paused: false });
6469
- }}
6470
- label="Enable Rewind"
6471
- />
6472
- </div>
6473
- {captureControlsLocked ? (
6474
- <p className="rewind-capture-lock-note" role="status">
6475
- Capture settings unlock when this Clip ends.
6476
- </p>
6477
- ) : null}
6478
- {screenMemory.enabled ? (
6479
- <>
6480
- <div className="rewind-setting-row">
6481
- <SettingLabel
6482
- label="Remember"
6483
- hint="Choose whether audio joins the local screen memory."
6484
- />
6485
- <RewindChoicePopover
6486
- title="Remember"
6487
- value={screenMemory.captureMode}
6488
- options={[
6489
- { value: "visuals", label: "Visuals" },
6490
- { value: "visuals-audio", label: "Visuals + audio" },
6491
- ]}
6492
- disabled={captureDisabled}
6493
- onChange={(value) =>
6494
- void setScreenMemoryConfig({
6495
- captureMode: value as "visuals" | "visuals-audio",
6496
- })
6497
- }
6498
- />
6499
- </div>
6500
- <div className="rewind-setting-row">
6501
- <SettingLabel
6502
- label="Remember the last…"
6503
- hint="How long Rewind keeps recent moments available."
6504
- />
6505
- <RewindChoicePopover
6506
- title="Remember the last"
6507
- value={String(screenMemory.retentionHours)}
6508
- options={[
6509
- { value: "8", label: "8 hours" },
6510
- { value: "24", label: "24 hours" },
6511
- ]}
6512
- disabled={screenMemoryConfigBusy}
6513
- onChange={(value) =>
6840
+ ))}
6841
+ </SettingsGroup>
6842
+
6843
+ <SettingsGroup label="Updates">
6844
+ <SettingsRow
6845
+ label={updateRowLabel}
6846
+ description={updateRowDescription}
6847
+ control={renderUpdateControl()}
6848
+ >
6849
+ {updateStatus.state === "error" ? (
6850
+ <p className="text-xs text-destructive">{updateStatus.message}</p>
6851
+ ) : null}
6852
+ </SettingsRow>
6853
+ </SettingsGroup>
6854
+ </div>
6855
+ );
6856
+ }
6857
+
6858
+ function renderUpdateControl() {
6859
+ if (!updateChecksSupported) {
6860
+ return null;
6861
+ }
6862
+ if (updateReady) {
6863
+ return (
6864
+ <SettingsActionButton
6865
+ emphasis="primary"
6866
+ onClick={() => {
6867
+ installAndRestart().catch((err) => {
6868
+ console.error("[clips-updater] relaunch failed:", err);
6869
+ });
6870
+ }}
6871
+ >
6872
+ Restart to install
6873
+ </SettingsActionButton>
6874
+ );
6875
+ }
6876
+ if (updateBusy) {
6877
+ return (
6878
+ <IconRefresh
6879
+ size={15}
6880
+ stroke={1.9}
6881
+ className="update-spinner text-muted-foreground"
6882
+ aria-hidden="true"
6883
+ />
6884
+ );
6885
+ }
6886
+ return (
6887
+ <SettingsActionButton onClick={checkForDesktopUpdate}>
6888
+ Check now
6889
+ </SettingsActionButton>
6890
+ );
6891
+ }
6892
+
6893
+ function renderRecordingSettings() {
6894
+ return (
6895
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
6896
+ <SettingsGroup>
6897
+ <SettingsRow
6898
+ label="Voice cleanup"
6899
+ description="Reduces background noise and echo from your microphone"
6900
+ control={
6901
+ <SettingsSwitch
6902
+ checked={voiceCleanupEnabled}
6903
+ onCheckedChange={setVoiceCleanupEnabled}
6904
+ disabled={captureControlsLocked}
6905
+ label="Voice cleanup"
6906
+ />
6907
+ }
6908
+ />
6909
+ <SettingsRow
6910
+ label="Start/stop shortcut"
6911
+ description="Start and stop a recording from any app"
6912
+ control={
6913
+ <ShortcutRecorder
6914
+ value={recordCustomShortcut}
6915
+ placeholder="Set"
6916
+ onChange={onRecordCustomShortcutChange}
6917
+ />
6918
+ }
6919
+ />
6920
+ <SettingsRow
6921
+ label="Save to"
6922
+ description="Uploads recordings to your cloud library, or saves locally on device"
6923
+ control={
6924
+ <SettingsSelect
6925
+ ariaLabel="Save recordings to"
6926
+ value={localRecordingMode}
6927
+ onValueChange={(value) =>
6928
+ setLocalRecordingMode(value as LocalRecordingMode)
6929
+ }
6930
+ options={[
6931
+ { value: "off", label: "Cloud Clips" },
6932
+ { value: "composed", label: "This device, one video" },
6933
+ { value: "separate", label: "This device, screen + camera" },
6934
+ ]}
6935
+ />
6936
+ }
6937
+ />
6938
+ </SettingsGroup>
6939
+ </div>
6940
+ );
6941
+ }
6942
+
6943
+ /**
6944
+ * Rewind's settings, flat. Every one of these used to live behind a Manage
6945
+ * button inside a popover launched from another popover — three layers deep,
6946
+ * with tooltips escaping behind the window. They are ordinary rows now, in
6947
+ * the order a user reasons about them: does it run, what does it keep, who
6948
+ * may see it, where does it live.
6949
+ */
6950
+ function renderRewindSettings() {
6951
+ const rewindOn = screenMemory.enabled === true;
6952
+ return (
6953
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
6954
+ <SettingsGroup>
6955
+ {/* A confirmation, so a dialog rather than a takeover: the user is
6956
+ answering one question about the screen behind it, not moving to
6957
+ a new place. What it remembers is chosen afterwards, in the row
6958
+ below — asking before they have agreed puts the options in front
6959
+ of the decision.
6960
+
6961
+ The copy names what is captured and what can leave, and nothing
6962
+ else. Two claims are tempting and both are false: this buffer
6963
+ holds screen *video* (hence the GB disk limit below), not app
6964
+ and window notes; and it cannot promise "never leaves this Mac"
6965
+ because the agent-handoff path uploads an approved range. A
6966
+ consent screen is the one place a comforting simplification is
6967
+ indistinguishable from a lie. */}
6968
+ <UiAlertDialog
6969
+ open={rewindConsentOpen}
6970
+ onOpenChange={setRewindConsentOpen}
6971
+ >
6972
+ <UiAlertDialogContent className="max-w-md">
6973
+ <UiAlertDialogHeader>
6974
+ <UiAlertDialogTitle>Turn on Rewind?</UiAlertDialogTitle>
6975
+ <UiAlertDialogDescription>
6976
+ Clips will continuously record your screen and keep it on this
6977
+ device. Agents get text excerpts, and video is shared only
6978
+ when you approve it.
6979
+ </UiAlertDialogDescription>
6980
+ </UiAlertDialogHeader>
6981
+ <UiAlertDialogFooter>
6982
+ <UiAlertDialogCancel>Not now</UiAlertDialogCancel>
6983
+ <UiAlertDialogAction
6984
+ /* Also disabled until featureConfig loads:
6985
+ setScreenMemoryConfig no-ops without it, and the finally
6986
+ below would close the dialog with nothing written and
6987
+ nothing said. */
6988
+ disabled={screenMemoryConfigBusy || !featureConfig}
6989
+ onClick={(event) => {
6990
+ // Radix closes on click by default, which would unmount
6991
+ // the busy label before the invoke even starts. Hold the
6992
+ // dialog open until the write settles either way — the
6993
+ // result (switch state, error line) renders in the row
6994
+ // behind it. Deliberately no captureMode here: re-enabling
6995
+ // must not reset a stored "Screen + audio" choice.
6996
+ event.preventDefault();
6514
6997
  void setScreenMemoryConfig({
6515
- retentionHours: Number(value),
6516
- })
6517
- }
6518
- />
6519
- </div>
6520
- <div className="rewind-setting-row">
6521
- <SettingLabel
6522
- label="Keep up to"
6523
- hint="Older moments are removed when this local limit is reached."
6524
- />
6525
- <RewindChoicePopover
6526
- title="Keep up to"
6527
- value={String(screenMemory.maxBytes)}
6528
- options={[
6529
- {
6530
- value: String(5 * 1024 * 1024 * 1024),
6531
- label: "5 GB",
6532
- },
6533
- {
6534
- value: String(20 * 1024 * 1024 * 1024),
6535
- label: "20 GB",
6536
- },
6537
- {
6538
- value: String(50 * 1024 * 1024 * 1024),
6539
- label: "50 GB",
6540
- },
6541
- ]}
6542
- disabled={screenMemoryConfigBusy}
6543
- onChange={(value) =>
6544
- void setScreenMemoryConfig({ maxBytes: Number(value) })
6998
+ enabled: true,
6999
+ paused: false,
7000
+ }).finally(() => setRewindConsentOpen(false));
7001
+ }}
7002
+ >
7003
+ {screenMemoryConfigBusy ? "Turning on…" : "Turn on Rewind"}
7004
+ </UiAlertDialogAction>
7005
+ </UiAlertDialogFooter>
7006
+ </UiAlertDialogContent>
7007
+ </UiAlertDialog>
7008
+ <SettingsRow
7009
+ label="Rewind"
7010
+ description="Keeps a rolling record of your recent screen on this device"
7011
+ control={
7012
+ <SettingsSwitch
7013
+ checked={rewindOn}
7014
+ onCheckedChange={(next) => {
7015
+ // Turning it on starts continuously capturing the screen, so
7016
+ // it routes through consent rather than flipping silently.
7017
+ // Turning it off needs no confirmation — stopping is safe.
7018
+ if (next) {
7019
+ setRewindConsentOpen(true);
7020
+ return;
6545
7021
  }
6546
- />
6547
- </div>
6548
- <div className="rewind-settings-status">
6549
- <span
6550
- className={`rewind-home-dot ${rewindStatusPresentation.isLive ? "is-live" : ""}`}
6551
- />
6552
- <span className="rewind-settings-status-copy">
6553
- <strong>{rewindStatusPresentation.title}</strong>
6554
- <span>
6555
- {rewindStatusPresentation.kind === "recording" &&
6556
- !rewindStatusPresentation.hasError
6557
- ? `${screenMemorySegments.length} retained segment${screenMemorySegments.length === 1 ? "" : "s"} · ${formatStorageBytes(screenMemoryTotalBytes)}`
6558
- : rewindStatusPresentation.detail}
6559
- </span>
6560
- </span>
6561
- </div>
7022
+ void setScreenMemoryConfig({ enabled: false });
7023
+ }}
7024
+ /* Rust rejects Rewind capture changes mid-Clip
7025
+ (config.rs `set_feature_config` guard); disabling here
7026
+ turns that hard error into a visible lock. */
7027
+ disabled={screenMemoryConfigBusy || captureControlsLocked}
7028
+ label="Rewind"
7029
+ />
7030
+ }
7031
+ >
7032
+ {screenMemoryMessage ? (
7033
+ <p
7034
+ className={
7035
+ screenMemoryMessage.kind === "ok"
7036
+ ? "text-xs text-success"
7037
+ : "text-xs text-destructive"
7038
+ }
7039
+ >
7040
+ {screenMemoryMessage.text}
7041
+ </p>
7042
+ ) : rewindOn && rewindStatusPresentation.hasError ? (
7043
+ <p className="text-xs text-destructive">
7044
+ {rewindStatusPresentation.detail}
7045
+ </p>
7046
+ ) : null}
7047
+ </SettingsRow>
7048
+ {rewindOn ? (
7049
+ <>
7050
+ <SettingsRow
7051
+ label="Remember"
7052
+ description="Choose what Rewind captures"
7053
+ control={
7054
+ <SettingsSelect
7055
+ ariaLabel="What Rewind remembers"
7056
+ value={screenMemory.captureMode ?? "visuals"}
7057
+ onValueChange={(value) => {
7058
+ void setScreenMemoryConfig({
7059
+ captureMode: value as RewindCaptureMode,
7060
+ });
7061
+ }}
7062
+ disabled={screenMemoryConfigBusy || captureControlsLocked}
7063
+ options={[
7064
+ { value: "visuals", label: "Screen only" },
7065
+ { value: "visuals-audio", label: "Screen + audio" },
7066
+ ]}
7067
+ />
7068
+ }
7069
+ />
7070
+ <SettingsRow
7071
+ label="Time limit"
7072
+ description="Choose how long Rewind keeps your screen history"
7073
+ control={
7074
+ <SettingsSelect
7075
+ ariaLabel="Rewind time limit"
7076
+ placeholder={`${screenMemory.retentionHours} hours`}
7077
+ value={String(screenMemory.retentionHours)}
7078
+ onValueChange={(value) => {
7079
+ void setScreenMemoryConfig({
7080
+ retentionHours: Number(value),
7081
+ });
7082
+ }}
7083
+ disabled={screenMemoryConfigBusy}
7084
+ options={[
7085
+ { value: "8", label: "8 hours" },
7086
+ { value: "24", label: "24 hours" },
7087
+ ]}
7088
+ />
7089
+ }
7090
+ />
7091
+ <SettingsRow
7092
+ label="Storage limit"
7093
+ description="Choose how much space Rewind can use on this device"
7094
+ control={
7095
+ <SettingsSelect
7096
+ ariaLabel="Rewind storage limit"
7097
+ placeholder={formatStorageBytes(screenMemory.maxBytes)}
7098
+ value={String(screenMemory.maxBytes)}
7099
+ onValueChange={(value) => {
7100
+ void setScreenMemoryConfig({ maxBytes: Number(value) });
7101
+ }}
7102
+ disabled={screenMemoryConfigBusy}
7103
+ options={[
7104
+ { value: String(5 * 1024 * 1024 * 1024), label: "5 GB" },
7105
+ {
7106
+ value: String(20 * 1024 * 1024 * 1024),
7107
+ label: "20 GB",
7108
+ },
7109
+ {
7110
+ value: String(50 * 1024 * 1024 * 1024),
7111
+ label: "50 GB",
7112
+ },
7113
+ ]}
7114
+ />
7115
+ }
7116
+ />
6562
7117
  </>
6563
7118
  ) : null}
6564
- </div>
6565
- {screenMemory.enabled ? (
7119
+ </SettingsGroup>
7120
+
7121
+ {rewindOn ? (
6566
7122
  <>
6567
- <div className="setup-section-heading">Privacy</div>
6568
- <div className="rewind-settings-section">
6569
- <div className="rewind-setting-row rewind-setting-row--action">
6570
- <div className="rewind-setting-copy">
6571
- <SettingLabel
6572
- label="Excluded apps"
6573
- hint="Rewind never remembers these applications."
6574
- />
6575
- <span>{excludedAppsSummary}</span>
6576
- </div>
6577
- <DesktopSettingsPopover
6578
- title="Excluded apps"
6579
- open={rewindPrivacyOpen}
6580
- onOpenChange={setRewindPrivacyOpen}
6581
- side="bottom"
6582
- contentClassName="rewind-popover-content"
6583
- trigger={
6584
- <button
6585
- type="button"
6586
- className="secondary rewind-manage-button"
6587
- disabled={screenMemoryConfigBusy}
6588
- >
6589
- Manage
6590
- </button>
6591
- }
6592
- >
6593
- <div className="rewind-popover-stack">
6594
- <p className="rewind-popover-hint">
6595
- These applications are always excluded from local Rewind
6596
- memory.
6597
- </p>
6598
- <button
6599
- type="button"
6600
- className="secondary"
6601
- disabled={excludedAppsBusy || screenMemoryConfigBusy}
6602
- onClick={() => void chooseExcludedApplications()}
6603
- >
6604
- <IconFolderOpen size={14} stroke={1.9} />
6605
- {excludedAppsBusy ? "Choosing…" : "Add applications…"}
6606
- </button>
6607
- {excludedAppGroups.length > 0 ? (
6608
- <div className="rewind-excluded-app-list">
6609
- {excludedAppGroups.map((app) => (
6610
- <div
6611
- className={`rewind-excluded-app ${app.installed ? "" : "is-missing"}`}
6612
- key={app.bundleId}
6613
- >
6614
- <div
6615
- className="rewind-excluded-app-mark"
6616
- aria-hidden="true"
6617
- >
6618
- {app.name.slice(0, 1).toUpperCase()}
6619
- </div>
6620
- <div className="rewind-excluded-app-copy">
6621
- <strong>{app.name}</strong>
6622
- <span>
6623
- {app.installed
6624
- ? "Excluded"
6625
- : "Not currently installed · still excluded"}
6626
- </span>
6627
- </div>
6628
- <button
6629
- type="button"
6630
- className="rewind-excluded-app-remove"
6631
- aria-label={`Remove ${app.name}`}
6632
- disabled={screenMemoryConfigBusy}
6633
- onClick={() =>
6634
- removeExcludedApplications(app.bundleIds)
6635
- }
6636
- >
6637
- <IconX size={14} stroke={2} />
6638
- </button>
6639
- </div>
6640
- ))}
6641
- </div>
6642
- ) : (
6643
- <p className="setup-hint">No apps are excluded.</p>
6644
- )}
6645
- </div>
6646
- </DesktopSettingsPopover>
6647
- </div>
6648
- </div>
6649
- <div className="setup-section-heading">Agent</div>
6650
- <div className="rewind-settings-section">
6651
- <div className="rewind-setting-row rewind-setting-row--action">
6652
- <div className="rewind-setting-copy">
6653
- <SettingLabel
6654
- label="Agent setup"
6655
- hint="Install the reusable Rewind instructions for a local agent."
6656
- />
6657
- <span>Prompt and local connection</span>
6658
- </div>
6659
- <DesktopSettingsPopover
6660
- title="Agent setup"
6661
- open={rewindAgentSetupOpen}
6662
- onOpenChange={setRewindAgentSetupOpen}
6663
- side="bottom"
6664
- contentClassName="rewind-popover-content"
6665
- trigger={
6666
- <button
6667
- type="button"
6668
- className="secondary rewind-manage-button"
6669
- >
6670
- Manage
6671
- </button>
6672
- }
6673
- >
6674
- <div className="rewind-popover-stack">
6675
- <div className="rewind-agent-guide">
6676
- <div className="rewind-agent-guide-icon">
6677
- <IconHistory size={17} stroke={1.8} />
6678
- </div>
6679
- <div>
6680
- <strong>Set up your agent once</strong>
6681
- <p>
6682
- Copy the setup prompt into a compatible agent. It
6683
- installs Rewind's instructions and repairs the local
6684
- connection.
6685
- </p>
6686
- </div>
6687
- </div>
6688
- <div className="setup-button-row">
6689
- <button
6690
- type="button"
6691
- className="secondary"
6692
- onClick={onCopyRewindAgentPrompt}
6693
- >
6694
- <IconCopy size={14} stroke={1.9} />
6695
- {rewindAgentPromptCopied
6696
- ? "Setup prompt copied"
6697
- : "Copy setup prompt"}
6698
- </button>
6699
- <button
6700
- type="button"
6701
- className="secondary"
6702
- onClick={onOpenRewindDocs}
7123
+ <SettingsGroup label="Privacy">
7124
+ <SettingsRow
7125
+ label="Excluded apps"
7126
+ description={"Apps Rewind never captures"}
7127
+ control={
7128
+ <SettingsActionButton
7129
+ onClick={() => void chooseExcludedApplications()}
7130
+ disabled={excludedAppsBusy}
7131
+ >
7132
+ Choose apps
7133
+ </SettingsActionButton>
7134
+ }
7135
+ >
7136
+ {excludedAppGroups.length > 0 ? (
7137
+ <div className="grid gap-1">
7138
+ {excludedAppGroups.map((app) => (
7139
+ <div
7140
+ key={app.bundleIds.join(",")}
7141
+ className="flex items-center justify-between gap-2"
6703
7142
  >
6704
- <IconExternalLink size={14} stroke={1.9} />
6705
- Learn about Rewind
6706
- </button>
6707
- </div>
6708
- <details className="setup-advanced rewind-agent-repair">
6709
- <summary className="setup-advanced-summary">
6710
- Repair an agent connection
6711
- </summary>
6712
- <div className="setup-advanced-body">
6713
- <p className="setup-hint">
6714
- Use this only when the copied setup prompt cannot
6715
- repair a known client.
6716
- </p>
6717
- <div className="rewind-memory-actions">
6718
- <button
6719
- type="button"
6720
- className="secondary"
6721
- disabled={agentConnectionBusy !== null}
6722
- onClick={() =>
6723
- void installRewindAgentConnection("codex")
6724
- }
6725
- >
6726
- {agentConnectionBusy === "codex"
6727
- ? "Repairing…"
6728
- : "Repair Codex connection"}
6729
- </button>
6730
- <button
6731
- type="button"
6732
- className="secondary"
6733
- disabled={agentConnectionBusy !== null}
6734
- onClick={() =>
6735
- void installRewindAgentConnection("claude-code")
6736
- }
6737
- >
6738
- {agentConnectionBusy === "claude-code"
6739
- ? "Repairing…"
6740
- : "Repair Claude Code connection"}
6741
- </button>
6742
- </div>
6743
- {agentConnectionMessage ? (
6744
- <p
6745
- className={
6746
- agentConnectionMessage.kind === "error"
6747
- ? "setup-error"
6748
- : "setup-hint"
6749
- }
6750
- role="status"
6751
- >
6752
- {agentConnectionMessage.text}
6753
- </p>
6754
- ) : null}
7143
+ <span className="truncate">{app.name}</span>
7144
+ <SettingsActionButton
7145
+ emphasis="quiet"
7146
+ onClick={() =>
7147
+ removeExcludedApplications(app.bundleIds)
7148
+ }
7149
+ >
7150
+ Remove
7151
+ </SettingsActionButton>
6755
7152
  </div>
6756
- </details>
7153
+ ))}
6757
7154
  </div>
6758
- </DesktopSettingsPopover>
6759
- </div>
6760
- <div className="rewind-setting-row rewind-setting-row--action">
6761
- <div className="rewind-setting-copy">
6762
- <SettingLabel
6763
- label="Before sending"
6764
- hint="Control how an agent-created private Clip is prepared."
7155
+ ) : null}
7156
+ </SettingsRow>
7157
+ <SettingsRow
7158
+ label="Review before sending"
7159
+ description="Approve each visual or audio range before an agent receives it"
7160
+ control={
7161
+ <SettingsSwitch
7162
+ checked={screenMemory.reviewBeforeSending !== false}
7163
+ onCheckedChange={(next) => {
7164
+ void setScreenMemoryConfig({
7165
+ reviewBeforeSending: next,
7166
+ });
7167
+ }}
7168
+ disabled={screenMemoryConfigBusy}
7169
+ label="Review before sending"
6765
7170
  />
6766
- <span>
6767
- {screenMemory.reviewBeforeSending
6768
- ? "Review media before sending"
6769
- : "Send private Clips immediately"}
6770
- </span>
6771
- </div>
6772
- <DesktopSettingsPopover
6773
- title="Before sending"
6774
- open={rewindHandoffOpen}
6775
- onOpenChange={setRewindHandoffOpen}
6776
- side="bottom"
6777
- contentClassName="rewind-popover-content"
6778
- trigger={
6779
- <button
6780
- type="button"
6781
- className="secondary rewind-manage-button"
6782
- >
6783
- Manage
6784
- </button>
6785
- }
6786
- >
6787
- <div className="rewind-popover-stack">
6788
- <div className="rewind-popover-setting-row">
6789
- <div className="rewind-setting-copy">
6790
- <strong>Review before sending</strong>
6791
- <span>
6792
- Preview and trim a visual or audio range before it
6793
- becomes a private Clip.
7171
+ }
7172
+ />
7173
+ <SettingsRow
7174
+ label="Preview before sending"
7175
+ description="Open the range locally so you see exactly what is sent"
7176
+ control={
7177
+ <SettingsSwitch
7178
+ checked={screenMemory.autoPreviewBeforeSending === true}
7179
+ onCheckedChange={(next) => {
7180
+ void setScreenMemoryConfig({
7181
+ autoPreviewBeforeSending: next,
7182
+ });
7183
+ }}
7184
+ disabled={screenMemoryConfigBusy}
7185
+ label="Preview before sending"
7186
+ />
7187
+ }
7188
+ />
7189
+ <SettingsRow
7190
+ label="Keep agent Clips"
7191
+ description="Choose how long Clips made for agents stay in your library"
7192
+ control={
7193
+ <SettingsSelect
7194
+ ariaLabel="How long agent-created Clips are kept"
7195
+ value={screenMemory.agentClipRetention}
7196
+ onValueChange={(value) => {
7197
+ void setScreenMemoryConfig({
7198
+ agentClipRetention:
7199
+ value as ScreenMemoryStatus["config"]["agentClipRetention"],
7200
+ });
7201
+ }}
7202
+ disabled={screenMemoryConfigBusy}
7203
+ options={[
7204
+ { value: "forever", label: "Forever" },
7205
+ { value: "24-hours", label: "24 hours" },
7206
+ { value: "7-days", label: "7 days" },
7207
+ { value: "30-days", label: "30 days" },
7208
+ ]}
7209
+ />
7210
+ }
7211
+ />
7212
+ <SettingsRow
7213
+ label="Agent activity"
7214
+ description="Each time an agent searched this device's memory, newest first"
7215
+ >
7216
+ {rewindEgressEvents.length === 0 ? (
7217
+ <p>No agent has searched it yet.</p>
7218
+ ) : (
7219
+ <div className="grid gap-1">
7220
+ {rewindEgressEvents.slice(0, 10).map((event) => (
7221
+ <div
7222
+ key={`${event.requestId}-${event.state}`}
7223
+ className="flex items-center justify-between gap-2"
7224
+ >
7225
+ <span className="truncate">
7226
+ {new Date(event.occurredAt).toLocaleString()}
6794
7227
  </span>
6795
- </div>
6796
- <Switch
6797
- on={screenMemory.reviewBeforeSending}
6798
- disabled={screenMemoryConfigBusy}
6799
- onChange={(enabled) =>
6800
- void setScreenMemoryConfig({
6801
- reviewBeforeSending: enabled,
6802
- })
6803
- }
6804
- label="Review visual and audio ranges before sending"
6805
- />
6806
- </div>
6807
- <div className="rewind-popover-setting-row">
6808
- <div className="rewind-setting-copy">
6809
- <strong>Open local preview automatically</strong>
6810
- <span>
6811
- Prepare the selected range in QuickTime when an agent
6812
- asks for it.
7228
+ <span className="shrink-0">
7229
+ {event.state} ·{" "}
7230
+ {`${event.evidenceCount} item${event.evidenceCount === 1 ? "" : "s"}`}
6813
7231
  </span>
6814
7232
  </div>
6815
- <Switch
6816
- on={screenMemory.autoPreviewBeforeSending}
6817
- disabled={
6818
- screenMemoryConfigBusy ||
6819
- !screenMemory.reviewBeforeSending
6820
- }
6821
- onChange={(enabled) =>
6822
- void setScreenMemoryConfig({
6823
- autoPreviewBeforeSending: enabled,
6824
- })
6825
- }
6826
- label="Open a local preview automatically before sending"
6827
- />
6828
- </div>
6829
- <div className="rewind-popover-setting-row">
6830
- <div className="rewind-setting-copy">
6831
- <strong>Agent-created Clip retention</strong>
6832
- <span>Applies to future private Clips.</span>
6833
- </div>
6834
- <RewindChoicePopover
6835
- title="Agent-created Clip retention"
6836
- value={screenMemory.agentClipRetention}
6837
- options={[
6838
- { value: "forever", label: "Keep forever" },
6839
- {
6840
- value: "24-hours",
6841
- label: "Delete after 24 hours",
6842
- },
6843
- { value: "7-days", label: "Delete after 7 days" },
6844
- {
6845
- value: "30-days",
6846
- label: "Delete after 30 days",
6847
- },
6848
- ]}
6849
- disabled={screenMemoryConfigBusy}
6850
- onChange={(value) =>
6851
- void setScreenMemoryConfig({
6852
- agentClipRetention: value as
6853
- | "forever"
6854
- | "24-hours"
6855
- | "7-days"
6856
- | "30-days",
6857
- })
6858
- }
6859
- />
6860
- </div>
6861
- <p className="rewind-boundary-note">
6862
- Raw Rewind files stay local. If review is off, an
6863
- agent-requested media range becomes a private Clip
6864
- immediately.
6865
- </p>
7233
+ ))}
6866
7234
  </div>
6867
- </DesktopSettingsPopover>
6868
- </div>
6869
- <div className="rewind-setting-row rewind-setting-row--action">
6870
- <div className="rewind-setting-copy">
6871
- <SettingLabel
6872
- label="Agent activity"
6873
- hint="Review when an agent searched bounded local evidence."
6874
- />
6875
- <span>
6876
- {rewindEgressEvents.length === 0
6877
- ? "No matching-text requests yet"
6878
- : `${rewindEgressEvents.length} recent request${rewindEgressEvents.length === 1 ? "" : "s"}`}
6879
- </span>
6880
- </div>
6881
- <DesktopSettingsPopover
6882
- title="Agent activity"
6883
- open={rewindActivityOpen}
6884
- onOpenChange={(open) => {
6885
- setRewindActivityOpen(open);
6886
- if (open) refreshRewindEgressLog();
6887
- }}
6888
- side="bottom"
6889
- contentClassName="rewind-popover-content"
6890
- trigger={
6891
- <button
6892
- type="button"
6893
- className="secondary rewind-manage-button"
7235
+ )}
7236
+ </SettingsRow>
7237
+ </SettingsGroup>
7238
+
7239
+ <SettingsGroup label="Agents">
7240
+ <SettingsRow
7241
+ label="Setup prompt"
7242
+ description="Paste it into an agent once to install Rewind's instructions"
7243
+ control={
7244
+ <>
7245
+ <SettingsActionButton
7246
+ emphasis="quiet"
7247
+ onClick={onOpenRewindDocs}
6894
7248
  >
6895
- View
6896
- </button>
6897
- }
6898
- >
6899
- <div className="rewind-popover-stack">
6900
- <p className="rewind-popover-hint">
6901
- Raw Rewind media remains on this Mac unless you explicitly
6902
- create a private Clip.
6903
- </p>
6904
- {rewindEgressEvents.length === 0 ? (
6905
- <p className="setup-hint">
6906
- No matching-text requests yet.
6907
- </p>
6908
- ) : (
6909
- rewindEgressEvents.slice(0, 10).map((event) => (
6910
- <p
6911
- className="setup-hint"
6912
- key={`${event.requestId}-${event.state}`}
6913
- >
6914
- <strong>
6915
- {new Date(event.occurredAt).toLocaleString()}
6916
- </strong>
6917
- {` · ${event.state} · ${event.evidenceCount} item${event.evidenceCount === 1 ? "" : "s"}`}
6918
- </p>
6919
- ))
6920
- )}
6921
- </div>
6922
- </DesktopSettingsPopover>
6923
- </div>
6924
- <div className="rewind-setting-row rewind-setting-row--action">
6925
- <div className="rewind-setting-copy">
6926
- <SettingLabel
6927
- label="Manual search"
6928
- hint="Search retained local evidence without asking an agent."
6929
- />
6930
- <span>Find and replay a source moment</span>
6931
- </div>
6932
- <DesktopSettingsPopover
6933
- title="Manual search"
6934
- open={rewindSearchOpen}
6935
- onOpenChange={setRewindSearchOpen}
6936
- side="bottom"
6937
- contentClassName="rewind-memory-launcher"
6938
- trigger={
6939
- <button
6940
- type="button"
6941
- className="secondary rewind-manage-button"
7249
+ Learn more
7250
+ </SettingsActionButton>
7251
+ <SettingsActionButton onClick={onCopyRewindAgentPrompt}>
7252
+ {rewindAgentPromptCopied ? "Copied" : "Copy"}
7253
+ </SettingsActionButton>
7254
+ </>
7255
+ }
7256
+ />
7257
+ <SettingsRow
7258
+ label="Connect an agent"
7259
+ description="Gives a local agent access to this device's Rewind memory"
7260
+ control={
7261
+ <>
7262
+ <SettingsActionButton
7263
+ onClick={() => void installRewindAgentConnection("codex")}
7264
+ disabled={agentConnectionBusy !== null}
6942
7265
  >
6943
- Search
6944
- </button>
6945
- }
6946
- >
6947
- <Setup
6948
- surface="memory"
6949
- recordingActive={recordingActive}
6950
- promptRewindEnable={false}
6951
- initial={initial}
6952
- serverUrl={serverUrl}
6953
- signedInAs={signedInAs}
6954
- voiceShortcut={voiceShortcut}
6955
- voiceCustomShortcut={voiceCustomShortcut}
6956
- popoverCustomShortcut={popoverCustomShortcut}
6957
- recordCustomShortcut={recordCustomShortcut}
6958
- voiceMode={voiceMode}
6959
- voiceProvider={voiceProvider}
6960
- voiceInstructions={voiceInstructions}
6961
- shortcutRegistrationError={shortcutRegistrationError}
6962
- onVoiceShortcutChange={onVoiceShortcutChange}
6963
- onVoiceCustomShortcutChange={onVoiceCustomShortcutChange}
6964
- onPopoverCustomShortcutChange={
6965
- onPopoverCustomShortcutChange
7266
+ Codex
7267
+ </SettingsActionButton>
7268
+ <SettingsActionButton
7269
+ onClick={() =>
7270
+ void installRewindAgentConnection("claude-code")
7271
+ }
7272
+ disabled={agentConnectionBusy !== null}
7273
+ >
7274
+ Claude Code
7275
+ </SettingsActionButton>
7276
+ </>
7277
+ }
7278
+ >
7279
+ {agentConnectionMessage ? (
7280
+ <p
7281
+ className={
7282
+ agentConnectionMessage.kind === "ok"
7283
+ ? "text-xs text-success"
7284
+ : "text-xs text-destructive"
6966
7285
  }
6967
- onRecordCustomShortcutChange={onRecordCustomShortcutChange}
6968
- onVoiceModeChange={onVoiceModeChange}
6969
- onVoiceProviderChange={onVoiceProviderChange}
6970
- onVoiceInstructionsChange={onVoiceInstructionsChange}
6971
- onConnect={onConnect}
6972
- rewindAgentPromptCopied={rewindAgentPromptCopied}
6973
- onCopyRewindAgentPrompt={onCopyRewindAgentPrompt}
6974
- onOpenRewindDocs={onOpenRewindDocs}
6975
- onCancel={() => setRewindSearchOpen(false)}
6976
- />
6977
- </DesktopSettingsPopover>
6978
- </div>
6979
- <div className="rewind-setting-row rewind-setting-row--action">
6980
- <div className="rewind-setting-copy">
6981
- <SettingLabel
6982
- label="Local memory"
6983
- hint="Save, open, or erase the private Rewind archive on this Mac."
6984
- />
6985
- <span>
6986
- {screenMemorySegments.length} retained segment
6987
- {screenMemorySegments.length === 1 ? "" : "s"} ·{" "}
6988
- {storageLabel}
6989
- </span>
6990
- </div>
6991
- <DesktopSettingsPopover
6992
- title="Local memory"
6993
- open={rewindMemoryOpen}
6994
- onOpenChange={setRewindMemoryOpen}
6995
- side="bottom"
6996
- contentClassName="rewind-popover-content"
6997
- trigger={
6998
- <button
6999
- type="button"
7000
- className="secondary rewind-manage-button"
7286
+ >
7287
+ {agentConnectionMessage.text}
7288
+ </p>
7289
+ ) : null}
7290
+ </SettingsRow>
7291
+ </SettingsGroup>
7292
+
7293
+ <SettingsGroup label="Storage">
7294
+ <SettingsRow
7295
+ label="Search memory"
7296
+ description="Find and replay a recent moment yourself"
7297
+ control={
7298
+ <SettingsActionButton onClick={onOpenMemory}>
7299
+ Search
7300
+ </SettingsActionButton>
7301
+ }
7302
+ />
7303
+ <SettingsRow
7304
+ label="Save last 5 minutes"
7305
+ description="Exports recent memory as video files on this device. Nothing is uploaded."
7306
+ control={
7307
+ <SettingsActionButton
7308
+ onClick={() => void exportScreenMemoryRecent()}
7309
+ disabled={screenMemoryBusy}
7310
+ >
7311
+ Save
7312
+ </SettingsActionButton>
7313
+ }
7314
+ />
7315
+ <SettingsRow
7316
+ label="On this device"
7317
+ description={`${screenMemorySegments.length} ${screenMemorySegments.length === 1 ? "segment" : "segments"} · ${formatStorageBytes(screenMemoryTotalBytes)}`}
7318
+ control={
7319
+ <>
7320
+ <SettingsActionButton onClick={openScreenMemoryFolder}>
7321
+ Open folder
7322
+ </SettingsActionButton>
7323
+ <SettingsActionButton
7324
+ emphasis="destructive"
7325
+ onClick={() => void clearScreenMemory()}
7326
+ disabled={screenMemoryBusy}
7001
7327
  >
7002
- Manage
7003
- </button>
7004
- }
7005
- >
7006
- <div className="rewind-popover-stack">
7007
- <div className="rewind-memory-actions">
7008
- <div className="rewind-memory-action">
7009
- <div className="rewind-memory-action-copy">
7010
- <strong>Save a local Clip</strong>
7011
- <p>
7012
- Export the previous five minutes as a video on this
7013
- Mac. Nothing is uploaded.
7014
- </p>
7015
- {screenMemoryExportResult ? (
7016
- <div
7017
- className="rewind-inline-receipt"
7018
- role="status"
7019
- >
7020
- <span>Saved locally</span>
7021
- <button
7022
- type="button"
7023
- className="rewind-text-button"
7024
- onClick={() =>
7025
- void invoke("open_local_recording_folder", {
7026
- path: screenMemoryExportResult.folderPath,
7027
- })
7028
- }
7029
- >
7030
- Show in Finder
7031
- </button>
7032
- </div>
7033
- ) : null}
7034
- </div>
7035
- <button
7036
- type="button"
7037
- className="secondary"
7038
- disabled={
7039
- screenMemoryBusy ||
7040
- screenMemorySegments.length === 0
7041
- }
7042
- onClick={exportScreenMemoryRecent}
7043
- >
7044
- <IconDownload size={15} stroke={1.9} /> Save previous
7045
- 5 minutes
7046
- </button>
7047
- </div>
7048
- <div className="rewind-memory-action">
7049
- <div>
7050
- <strong>View Rewind files</strong>
7051
- <p>Open the private folder for local memory.</p>
7052
- </div>
7053
- <button
7054
- type="button"
7055
- className="secondary"
7056
- onClick={openScreenMemoryFolder}
7057
- >
7058
- <IconFolderOpen size={15} stroke={1.9} /> Open folder
7059
- </button>
7060
- </div>
7061
- <div className="rewind-memory-action is-danger">
7062
- <div>
7063
- <strong>Erase Rewind memory</strong>
7064
- <p>Permanently delete retained media and indexes.</p>
7065
- </div>
7066
- <button
7067
- type="button"
7068
- className="secondary rewind-danger-button"
7069
- disabled={
7070
- screenMemoryBusy ||
7071
- screenMemorySegments.length === 0
7072
- }
7073
- onClick={clearScreenMemory}
7074
- >
7075
- <IconTrash size={15} stroke={1.9} /> Erase all memory…
7076
- </button>
7077
- </div>
7078
- </div>
7079
- {screenMemoryMessage ? (
7080
- <p
7081
- className={
7082
- screenMemoryMessage.kind === "ok"
7083
- ? "setup-success"
7084
- : "setup-warning"
7085
- }
7086
- >
7087
- {screenMemoryMessage.text}
7088
- </p>
7089
- ) : null}
7090
- </div>
7091
- </DesktopSettingsPopover>
7092
- </div>
7093
- </div>
7328
+ Delete all
7329
+ </SettingsActionButton>
7330
+ </>
7331
+ }
7332
+ />
7333
+ </SettingsGroup>
7094
7334
  </>
7095
- ) : (
7096
- <div className="popover-empty-card rewind-memory-empty">
7097
- <IconHistory size={20} stroke={1.7} />
7098
- <strong>Nothing is being remembered</strong>
7099
- <p>
7100
- Turning Rewind on begins a private rolling memory after you choose
7101
- what it may remember.
7102
- </p>
7103
- <button
7104
- type="button"
7105
- className="secondary"
7106
- onClick={() => setRewindConsentOpen(true)}
7107
- >
7108
- Choose what to remember
7109
- </button>
7110
- </div>
7111
- )}
7112
- {screenMemoryMessage && screenMemory.enabled ? (
7113
- <p
7114
- className={
7115
- screenMemoryMessage.kind === "ok"
7116
- ? "setup-success"
7117
- : "setup-warning"
7118
- }
7119
- >
7120
- {screenMemoryMessage.text}
7121
- </p>
7122
7335
  ) : null}
7123
7336
  </div>
7124
7337
  );
7125
7338
  }
7126
7339
 
7127
- function renderGeneralSettings() {
7340
+ function renderAdvancedSettings() {
7128
7341
  return (
7129
- <div className="settings-tab-content">
7130
- <DesktopSettingsGroup
7131
- title="Connection"
7132
- description="Choose the Clips backend used by this desktop recorder."
7133
- >
7134
- <DesktopSettingsRow
7342
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
7343
+ <SettingsGroup label="Connection">
7344
+ <SettingsRow
7135
7345
  label="Clips server URL"
7136
- description="The backend this tray app connects to."
7346
+ description="The server Clips signs in and uploads to"
7137
7347
  control={
7138
- <div className="settings-control-group settings-control-group--wide">
7139
- <input
7140
- id="clips-url"
7141
- className="settings-url-input"
7142
- type="url"
7143
- value={url}
7144
- onChange={(event) => setUrl(event.target.value)}
7145
- placeholder="http://localhost:8080"
7146
- />
7147
- <button
7148
- className="secondary"
7149
- type="button"
7150
- onClick={handleConnect}
7151
- >
7152
- Connect
7153
- </button>
7154
- </div>
7348
+ <SettingsPopover
7349
+ title="Clips server URL"
7350
+ open={serverUrlOpen}
7351
+ onOpenChange={setServerUrlOpen}
7352
+ trigger={<SettingsValueTrigger mono value={serverHostLabel} />}
7353
+ >
7354
+ <div className="grid gap-1.5">
7355
+ <div className="flex items-center gap-2">
7356
+ <Input
7357
+ id="clips-url"
7358
+ type="url"
7359
+ value={url}
7360
+ aria-invalid={serverUrlError ? true : undefined}
7361
+ onChange={(event) => {
7362
+ setUrl(event.target.value);
7363
+ setServerUrlError(null);
7364
+ }}
7365
+ onKeyDown={(event) => {
7366
+ if (event.key === "Enter") {
7367
+ event.preventDefault();
7368
+ handleConnect();
7369
+ }
7370
+ }}
7371
+ placeholder="http://localhost:8080"
7372
+ className="h-8 text-sm"
7373
+ />
7374
+ <SettingsActionButton
7375
+ className="shrink-0"
7376
+ onClick={handleConnect}
7377
+ disabled={!url.trim()}
7378
+ >
7379
+ Connect
7380
+ </SettingsActionButton>
7381
+ </div>
7382
+ {serverUrlError ? (
7383
+ <p className="text-xs text-destructive">{serverUrlError}</p>
7384
+ ) : null}
7385
+ </div>
7386
+ </SettingsPopover>
7155
7387
  }
7156
7388
  />
7157
- </DesktopSettingsGroup>
7389
+ </SettingsGroup>
7158
7390
 
7159
- <DesktopSettingsGroup
7160
- title="App behavior"
7161
- description="Keep Clips ready when you need it and out of the way when you do not."
7162
- >
7163
- <DesktopSettingsRow
7164
- label="Open at login"
7165
- description="Start Clips when you sign in."
7391
+ <SettingsGroup label="Transcription">
7392
+ <SettingsRow
7393
+ label="Transcription engine"
7394
+ description="Choose what transcribes dictation and meetings"
7166
7395
  control={
7167
- <Switch
7168
- on={launchAtLoginEnabled}
7169
- onChange={setLaunchAtLoginEnabled}
7170
- label="Open Clips at login"
7171
- />
7172
- }
7173
- />
7174
- <DesktopSettingsRow
7175
- label="Hide when inactive"
7176
- description="Hide the tray window when it loses focus."
7177
- control={
7178
- <Switch
7179
- on={autoHidePopoverEnabled}
7180
- onChange={setAutoHidePopoverEnabled}
7181
- label="Hide Clips when focus leaves"
7182
- />
7183
- }
7184
- />
7185
- <DesktopSettingsRow
7186
- label="Show in screen captures"
7187
- description="Include Clips windows in screenshots and recordings."
7188
- control={
7189
- <Switch
7190
- on={showInScreenCapture}
7191
- onChange={setShowInScreenCapture}
7192
- label="Show Clips in screen captures"
7396
+ <SettingsSelect
7397
+ ariaLabel="Transcription engine"
7398
+ value={selectedMode}
7399
+ onValueChange={(value) =>
7400
+ selectProviderMode(value as VoiceProviderMode)
7401
+ }
7402
+ options={[
7403
+ {
7404
+ value: "native",
7405
+ label: isMacPlatform()
7406
+ ? "macOS on-device"
7407
+ : "Browser built-in",
7408
+ },
7409
+ { value: "whisper", label: "Local Whisper" },
7410
+ { value: "builder", label: "Builder.io" },
7411
+ { value: "byok", label: "Your own key" },
7412
+ ]}
7193
7413
  />
7194
7414
  }
7195
- />
7196
- </DesktopSettingsGroup>
7415
+ >
7416
+ {providerWarning ||
7417
+ (selectedMode === "builder" && !providerStatus?.builder) ? (
7418
+ <>
7419
+ {providerWarning ? (
7420
+ <p className="text-xs text-destructive">{providerWarning}</p>
7421
+ ) : null}
7422
+ {selectedMode === "builder" && !providerStatus?.builder ? (
7423
+ <SettingsActionButton
7424
+ className="w-fit"
7425
+ onClick={connectBuilder}
7426
+ >
7427
+ Use Builder.io
7428
+ </SettingsActionButton>
7429
+ ) : null}
7430
+ </>
7431
+ ) : null}
7432
+ </SettingsRow>
7197
7433
 
7198
- <DesktopSettingsGroup
7199
- title="Updates"
7200
- description="Keep the signed Clips desktop release current."
7201
- >
7202
- <DesktopSettingsRow
7203
- label="Desktop updates"
7204
- description={
7205
- updateChecksSupported
7206
- ? desktopUpdateStatusText(updateStatus)
7207
- : "Update checks are available in signed release builds."
7208
- }
7209
- control={
7210
- <div className="settings-control-group">
7211
- <button
7212
- type="button"
7213
- className="secondary"
7214
- onClick={checkForDesktopUpdate}
7215
- disabled={!updateChecksSupported || updateBusy || updateReady}
7216
- >
7217
- <IconRefresh
7218
- size={15}
7219
- stroke={1.9}
7220
- className={updateBusy ? "update-spinner" : undefined}
7221
- />
7222
- {updateCheckLabel}
7223
- </button>
7224
- {updateReady ? (
7225
- <button
7226
- type="button"
7227
- className="secondary"
7228
- onClick={() => {
7229
- installAndRestart().catch((err) => {
7230
- console.error("[clips-updater] relaunch failed:", err);
7231
- });
7434
+ {selectedMode === "byok" ? (
7435
+ <>
7436
+ <SettingsRow
7437
+ label="Key provider"
7438
+ description="Choose whose model cleans up dictated text"
7439
+ control={
7440
+ <SettingsSelect
7441
+ ariaLabel="Key provider"
7442
+ value={byokProvider}
7443
+ onValueChange={(value) => {
7444
+ setApiKeyMessage(null);
7445
+ onVoiceProviderChange(value as ByokVoiceProvider);
7232
7446
  }}
7447
+ options={[
7448
+ { value: "gemini", label: "Google Gemini" },
7449
+ { value: "groq", label: "Groq" },
7450
+ ]}
7451
+ />
7452
+ }
7453
+ />
7454
+ <SettingsRow
7455
+ label={
7456
+ providerStatus?.[byokProvider]
7457
+ ? `${labelForByokProvider(byokProvider)} key (set)`
7458
+ : `${labelForByokProvider(byokProvider)} key`
7459
+ }
7460
+ description="Saved to your Clips account"
7461
+ stacked
7462
+ control={
7463
+ <div className="flex w-full items-center gap-2">
7464
+ <Input
7465
+ type="password"
7466
+ value={apiKeyValue}
7467
+ onChange={(event) => setApiKeyValue(event.target.value)}
7468
+ onKeyDown={(event) => {
7469
+ if (event.key === "Enter") {
7470
+ event.preventDefault();
7471
+ saveApiKey();
7472
+ }
7473
+ }}
7474
+ placeholder={
7475
+ providerStatus?.[byokProvider]
7476
+ ? "Paste to rotate"
7477
+ : `Paste ${keyForByokProvider(byokProvider)}`
7478
+ }
7479
+ className="h-8 text-sm"
7480
+ />
7481
+ <SettingsActionButton
7482
+ className="shrink-0"
7483
+ onClick={saveApiKey}
7484
+ disabled={!apiKeyValue.trim() || apiKeySaving}
7485
+ >
7486
+ {apiKeySaving
7487
+ ? "Saving…"
7488
+ : providerStatus?.[byokProvider]
7489
+ ? "Rotate"
7490
+ : "Save"}
7491
+ </SettingsActionButton>
7492
+ </div>
7493
+ }
7494
+ >
7495
+ {apiKeyMessage ? (
7496
+ <p
7497
+ className={
7498
+ apiKeyMessage.kind === "ok"
7499
+ ? "text-xs text-success"
7500
+ : "text-xs text-destructive"
7501
+ }
7233
7502
  >
7234
- Restart to install
7235
- </button>
7503
+ {apiKeyMessage.text}
7504
+ </p>
7236
7505
  ) : null}
7237
- </div>
7238
- }
7239
- />
7240
- </DesktopSettingsGroup>
7506
+ </SettingsRow>
7507
+ </>
7508
+ ) : null}
7241
7509
 
7242
- <DesktopSettingsGroup
7243
- title="Account & diagnostics"
7244
- description="Manage the local desktop session and find troubleshooting information."
7245
- >
7246
- {signedInAs && onSignOut ? (
7247
- <DesktopSettingsRow
7248
- label="Signed in"
7249
- description={signedInAs}
7510
+ {selectedMode !== "native" && selectedMode !== "whisper" ? (
7511
+ <SettingsRow
7512
+ label="Cleanup instructions"
7513
+ description="Names, jargon, and casing to keep exactly as you say them"
7514
+ stacked
7250
7515
  control={
7251
- <button
7252
- type="button"
7253
- className="link-button"
7254
- onClick={onSignOut}
7255
- >
7256
- Sign out
7257
- </button>
7516
+ <Textarea
7517
+ id="voice-instructions"
7518
+ rows={3}
7519
+ value={voiceInstructions}
7520
+ onChange={(event) =>
7521
+ onVoiceInstructionsChange(event.target.value)
7522
+ }
7523
+ placeholder="Example: keep it casual and preserve technical terms exactly."
7524
+ className="min-h-16 text-sm"
7525
+ />
7258
7526
  }
7259
7527
  />
7260
7528
  ) : null}
7261
- <DesktopSettingsRow
7262
- label="Diagnostic logs"
7263
- description="Open the local Clips logs folder."
7264
- control={
7265
- <button
7266
- type="button"
7267
- className="secondary"
7268
- onClick={() => {
7269
- invoke("open_logs").catch((err) => {
7270
- console.error("[clips-tray] open logs failed:", err);
7271
- });
7272
- }}
7273
- >
7274
- <IconFolderOpen size={15} stroke={1.9} />
7275
- Open logs
7276
- </button>
7277
- }
7278
- />
7279
- </DesktopSettingsGroup>
7280
- </div>
7281
- );
7282
- }
7283
7529
 
7284
- function renderRecordingSettings() {
7285
- return (
7286
- <div className="settings-tab-content">
7287
- <DesktopSettingsGroup
7288
- title="Permissions"
7289
- description="Clips needs access to capture your screen, camera, and microphone."
7290
- >
7291
- <div className="settings-group-inset">
7292
- <ReadinessPanel
7293
- mode="screen-camera"
7294
- cameraOn={true}
7295
- micOn={true}
7296
- includeVoicePaste={voiceEnabled}
7297
- includeFnMonitoring={fnShortcutSelected}
7298
- open={readinessOpen}
7299
- onOpenChange={setReadinessOpen}
7300
- onOpenPermission={openPrivacySettings}
7301
- />
7302
- </div>
7303
- </DesktopSettingsGroup>
7304
-
7305
- <DesktopSettingsGroup
7306
- title="Capture"
7307
- description="Choose how recordings are saved and how the tray opens them."
7308
- >
7309
- <DesktopSettingsRow
7310
- label="Voice cleanup"
7311
- description="Reduce steady background noise in microphone recordings."
7530
+ <SettingsRow
7531
+ label="Local Whisper model"
7532
+ description="Transcribes everyone else in a meeting, offline on this device"
7312
7533
  control={
7313
- <Switch
7314
- on={voiceCleanupEnabled}
7315
- onChange={setVoiceCleanupEnabled}
7316
- disabled={captureControlsLocked}
7317
- label="Use voice cleanup"
7534
+ <SettingsSwitch
7535
+ checked={whisperModelEnabled}
7536
+ onCheckedChange={whisper.setEnabled}
7537
+ label="Local Whisper model"
7318
7538
  />
7319
7539
  }
7320
- />
7321
- <DesktopSettingsRow
7322
- label="Rewind"
7323
- description={rewindStatusPresentation.title}
7324
- control={
7325
- <DesktopSettingsPopover
7326
- title="Rewind settings"
7327
- open={rewindSettingsOpen}
7328
- onOpenChange={setRewindSettingsOpen}
7329
- side="left"
7330
- contentClassName="rewind-settings-launcher"
7331
- trigger={
7332
- <button type="button" className="secondary">
7333
- Manage
7334
- </button>
7335
- }
7336
- >
7337
- <Setup
7338
- surface="rewind"
7339
- recordingActive={recordingActive}
7340
- promptRewindEnable={false}
7341
- initial={initial}
7342
- serverUrl={serverUrl}
7343
- signedInAs={signedInAs}
7344
- voiceShortcut={voiceShortcut}
7345
- voiceCustomShortcut={voiceCustomShortcut}
7346
- popoverCustomShortcut={popoverCustomShortcut}
7347
- recordCustomShortcut={recordCustomShortcut}
7348
- voiceMode={voiceMode}
7349
- voiceProvider={voiceProvider}
7350
- voiceInstructions={voiceInstructions}
7351
- shortcutRegistrationError={shortcutRegistrationError}
7352
- onVoiceShortcutChange={onVoiceShortcutChange}
7353
- onVoiceCustomShortcutChange={onVoiceCustomShortcutChange}
7354
- onPopoverCustomShortcutChange={onPopoverCustomShortcutChange}
7355
- onRecordCustomShortcutChange={onRecordCustomShortcutChange}
7356
- onVoiceModeChange={onVoiceModeChange}
7357
- onVoiceProviderChange={onVoiceProviderChange}
7358
- onVoiceInstructionsChange={onVoiceInstructionsChange}
7359
- onConnect={onConnect}
7360
- rewindAgentPromptCopied={rewindAgentPromptCopied}
7361
- onCopyRewindAgentPrompt={onCopyRewindAgentPrompt}
7362
- onOpenRewindDocs={onOpenRewindDocs}
7363
- onCancel={() => setRewindSettingsOpen(false)}
7364
- />
7365
- </DesktopSettingsPopover>
7366
- }
7367
- />
7368
- <DesktopSettingsRow
7369
- label="Clip Drafts"
7370
- description="Open local recordings that were kept after an upload issue."
7371
- control={
7372
- <button
7373
- type="button"
7374
- className="secondary"
7375
- onClick={openClipDraftsFolder}
7376
- >
7377
- <IconFolderOpen size={15} stroke={1.9} />
7378
- Open Clip Drafts
7379
- </button>
7380
- }
7381
7540
  >
7382
- {clipDraftsError ? (
7383
- <p className="setup-warning">{clipDraftsError}</p>
7384
- ) : null}
7385
- </DesktopSettingsRow>
7386
- <DesktopSettingsRow
7387
- label="Save recordings locally"
7388
- description="Keep recordings on this Mac instead of uploading them."
7389
- control={
7390
- <select
7391
- id="local-recording-mode"
7392
- className="setup-select"
7393
- value={localRecordingMode}
7394
- onChange={(event) =>
7395
- setLocalRecordingMode(
7396
- event.target.value as LocalRecordingMode,
7397
- )
7398
- }
7399
- >
7400
- <option value="off">Cloud Clips (default)</option>
7401
- <option value="composed">One local composed video</option>
7402
- <option value="separate">
7403
- Two local files: desktop + camera
7404
- </option>
7405
- </select>
7406
- }
7407
- />
7408
- <DesktopSettingsRow
7541
+ {whisperModelEnabled ? null : (
7542
+ <WhisperModelStatusRow
7543
+ status={whisperStatus}
7544
+ enabled={false}
7545
+ onDownload={whisper.triggerDownload}
7546
+ />
7547
+ )}
7548
+ </SettingsRow>
7549
+ {whisperModelEnabled ? (
7550
+ <SettingsRow
7551
+ label="Model"
7552
+ description="Choose the model used for offline transcription"
7553
+ control={
7554
+ <SettingsSelect
7555
+ ariaLabel="Whisper model"
7556
+ value={whisperModelId}
7557
+ onValueChange={whisper.setModelId}
7558
+ placeholder={
7559
+ whisperModels.length === 0
7560
+ ? "No models available"
7561
+ : "Choose a model"
7562
+ }
7563
+ options={whisperModels.map((model) => ({
7564
+ value: model.id,
7565
+ label: whisperModelOptionLabel(model),
7566
+ }))}
7567
+ disabled={
7568
+ whisperModels.length === 0 ||
7569
+ whisperStatus?.state === "downloading"
7570
+ }
7571
+ />
7572
+ }
7573
+ >
7574
+ {/* `ready` renders nothing (the picker already says which model),
7575
+ so gating on `whisperStatus` alone left an empty children row
7576
+ adding phantom space under the select. Gate on what will
7577
+ actually render. */}
7578
+ {(whisperStatus && whisperStatus.state !== "ready") ||
7579
+ deletableModels.length > 0 ? (
7580
+ <>
7581
+ <WhisperModelStatusRow
7582
+ status={whisperStatus}
7583
+ enabled={whisperModelEnabled}
7584
+ onDownload={whisper.triggerDownload}
7585
+ />
7586
+ {deletableModels.length > 0 ? (
7587
+ <div className="grid gap-1">
7588
+ {deletableModels.map((model) => (
7589
+ <div
7590
+ key={model.id}
7591
+ className="flex items-center justify-between gap-2"
7592
+ >
7593
+ <span className="truncate">
7594
+ {model.title} &middot; {model.sizeMb} MB
7595
+ </span>
7596
+ <SettingsActionButton
7597
+ emphasis="quiet"
7598
+ onClick={() => whisper.deleteModel(model.id)}
7599
+ >
7600
+ <IconTrash className="size-3.5" stroke={1.9} />
7601
+ Delete
7602
+ </SettingsActionButton>
7603
+ </div>
7604
+ ))}
7605
+ </div>
7606
+ ) : null}
7607
+ </>
7608
+ ) : null}
7609
+ </SettingsRow>
7610
+ ) : null}
7611
+ </SettingsGroup>
7612
+
7613
+ <SettingsGroup label="Capture">
7614
+ <SettingsRow
7409
7615
  label="Screen region guides"
7410
7616
  description={
7411
- regionGuideCount === 0
7412
- ? "Frame recordings with private guides that never enter the saved Clip."
7413
- : `${regionGuideCount} ${regionGuideCount === 1 ? "rectangle" : "rectangles"} saved.`
7617
+ "Outlines to frame a shot that never appear in the Clip"
7414
7618
  }
7415
- extraClassName="settings-row-extra--inline"
7416
7619
  control={
7417
- <div className="settings-control-group">
7418
- <Switch
7419
- on={regionGuides.enabled}
7420
- onChange={setRegionGuidesEnabled}
7620
+ <>
7621
+ <SettingsSwitch
7622
+ checked={regionGuides.enabled}
7623
+ onCheckedChange={setRegionGuidesEnabled}
7421
7624
  label="Show screen region guides while recording"
7422
7625
  />
7423
- <button
7424
- type="button"
7425
- className="secondary"
7426
- onClick={openRegionGuideEditor}
7427
- >
7428
- <IconPencil size={15} stroke={1.9} />
7429
- Edit preset
7430
- </button>
7431
- </div>
7626
+ <SettingsActionButton onClick={openRegionGuideEditor}>
7627
+ <IconPencil className="size-3.5" stroke={1.9} />
7628
+ Edit
7629
+ </SettingsActionButton>
7630
+ </>
7432
7631
  }
7433
7632
  >
7434
7633
  {regionGuides.enabled ? (
7435
- <>
7436
- <span>Keep guides visible when you are not recording.</span>
7437
- <Switch
7438
- on={regionGuidesAlwaysVisible}
7439
- onChange={setRegionGuidesAlwaysVisible}
7634
+ <div className="flex flex-wrap items-center gap-2.5">
7635
+ <span className="flex-1">
7636
+ Keep guides visible when not recording
7637
+ </span>
7638
+ <SettingsSwitch
7639
+ checked={regionGuidesAlwaysVisible}
7640
+ onCheckedChange={setRegionGuidesAlwaysVisible}
7440
7641
  label="Keep region guides on screen even when not recording"
7441
7642
  />
7442
7643
  {regionGuideCount > 0 ? (
7443
- <button
7444
- type="button"
7445
- className="settings-text-button"
7644
+ <SettingsActionButton
7645
+ emphasis="quiet"
7446
7646
  onClick={clearRegionGuidePreset}
7447
7647
  >
7448
- Clear saved preset
7449
- </button>
7648
+ Clear preset
7649
+ </SettingsActionButton>
7450
7650
  ) : null}
7451
- </>
7651
+ </div>
7452
7652
  ) : null}
7453
- </DesktopSettingsRow>
7454
- </DesktopSettingsGroup>
7653
+ </SettingsRow>
7654
+ </SettingsGroup>
7455
7655
 
7456
- <DesktopSettingsGroup
7457
- title="Keyboard shortcuts"
7458
- description="Optional global shortcuts for recording and opening the tray."
7459
- >
7460
- <DesktopSettingsRow
7461
- label="Start/stop recording"
7462
- description="Start full-screen, region, or camera recordings and stop the active recording."
7656
+ <SettingsGroup label="Window">
7657
+ <SettingsRow
7658
+ label="Show Clips in screen captures"
7659
+ description="The Clips window will appear in your recordings"
7463
7660
  control={
7464
- <ShortcutRecorder
7465
- value={recordCustomShortcut}
7466
- placeholder="Record shortcut"
7467
- onChange={onRecordCustomShortcutChange}
7661
+ <SettingsSwitch
7662
+ checked={showInScreenCapture}
7663
+ onCheckedChange={setShowInScreenCapture}
7664
+ label="Show Clips in screen captures"
7468
7665
  />
7469
7666
  }
7470
7667
  />
7471
- <DesktopSettingsRow
7472
- label="Open Clips"
7473
- description="Open the tray popover; Cmd Shift L remains available."
7668
+ <SettingsRow
7669
+ label="Open Clips shortcut"
7670
+ description="Shortcut to open Clips from any app"
7474
7671
  control={
7475
7672
  <ShortcutRecorder
7476
7673
  value={popoverCustomShortcut}
7477
- placeholder="Record shortcut"
7674
+ /* The built-in Cmd/Ctrl+Shift+L binding is always registered
7675
+ (shortcuts.rs), so an empty custom slot shows the shortcut
7676
+ that actually works instead of a blank "Set". Recording a
7677
+ custom chord adds a second binding; clearing it returns to
7678
+ showing the built-in one. */
7679
+ placeholder={compactShortcutLabel(
7680
+ isMacPlatform() ? "Cmd+Shift+L" : "Ctrl+Shift+L",
7681
+ )}
7478
7682
  onChange={onPopoverCustomShortcutChange}
7479
7683
  />
7480
7684
  }
7481
7685
  >
7482
- <>
7483
- <span>Leave this empty to use only Cmd Shift L.</span>
7484
- {shortcutRegistrationError ? (
7485
- <span className="setup-warning">
7486
- {shortcutRegistrationError}
7487
- </span>
7488
- ) : null}
7489
- </>
7490
- </DesktopSettingsRow>
7491
- </DesktopSettingsGroup>
7686
+ {shortcutRegistrationError ? (
7687
+ <p className="text-xs text-destructive">
7688
+ {shortcutRegistrationError}
7689
+ </p>
7690
+ ) : null}
7691
+ </SettingsRow>
7692
+ </SettingsGroup>
7693
+
7694
+ <SettingsGroup label="Troubleshooting">
7695
+ <SettingsRow
7696
+ label="Clip drafts"
7697
+ description="Local copies kept when an upload doesn't finish"
7698
+ control={
7699
+ <SettingsActionButton onClick={openClipDraftsFolder}>
7700
+ Open folder
7701
+ </SettingsActionButton>
7702
+ }
7703
+ >
7704
+ {clipDraftsError ? (
7705
+ <p className="text-xs text-destructive">{clipDraftsError}</p>
7706
+ ) : null}
7707
+ </SettingsRow>
7708
+ <SettingsRow
7709
+ label="Diagnostic logs"
7710
+ description="Attach these when you report a problem"
7711
+ control={
7712
+ <SettingsActionButton
7713
+ onClick={() => {
7714
+ invoke("open_logs").catch((err) => {
7715
+ console.error("[clips-tray] open logs failed:", err);
7716
+ });
7717
+ }}
7718
+ >
7719
+ Open folder
7720
+ </SettingsActionButton>
7721
+ }
7722
+ />
7723
+ </SettingsGroup>
7724
+
7725
+ {signedInAs && onSignOut ? (
7726
+ <SettingsGroup label="Account">
7727
+ <SettingsRow
7728
+ label={`Signed in as ${signedInAs}`}
7729
+ description="Recordings from this device land in this library"
7730
+ control={
7731
+ <SettingsActionButton onClick={onSignOut}>
7732
+ Sign out
7733
+ </SettingsActionButton>
7734
+ }
7735
+ />
7736
+ </SettingsGroup>
7737
+ ) : null}
7492
7738
  </div>
7493
7739
  );
7494
7740
  }
7495
7741
 
7496
7742
  function renderMeetingSettings() {
7497
7743
  return (
7498
- <div className="settings-tab-content">
7499
- <DesktopSettingsGroup
7500
- title="Meeting notes"
7501
- description="Use calendar meetings to show reminders and start live transcription."
7502
- >
7503
- <DesktopSettingsRow
7744
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
7745
+ <SettingsGroup>
7746
+ <SettingsRow
7504
7747
  label="Meeting notes"
7505
- description="Show the meeting widget and enable live notes."
7748
+ description="Transcribes and summarizes meetings from your calendar"
7506
7749
  control={
7507
- <Switch
7508
- on={meetingsEnabled}
7509
- onChange={setMeetingsEnabled}
7510
- label="Enable meeting notes"
7750
+ <SettingsSwitch
7751
+ checked={meetingsEnabled}
7752
+ onCheckedChange={setMeetingsEnabled}
7753
+ label="Meeting notes"
7511
7754
  />
7512
7755
  }
7513
7756
  />
7514
7757
  {meetingsEnabled ? (
7515
7758
  <>
7516
- <DesktopSettingsRow
7517
- label="Meeting transcription"
7518
- description="Ask first, start automatically, or wait for a manual start."
7759
+ <SettingsRow
7760
+ label="When a meeting starts"
7761
+ description="Choose how note taking begins"
7519
7762
  control={
7520
- <select
7521
- id="meeting-transcription-mode"
7522
- className="setup-select"
7763
+ <SettingsSelect
7764
+ ariaLabel="When a meeting starts"
7523
7765
  value={meetingTranscriptionMode}
7524
- onChange={(event) =>
7766
+ onValueChange={(value) =>
7525
7767
  setMeetingTranscriptionMode(
7526
- event.target.value as MeetingTranscriptionMode,
7768
+ value as MeetingTranscriptionMode,
7527
7769
  )
7528
7770
  }
7529
- >
7530
- <option value="ask">Ask at meeting time</option>
7531
- <option value="auto">
7532
- Auto-start during meeting times
7533
- </option>
7534
- <option value="manual">Manual only</option>
7535
- </select>
7771
+ options={[
7772
+ { value: "ask", label: "Ask first" },
7773
+ { value: "auto", label: "Start notes" },
7774
+ { value: "manual", label: "Do nothing" },
7775
+ ]}
7776
+ />
7536
7777
  }
7537
7778
  />
7538
- <DesktopSettingsRow
7539
- label="Meeting widget"
7540
- description="Show the on-screen meeting widget near calendar start times."
7779
+ <SettingsRow
7780
+ label="Meeting notifications"
7781
+ description="Show a notification before scheduled meetings and when a call is detected"
7541
7782
  control={
7542
- <Switch
7543
- on={showMeetingWidgetEnabled}
7544
- onChange={setShowMeetingWidgetEnabled}
7545
- label="Show meeting widget"
7783
+ <SettingsSwitch
7784
+ checked={showMeetingWidgetEnabled}
7785
+ onCheckedChange={setShowMeetingWidgetEnabled}
7786
+ label="Meeting notifications"
7546
7787
  />
7547
7788
  }
7548
7789
  />
7549
7790
  </>
7550
7791
  ) : null}
7551
- </DesktopSettingsGroup>
7792
+ </SettingsGroup>
7552
7793
  </div>
7553
7794
  );
7554
7795
  }
7555
7796
 
7556
7797
  function renderDictationSettings() {
7557
7798
  return (
7558
- <div className="settings-tab-content">
7559
- <DesktopSettingsGroup
7560
- title="Local transcription"
7561
- description="Use Whisper for offline transcription without an API key."
7562
- >
7563
- <DesktopSettingsRow
7564
- label="Whisper model"
7565
- description="Enable the local model for dictation and meetings."
7566
- control={
7567
- <Switch
7568
- on={whisperModelEnabled}
7569
- onChange={whisper.setEnabled}
7570
- label="Enable Whisper model"
7571
- />
7572
- }
7573
- />
7574
- <DesktopSettingsRow
7575
- label="Model"
7576
- description={
7577
- selectedWhisperModel?.description ??
7578
- "Choose the downloaded model used for offline transcription."
7579
- }
7580
- control={
7581
- <WhisperModelPicker
7582
- models={whisperModels}
7583
- modelId={whisperModelId}
7584
- onChange={whisper.setModelId}
7585
- disabled={
7586
- whisperModels.length === 0 ||
7587
- whisperStatus?.state === "downloading"
7588
- }
7589
- />
7590
- }
7591
- >
7592
- <>
7593
- <WhisperModelStatusRow
7594
- status={whisperStatus}
7595
- enabled={whisperModelEnabled}
7596
- onDownload={whisper.triggerDownload}
7597
- />
7598
- {deletableModels.length > 0 ? (
7599
- <div className="whisper-other-models">
7600
- <p className="setup-hint">Other downloaded models</p>
7601
- {deletableModels.map((model) => (
7602
- <div key={model.id} className="whisper-other-model-row">
7603
- <span className="whisper-other-model-name">
7604
- {model.title} &middot; {model.sizeMb} MB
7605
- </span>
7606
- <button
7607
- type="button"
7608
- className="whisper-delete-btn"
7609
- onClick={() => whisper.deleteModel(model.id)}
7610
- >
7611
- <IconTrash size={13} />
7612
- Delete
7613
- </button>
7614
- </div>
7615
- ))}
7616
- </div>
7617
- ) : null}
7618
- </>
7619
- </DesktopSettingsRow>
7620
- </DesktopSettingsGroup>
7621
-
7622
- <DesktopSettingsGroup
7623
- title="Voice dictation"
7624
- description="Speak to type anywhere on your Mac."
7625
- >
7626
- <DesktopSettingsRow
7799
+ <div className="mx-auto grid w-full max-w-[620px] gap-7 pb-4">
7800
+ <SettingsGroup>
7801
+ <SettingsRow
7627
7802
  label="Voice dictation"
7628
- description="Enable the global dictation shortcut."
7803
+ description="Turns speech into polished text in any app"
7629
7804
  control={
7630
- <Switch
7631
- on={voiceEnabled}
7632
- onChange={setVoiceEnabled}
7633
- label="Enable voice dictation"
7805
+ <SettingsSwitch
7806
+ checked={voiceEnabled}
7807
+ onCheckedChange={setVoiceEnabled}
7808
+ label="Voice dictation"
7634
7809
  />
7635
7810
  }
7636
7811
  />
7637
7812
  {voiceEnabled ? (
7638
7813
  <>
7639
- <DesktopSettingsRow
7640
- label="Provider"
7641
- description={providerHint[selectedMode]}
7814
+ <SettingsRow
7815
+ label="Shortcut"
7816
+ description="Shortcut to start dictation"
7642
7817
  control={
7643
- <div className="settings-control-group">
7644
- <select
7645
- id="voice-provider"
7646
- className="setup-select"
7647
- value={selectedMode}
7648
- onChange={(event) =>
7649
- selectProviderMode(
7650
- event.target.value as VoiceProviderMode,
7651
- )
7652
- }
7653
- >
7654
- <option value="native">On-device (free, fast)</option>
7655
- <option value="whisper" disabled={!whisperModelEnabled}>
7656
- {whisperModelEnabled
7657
- ? "Local Whisper (offline AI)"
7658
- : "Local Whisper — enable Whisper model first"}
7659
- </option>
7660
- <option value="builder">Builder.io</option>
7661
- <option value="byok">Add your own key</option>
7662
- </select>
7663
- {selectedMode === "builder" && !providerStatus?.builder ? (
7664
- <button
7665
- type="button"
7666
- className="secondary"
7667
- onClick={connectBuilder}
7668
- >
7669
- Use Builder.io
7670
- </button>
7671
- ) : null}
7672
- </div>
7673
- }
7674
- >
7675
- {providerWarning ||
7676
- (selectedMode === "whisper" && !whisperModelEnabled) ? (
7677
- <p className="setup-warning">
7678
- {providerWarning ??
7679
- "Whisper model is disabled. Enable it in Local transcription."}
7680
- </p>
7681
- ) : null}
7682
- </DesktopSettingsRow>
7683
-
7684
- {selectedMode === "byok" ? (
7685
- <>
7686
- <DesktopSettingsRow
7687
- label="Key provider"
7688
- description="Choose the provider key used for cleanup."
7689
- control={
7690
- <select
7691
- id="voice-byok-provider"
7692
- className="setup-select"
7693
- value={byokProvider}
7694
- onChange={(event) => {
7695
- setApiKeyMessage(null);
7696
- onVoiceProviderChange(
7697
- event.target.value as ByokVoiceProvider,
7698
- );
7699
- }}
7700
- >
7701
- <option value="gemini">
7702
- Google Gemini (recommended)
7703
- </option>
7704
- <option value="groq">Groq</option>
7705
- </select>
7818
+ <SettingsChoicePopover
7819
+ title="Dictation shortcut"
7820
+ trigger={
7821
+ <SettingsKeycap aria-haspopup="listbox">
7822
+ {compactVoiceShortcutLabel(
7823
+ voiceShortcut,
7824
+ voiceCustomShortcut,
7825
+ )}
7826
+ </SettingsKeycap>
7706
7827
  }
7707
- />
7708
- <DesktopSettingsRow
7709
- label="API key"
7710
- description={
7711
- providerStatus?.[byokProvider]
7712
- ? `${labelForByokProvider(byokProvider)} key is set. Paste a new key to rotate it.`
7713
- : `Save a ${keyForByokProvider(byokProvider)} key for cleanup.`
7714
- }
7715
- control={
7716
- <div className="settings-control-group settings-control-group--wide">
7717
- <input
7718
- type="password"
7719
- value={apiKeyValue}
7720
- onChange={(event) =>
7721
- setApiKeyValue(event.target.value)
7722
- }
7723
- onKeyDown={(event) => {
7724
- if (event.key === "Enter") {
7725
- event.preventDefault();
7726
- saveApiKey();
7727
- }
7728
- }}
7729
- placeholder={
7730
- providerStatus?.[byokProvider]
7731
- ? "Paste to rotate"
7732
- : `Paste ${keyForByokProvider(byokProvider)}`
7733
- }
7734
- className="settings-url-input"
7735
- />
7736
- <button
7737
- type="button"
7738
- className="secondary"
7739
- onClick={saveApiKey}
7740
- disabled={!apiKeyValue.trim() || apiKeySaving}
7741
- >
7742
- {apiKeySaving
7743
- ? "Saving..."
7744
- : providerStatus?.[byokProvider]
7745
- ? "Rotate"
7746
- : "Save"}
7747
- </button>
7748
- </div>
7828
+ value={voiceShortcut}
7829
+ options={VOICE_SHORTCUT_CHOICES}
7830
+ keepOpenValues={["custom", "fn", "both"]}
7831
+ onChange={(next) =>
7832
+ onVoiceShortcutChange(next as VoiceShortcutPreference)
7749
7833
  }
7750
7834
  >
7751
- {apiKeyMessage ? (
7752
- <p
7753
- className={
7754
- apiKeyMessage.kind === "ok"
7755
- ? "setup-success"
7756
- : "setup-warning"
7757
- }
7835
+ {voiceShortcut === "custom" ? (
7836
+ <ShortcutRecorder
7837
+ value={voiceCustomShortcut}
7838
+ placeholder="Record shortcut"
7839
+ onChange={onVoiceCustomShortcutChange}
7840
+ />
7841
+ ) : null}
7842
+ {isMacPlatform() && fnShortcutSelected ? (
7843
+ <SettingsActionButton
7844
+ className="w-full"
7845
+ onClick={() => openPrivacySettings("input-monitoring")}
7758
7846
  >
7759
- {apiKeyMessage.text}
7847
+ Grant Input Monitoring
7848
+ </SettingsActionButton>
7849
+ ) : null}
7850
+ {shortcutRegistrationError ? (
7851
+ <p className="text-xs text-destructive">
7852
+ {shortcutRegistrationError}
7760
7853
  </p>
7761
7854
  ) : null}
7762
- </DesktopSettingsRow>
7763
- </>
7764
- ) : null}
7765
-
7766
- {selectedMode !== "native" && selectedMode !== "whisper" ? (
7767
- <DesktopSettingsRow
7768
- label="Custom instructions"
7769
- description="Guide casing, names, punctuation, and terms of art for LLM cleanup."
7770
- stacked
7771
- control={
7772
- <textarea
7773
- id="voice-instructions"
7774
- className="setup-textarea"
7775
- rows={4}
7776
- value={voiceInstructions}
7777
- onChange={(event) =>
7778
- onVoiceInstructionsChange(event.target.value)
7779
- }
7780
- placeholder="Example: keep it casual and preserve technical terms exactly."
7781
- />
7782
- }
7783
- />
7784
- ) : null}
7785
-
7786
- <DesktopSettingsRow
7787
- label="Shortcut"
7788
- description={shortcutHint[voiceShortcut]}
7789
- control={
7790
- <select
7791
- id="voice-shortcut"
7792
- className="setup-select"
7793
- value={voiceShortcut}
7794
- onChange={(event) =>
7795
- onVoiceShortcutChange(
7796
- event.target.value as VoiceShortcutPreference,
7797
- )
7798
- }
7799
- >
7800
- <option value="cmd-shift-space">Cmd Shift Space</option>
7801
- <option value="ctrl-shift-space">Ctrl Shift Space</option>
7802
- <option value="custom">Custom shortcut</option>
7803
- <option value="fn">
7804
- Fn (globe, needs Input Monitoring)
7805
- </option>
7806
- <option value="both">All shortcuts (includes Fn)</option>
7807
- </select>
7855
+ </SettingsChoicePopover>
7808
7856
  }
7809
7857
  />
7810
- {voiceShortcut === "custom" ? (
7811
- <DesktopSettingsRow
7812
- label="Custom shortcut"
7813
- description="Record the key combination used for dictation."
7814
- control={
7815
- <ShortcutRecorder
7816
- value={voiceCustomShortcut}
7817
- placeholder="Record voice shortcut"
7818
- onChange={onVoiceCustomShortcutChange}
7819
- />
7820
- }
7821
- />
7822
- ) : null}
7823
- {isMacPlatform() && fnShortcutSelected ? (
7824
- <DesktopSettingsRow
7825
- label="Input Monitoring"
7826
- description="Required when the Fn / globe shortcut is enabled."
7827
- control={
7828
- <button
7829
- type="button"
7830
- className="secondary"
7831
- onClick={() => openPrivacySettings("input-monitoring")}
7832
- >
7833
- Open settings
7834
- </button>
7835
- }
7836
- />
7837
- ) : null}
7838
- <DesktopSettingsRow
7858
+ <SettingsRow
7839
7859
  label="Mode"
7840
- description={modeHint[voiceMode]}
7860
+ description="Choose how the shortcut starts and stops dictation"
7841
7861
  control={
7842
- <select
7843
- id="voice-mode"
7844
- className="setup-select"
7862
+ <SettingsSelect
7863
+ ariaLabel="Dictation mode"
7845
7864
  value={voiceMode}
7846
- onChange={(event) =>
7847
- onVoiceModeChange(event.target.value as VoiceMode)
7865
+ onValueChange={(value) =>
7866
+ onVoiceModeChange(value as VoiceMode)
7848
7867
  }
7849
- >
7850
- <option value="push-to-talk">Hold to dictate</option>
7851
- <option value="toggle">
7852
- Press to start, press to stop
7853
- </option>
7854
- </select>
7868
+ options={[
7869
+ { value: "push-to-talk", label: "Hold to dictate" },
7870
+ { value: "toggle", label: "Press to start and stop" },
7871
+ ]}
7872
+ />
7855
7873
  }
7856
7874
  />
7857
7875
  </>
7858
7876
  ) : null}
7859
- </DesktopSettingsGroup>
7877
+ </SettingsGroup>
7860
7878
  </div>
7861
7879
  );
7862
7880
  }
@@ -7864,12 +7882,47 @@ function Setup({
7864
7882
  const settingsTabs: Array<{
7865
7883
  id: SettingsTabId;
7866
7884
  label: string;
7867
- group: string;
7885
+ icon: ReactNode;
7886
+ alert?: boolean;
7868
7887
  }> = [
7869
- { id: "general", label: "General", group: "Personal" },
7870
- { id: "recording", label: "Recording", group: "Personal" },
7871
- { id: "meetings", label: "Meetings", group: "Workflow" },
7872
- { id: "dictation", label: "Dictation", group: "Workflow" },
7888
+ {
7889
+ id: "general",
7890
+ label: "General",
7891
+ icon: (
7892
+ <IconAdjustmentsHorizontal size={16} stroke={1.7} aria-hidden="true" />
7893
+ ),
7894
+ alert: updateReady,
7895
+ },
7896
+ {
7897
+ id: "recording",
7898
+ label: "Recording",
7899
+ icon: <IconVideo size={16} stroke={1.7} aria-hidden="true" />,
7900
+ },
7901
+ // Its own destination, not a popover buried in Advanced: Rewind carries ~10
7902
+ // settings across capture, privacy, agent, and storage.
7903
+ {
7904
+ id: "rewind",
7905
+ label: "Rewind",
7906
+ icon: <IconHistory size={16} stroke={1.7} aria-hidden="true" />,
7907
+ },
7908
+ {
7909
+ id: "meetings",
7910
+ label: "Meetings",
7911
+ icon: <IconCalendar size={16} stroke={1.7} aria-hidden="true" />,
7912
+ },
7913
+ // A microphone, not a keyboard: dictation is the surface you talk into, and
7914
+ // a keyboard icon read as "keyboard shortcuts" instead.
7915
+ {
7916
+ id: "dictation",
7917
+ label: "Dictation",
7918
+ icon: <IconMicrophone size={16} stroke={1.7} aria-hidden="true" />,
7919
+ },
7920
+ // A wrench, not a warning triangle: Advanced is rarely-needed, not unsafe.
7921
+ {
7922
+ id: "advanced",
7923
+ label: "Advanced",
7924
+ icon: <IconTool size={16} stroke={1.7} aria-hidden="true" />,
7925
+ },
7873
7926
  ];
7874
7927
  const activeSettingsTab =
7875
7928
  settingsTabs.find((tab) => tab.id === settingsTab) ?? settingsTabs[0];
@@ -7882,6 +7935,10 @@ function Setup({
7882
7935
  return renderMeetingSettings();
7883
7936
  case "dictation":
7884
7937
  return renderDictationSettings();
7938
+ case "rewind":
7939
+ return renderRewindSettings();
7940
+ case "advanced":
7941
+ return renderAdvancedSettings();
7885
7942
  case "general":
7886
7943
  default:
7887
7944
  return renderGeneralSettings();
@@ -7889,335 +7946,89 @@ function Setup({
7889
7946
  }
7890
7947
 
7891
7948
  return (
7892
- <div className="setup settings-page">
7893
- <div
7894
- className="setup-header settings-page-header"
7895
- onMouseDown={handlePopoverHeaderMouseDown}
7896
- >
7897
- {onCancel ? (
7898
- <button
7899
- type="button"
7900
- className="setup-back"
7901
- onClick={onCancel}
7902
- aria-label="Back"
7949
+ <div
7950
+ data-tw-surface
7951
+ /* A fixed height, not content-driven: the tray window resizes itself to
7952
+ match rendered content, so a taller tab would otherwise grow the window
7953
+ out from under the user. Tabs scroll inside this frame instead. */
7954
+ className="flex h-[560px] max-h-[calc(100vh-48px)] w-full flex-col overflow-hidden rounded-[14px] bg-background text-foreground"
7955
+ >
7956
+ <div className="grid min-h-0 flex-1 grid-cols-[176px_minmax(0,1fr)]">
7957
+ <nav
7958
+ className="flex min-w-0 flex-col gap-0.5 overflow-y-auto border-r border-border bg-muted/50 p-2.5 pt-3"
7959
+ aria-label="Settings sections"
7960
+ >
7961
+ {/* The tray window is chromeless, so this header is its only drag
7962
+ handle — without it the window cannot be moved. */}
7963
+ <div
7964
+ className="flex items-center pb-2"
7965
+ onMouseDown={handlePopoverHeaderMouseDown}
7903
7966
  >
7904
- <IconArrowLeft size={18} stroke={1.75} />
7905
- </button>
7906
- ) : null}
7907
- <div className="settings-page-title">
7908
- <h2>Settings</h2>
7909
- <p>Clips desktop preferences</p>
7910
- </div>
7911
- </div>
7912
-
7913
- <div className="settings-page-layout">
7914
- <nav className="settings-nav" aria-label="Settings sections">
7915
- {settingsTabs.map((tab, index) => (
7916
- <div
7917
- key={tab.id}
7918
- className={`settings-nav-group ${
7919
- index === 0 || settingsTabs[index - 1]?.group !== tab.group
7920
- ? "settings-nav-group--start"
7921
- : ""
7922
- }`}
7923
- >
7924
- {index === 0 || settingsTabs[index - 1]?.group !== tab.group ? (
7925
- <div className="settings-nav-group-label">{tab.group}</div>
7926
- ) : null}
7967
+ {onCancel ? (
7927
7968
  <button
7928
7969
  type="button"
7929
- className={`settings-nav-button ${settingsTab === tab.id ? "is-active" : ""}`}
7930
- aria-current={settingsTab === tab.id ? "page" : undefined}
7931
- onClick={() => setSettingsTab(tab.id)}
7970
+ className="flex min-h-8 w-full items-center gap-2 rounded-md px-2 text-base font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
7971
+ onClick={onCancel}
7932
7972
  >
7933
- {tab.label}
7973
+ <IconArrowLeft
7974
+ className="size-4 shrink-0"
7975
+ stroke={1.85}
7976
+ aria-hidden="true"
7977
+ />
7978
+ Back to app
7934
7979
  </button>
7935
- </div>
7936
- ))}
7937
- </nav>
7938
- <main className="settings-page-content" tabIndex={-1}>
7939
- <div className="settings-tab-heading">
7940
- <h3>{activeSettingsTab.label}</h3>
7941
- <p>
7942
- {settingsTab === "general"
7943
- ? "Connection, app behavior, updates, and diagnostics."
7944
- : settingsTab === "recording"
7945
- ? "Capture behavior, permissions, drafts, and shortcuts."
7946
- : settingsTab === "meetings"
7947
- ? "Meeting reminders and live transcription behavior."
7948
- : "Local transcription and voice dictation controls."}
7949
- </p>
7950
- </div>
7951
- {renderSettingsTab()}
7952
- </main>
7953
- </div>
7954
- </div>
7955
- );
7956
- }
7957
-
7958
- function DesktopSettingsPopover({
7959
- title,
7960
- trigger,
7961
- children,
7962
- open,
7963
- onOpenChange,
7964
- side = "left",
7965
- contentClassName,
7966
- }: {
7967
- title: string;
7968
- trigger: ReactElement;
7969
- children: ReactNode;
7970
- open: boolean;
7971
- onOpenChange: (open: boolean) => void;
7972
- side?: "bottom" | "left" | "right" | "top";
7973
- contentClassName?: string;
7974
- }) {
7975
- return (
7976
- <PopoverPrimitive.Root open={open} onOpenChange={onOpenChange}>
7977
- <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>
7978
- <PopoverPrimitive.Content
7979
- side={side}
7980
- align="start"
7981
- sideOffset={10}
7982
- collisionPadding={12}
7983
- className={`desktop-settings-popover ${contentClassName ?? ""}`}
7984
- data-popover-overlay="true"
7985
- >
7986
- <div className="desktop-settings-popover-header">
7987
- <div
7988
- className="desktop-settings-popover-title"
7989
- role="heading"
7990
- aria-level={2}
7991
- >
7992
- {title}
7980
+ ) : null}
7993
7981
  </div>
7994
- <PopoverPrimitive.Close
7995
- type="button"
7996
- className="desktop-settings-popover-close"
7997
- aria-label={`Close ${title}`}
7998
- >
7999
- <IconX size={15} stroke={1.9} />
8000
- </PopoverPrimitive.Close>
8001
- </div>
8002
- {children}
8003
- </PopoverPrimitive.Content>
8004
- </PopoverPrimitive.Root>
8005
- );
8006
- }
8007
-
8008
- function RewindChoicePopover({
8009
- title,
8010
- value,
8011
- options,
8012
- onChange,
8013
- disabled = false,
8014
- }: {
8015
- title: string;
8016
- value: string;
8017
- options: Array<{ value: string; label: string }>;
8018
- onChange: (value: string) => void;
8019
- disabled?: boolean;
8020
- }) {
8021
- const [open, setOpen] = useState(false);
8022
- const selectedOption =
8023
- options.find((option) => option.value === value)?.label ?? value;
8024
-
8025
- return (
8026
- <DesktopSettingsPopover
8027
- title={title}
8028
- open={open}
8029
- onOpenChange={setOpen}
8030
- side="bottom"
8031
- trigger={
8032
- <button
8033
- type="button"
8034
- className="rewind-setting-trigger"
8035
- disabled={disabled}
8036
- aria-haspopup="listbox"
8037
- aria-expanded={open}
8038
- >
8039
- <span>{selectedOption}</span>
8040
- <IconChevronDown size={15} stroke={1.8} aria-hidden="true" />
8041
- </button>
8042
- }
8043
- >
8044
- <div className="rewind-choice-list" role="listbox" aria-label={title}>
8045
- {options.map((option) => {
8046
- const selected = option.value === value;
8047
- return (
7982
+ {settingsTabs.map((tab) => (
8048
7983
  <button
7984
+ key={tab.id}
8049
7985
  type="button"
8050
- role="option"
8051
- aria-selected={selected}
8052
- className={`rewind-choice-option ${selected ? "is-selected" : ""}`}
8053
- key={option.value}
8054
- onClick={() => {
8055
- onChange(option.value);
8056
- setOpen(false);
8057
- }}
7986
+ className={cn(
7987
+ "flex min-h-8 w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-base font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
7988
+ settingsTab === tab.id
7989
+ ? "bg-accent text-foreground"
7990
+ : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
7991
+ )}
7992
+ aria-current={settingsTab === tab.id ? "page" : undefined}
7993
+ onClick={() => setSettingsTab(tab.id)}
8058
7994
  >
8059
- <span>{option.label}</span>
8060
- {selected ? <IconCheck size={15} stroke={2.1} /> : null}
7995
+ {tab.icon}
7996
+ <span className="flex-1 truncate">{tab.label}</span>
7997
+ {tab.alert ? (
7998
+ <span
7999
+ className="size-1.5 shrink-0 rounded-full bg-info"
8000
+ role="img"
8001
+ aria-label="Update ready"
8002
+ />
8003
+ ) : null}
8061
8004
  </button>
8062
- );
8063
- })}
8064
- </div>
8065
- </DesktopSettingsPopover>
8066
- );
8067
- }
8068
-
8069
- function DesktopSettingsGroup({
8070
- title,
8071
- description,
8072
- children,
8073
- }: {
8074
- title: string;
8075
- description?: string;
8076
- children: ReactNode;
8077
- }) {
8078
- return (
8079
- <section className="settings-group">
8080
- <header className="settings-group-header">
8081
- <h4>{title}</h4>
8082
- {description ? <p>{description}</p> : null}
8083
- </header>
8084
- <div className="settings-group-body">{children}</div>
8085
- </section>
8086
- );
8087
- }
8088
-
8089
- function DesktopSettingsRow({
8090
- label,
8091
- description,
8092
- control,
8093
- children,
8094
- extraClassName,
8095
- stacked = false,
8096
- }: {
8097
- label: string;
8098
- description: ReactNode;
8099
- control?: ReactNode;
8100
- children?: ReactNode;
8101
- extraClassName?: string;
8102
- stacked?: boolean;
8103
- }) {
8104
- return (
8105
- <div className={`settings-row ${stacked ? "settings-row--stacked" : ""}`}>
8106
- <div className="settings-row-copy">
8107
- <strong>{label}</strong>
8108
- <span>{description}</span>
8005
+ ))}
8006
+ </nav>
8007
+ <main
8008
+ data-popover-scroll-region
8009
+ className="min-h-0 min-w-0 overflow-y-auto overscroll-contain px-5 pb-6 pt-5"
8010
+ tabIndex={-1}
8011
+ >
8012
+ {/* Title only, and the title deliberately repeats the nav item —
8013
+ normally the first thing the default-chrome rule deletes. This
8014
+ surface is a macOS System Settings idiom (owner-approved
8015
+ mockups), where the pane restating the selected sidebar item is
8016
+ the platform convention; a subtitle under it is still banned. */}
8017
+ <h3 className="mx-auto mb-6 max-w-[620px] text-2xl font-semibold tracking-[-0.02em]">
8018
+ {activeSettingsTab.label}
8019
+ </h3>
8020
+ {renderSettingsTab()}
8021
+ </main>
8109
8022
  </div>
8110
- {control ? <div className="settings-row-control">{control}</div> : null}
8111
- {children ? (
8112
- <div className={`settings-row-extra ${extraClassName ?? ""}`}>
8113
- {children}
8114
- </div>
8115
- ) : null}
8116
8023
  </div>
8117
8024
  );
8118
8025
  }
8119
8026
 
8120
- function SettingLabel({
8121
- label,
8122
- hint,
8123
- htmlFor,
8124
- }: {
8125
- label: string;
8126
- hint: string;
8127
- htmlFor?: string;
8128
- }) {
8129
- return (
8130
- <label className="setup-label" htmlFor={htmlFor}>
8131
- <span>{label}</span>
8132
- <Tooltip>
8133
- <TooltipTrigger asChild>
8134
- <button type="button" className="setup-help" aria-label={hint}>
8135
- <IconInfoCircle size={14} stroke={1.75} />
8136
- </button>
8137
- </TooltipTrigger>
8138
- <TooltipContent>{hint}</TooltipContent>
8139
- </Tooltip>
8140
- </label>
8141
- );
8142
- }
8143
-
8144
- function WhisperModelPicker({
8145
- models,
8146
- modelId,
8147
- onChange,
8148
- disabled,
8149
- }: {
8150
- models: WhisperModelOption[];
8151
- modelId: string;
8152
- onChange: (modelId: string) => void;
8153
- disabled: boolean;
8154
- }) {
8155
- const [open, setOpen] = useState(false);
8156
- const selectedModel = models.find((model) => model.id === modelId);
8157
-
8158
- return (
8159
- <PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
8160
- <PopoverPrimitive.Trigger asChild>
8161
- <button
8162
- type="button"
8163
- className="whisper-model-picker-trigger"
8164
- disabled={disabled}
8165
- aria-haspopup="listbox"
8166
- aria-expanded={open}
8167
- >
8168
- <span className="whisper-model-picker-trigger-label">
8169
- {selectedModel
8170
- ? whisperModelOptionLabel(selectedModel)
8171
- : "No models available"}
8172
- </span>
8173
- <IconChevronDown size={15} stroke={1.8} aria-hidden="true" />
8174
- </button>
8175
- </PopoverPrimitive.Trigger>
8176
- <PopoverPrimitive.Portal>
8177
- <PopoverPrimitive.Content
8178
- side="bottom"
8179
- align="end"
8180
- sideOffset={6}
8181
- collisionPadding={12}
8182
- className="whisper-model-picker"
8183
- data-popover-overlay="true"
8184
- >
8185
- <div
8186
- className="whisper-model-picker-list"
8187
- role="listbox"
8188
- aria-label="Whisper model"
8189
- >
8190
- {models.map((model) => {
8191
- const selected = model.id === modelId;
8192
- return (
8193
- <button
8194
- key={model.id}
8195
- type="button"
8196
- role="option"
8197
- aria-selected={selected}
8198
- className={`whisper-model-picker-option ${selected ? "is-selected" : ""}`}
8199
- onClick={() => {
8200
- onChange(model.id);
8201
- setOpen(false);
8202
- }}
8203
- >
8204
- <span className="whisper-model-picker-option-title">
8205
- <span>{whisperModelOptionLabel(model)}</span>
8206
- {selected ? <IconCheck size={15} stroke={2.1} /> : null}
8207
- </span>
8208
- <span className="whisper-model-picker-option-description">
8209
- {model.description}
8210
- </span>
8211
- </button>
8212
- );
8213
- })}
8214
- </div>
8215
- </PopoverPrimitive.Content>
8216
- </PopoverPrimitive.Portal>
8217
- </PopoverPrimitive.Root>
8218
- );
8219
- }
8220
-
8027
+ /**
8028
+ * The one row that reports real state rather than offering a control. Tailwind,
8029
+ * not the old `.whisper-status` CSS, because it renders inside the settings
8030
+ * tree where the scoped preflight subset strips hand-written button chrome.
8031
+ */
8221
8032
  function WhisperModelStatusRow({
8222
8033
  status,
8223
8034
  enabled,
@@ -8234,26 +8045,21 @@ function WhisperModelStatusRow({
8234
8045
  }) {
8235
8046
  if (!enabled) {
8236
8047
  return (
8237
- <div className="whisper-status whisper-status-disabled">
8238
- <IconAlertTriangle size={13} className="whisper-status-icon" />
8239
- <span>
8240
- Without the Whisper model, only your microphone is transcribed — other
8241
- speakers are not captured.
8242
- </span>
8243
- </div>
8048
+ <p className="flex items-start gap-1.5">
8049
+ <IconAlertTriangle
8050
+ className="mt-px size-3.5 shrink-0 text-muted-foreground"
8051
+ stroke={1.9}
8052
+ aria-hidden="true"
8053
+ />
8054
+ <span>Without it, only your mic is transcribed</span>
8055
+ </p>
8244
8056
  );
8245
8057
  }
8246
8058
  if (!status) return null;
8247
8059
 
8248
- if (status.state === "ready") {
8249
- return (
8250
- <div className="whisper-status whisper-status-ready">
8251
- <IconCircleCheck size={13} className="whisper-status-icon" />
8252
- <span>Ready · {status.totalMb} MB</span>
8253
- <span className="whisper-status-path">{status.path}</span>
8254
- </div>
8255
- );
8256
- }
8060
+ // Nothing to say when the model is ready: the picker already shows which
8061
+ // model and how big it is, so a green "Ready · 141 MB" is the same fact twice.
8062
+ if (status.state === "ready") return null;
8257
8063
 
8258
8064
  if (status.state === "downloading") {
8259
8065
  const pct =
@@ -8261,32 +8067,41 @@ function WhisperModelStatusRow({
8261
8067
  ? Math.round((status.downloadedMb / status.totalMb) * 100)
8262
8068
  : 0;
8263
8069
  return (
8264
- <div className="whisper-status whisper-status-downloading">
8265
- <span className="whisper-progress-label">
8070
+ <div className="grid gap-1.5">
8071
+ <span>
8266
8072
  Downloading… {status.downloadedMb} / {status.totalMb} MB ({pct}%)
8267
8073
  </span>
8268
- <progress
8269
- className="whisper-progress-bar"
8270
- value={pct}
8271
- max={100}
8074
+ <div
8075
+ className="h-1 overflow-hidden rounded-full bg-muted"
8076
+ role="progressbar"
8077
+ aria-valuenow={pct}
8078
+ aria-valuemin={0}
8079
+ aria-valuemax={100}
8272
8080
  aria-label={`Whisper model download ${pct}% complete`}
8273
- />
8081
+ >
8082
+ <div
8083
+ className="h-full rounded-full bg-foreground transition-[width] duration-200"
8084
+ style={{ width: `${pct}%` }}
8085
+ />
8086
+ </div>
8274
8087
  </div>
8275
8088
  );
8276
8089
  }
8277
8090
 
8278
8091
  // "missing" state
8279
8092
  return (
8280
- <div className="whisper-status whisper-status-missing">
8281
- <IconAlertTriangle size={13} className="whisper-status-icon" />
8282
- <span>Model not downloaded.</span>
8283
- <button
8284
- type="button"
8285
- className="whisper-download-btn"
8286
- onClick={onDownload}
8287
- >
8093
+ <div className="flex flex-wrap items-center gap-2">
8094
+ <span className="flex items-center gap-1.5">
8095
+ <IconAlertTriangle
8096
+ className="size-3.5 shrink-0 text-muted-foreground"
8097
+ stroke={1.9}
8098
+ aria-hidden="true"
8099
+ />
8100
+ Not downloaded yet
8101
+ </span>
8102
+ <SettingsActionButton onClick={onDownload}>
8288
8103
  Download now
8289
- </button>
8104
+ </SettingsActionButton>
8290
8105
  </div>
8291
8106
  );
8292
8107
  }
@@ -8354,11 +8169,10 @@ function ShortcutRecorder({
8354
8169
  }
8355
8170
 
8356
8171
  return (
8357
- <div className="setup-shortcut-row">
8358
- <button
8172
+ <div className="flex flex-wrap items-center justify-end gap-1.5">
8173
+ <SettingsKeycap
8359
8174
  ref={buttonRef}
8360
- type="button"
8361
- className={`setup-shortcut-recorder ${recording ? "recording" : ""}`}
8175
+ active={recording}
8362
8176
  onClick={(event) => {
8363
8177
  if (recording) {
8364
8178
  event.preventDefault();
@@ -8390,7 +8204,7 @@ function ShortcutRecorder({
8390
8204
  if (!next) {
8391
8205
  setError(
8392
8206
  event.key === " " && !hasShortcutModifier(event)
8393
- ? "Space needs Cmd, Ctrl, Option, or Shift so it does not hijack typing."
8207
+ ? "Space needs Cmd, Ctrl, Option, or Shift so it doesn't hijack typing."
8394
8208
  : "Use at least one modifier plus a key.",
8395
8209
  );
8396
8210
  return;
@@ -8407,15 +8221,14 @@ function ShortcutRecorder({
8407
8221
  }}
8408
8222
  >
8409
8223
  {recording
8410
- ? "Press shortcut..."
8224
+ ? "Press shortcut"
8411
8225
  : value
8412
- ? humanReadableShortcutLabel(value)
8226
+ ? compactShortcutLabel(value)
8413
8227
  : placeholder}
8414
- </button>
8228
+ </SettingsKeycap>
8415
8229
  {value ? (
8416
- <button
8417
- type="button"
8418
- className="setup-shortcut-clear"
8230
+ <SettingsActionButton
8231
+ emphasis="quiet"
8419
8232
  onClick={() => {
8420
8233
  onChange("");
8421
8234
  setError(null);
@@ -8423,11 +8236,16 @@ function ShortcutRecorder({
8423
8236
  }}
8424
8237
  >
8425
8238
  Clear
8426
- </button>
8239
+ </SettingsActionButton>
8240
+ ) : null}
8241
+ {error ? (
8242
+ <p className="w-full text-right text-xs text-destructive">{error}</p>
8427
8243
  ) : null}
8428
- {error ? <p className="setup-warning">{error}</p> : null}
8429
8244
  {saved && !error ? (
8430
- <p className="setup-success" aria-live="polite">
8245
+ <p
8246
+ className="w-full text-right text-xs text-success"
8247
+ aria-live="polite"
8248
+ >
8431
8249
  Saved
8432
8250
  </p>
8433
8251
  ) : null}