@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/changelog/2026-08-19-desktop-settings-are-tighter-with-a-new-advanced-tab.md +6 -0
- package/corpus/templates/clips/desktop/README.md +79 -1
- package/corpus/templates/clips/desktop/components.json +20 -0
- package/corpus/templates/clips/desktop/package.json +10 -1
- package/corpus/templates/clips/desktop/src/app.tsx +1812 -1994
- package/corpus/templates/clips/desktop/src/components/ReadinessPanel.tsx +23 -65
- package/corpus/templates/clips/desktop/src/components/settings/settings-ui.tsx +399 -0
- package/corpus/templates/clips/desktop/src/components/ui/alert-dialog.tsx +166 -0
- package/corpus/templates/clips/desktop/src/components/ui/button.tsx +57 -0
- package/corpus/templates/clips/desktop/src/components/ui/empty.tsx +104 -0
- package/corpus/templates/clips/desktop/src/components/ui/input.tsx +22 -0
- package/corpus/templates/clips/desktop/src/components/ui/popover.tsx +38 -0
- package/corpus/templates/clips/desktop/src/components/ui/select.tsx +164 -0
- package/corpus/templates/clips/desktop/src/components/ui/skeleton.tsx +15 -0
- package/corpus/templates/clips/desktop/src/components/ui/spinner.tsx +17 -0
- package/corpus/templates/clips/desktop/src/components/ui/switch.tsx +27 -0
- package/corpus/templates/clips/desktop/src/components/ui/textarea.tsx +22 -0
- package/corpus/templates/clips/desktop/src/dev/browser-preview.ts +65 -0
- package/corpus/templates/clips/desktop/src/hooks/useSystemAccessRows.ts +128 -0
- package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +0 -2
- package/corpus/templates/clips/desktop/src/lib/permission-status.ts +84 -0
- package/corpus/templates/clips/desktop/src/lib/rewind-status.ts +6 -5
- package/corpus/templates/clips/desktop/src/lib/settings-navigation.ts +18 -5
- package/corpus/templates/clips/desktop/src/lib/utils.ts +6 -0
- package/corpus/templates/clips/desktop/src/main.tsx +31 -4
- package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +2 -2
- package/corpus/templates/clips/desktop/src/overlays/toolbar.tsx +2 -2
- package/corpus/templates/clips/desktop/src/styles.css +75 -1427
- package/corpus/templates/clips/desktop/src/tailwind.css +282 -0
- package/corpus/templates/clips/desktop/tsconfig.json +4 -1
- package/corpus/templates/clips/desktop/vite.config.ts +7 -1
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +1 -0
- package/corpus/templates/content/app/global.css +5 -0
- package/corpus/templates/content/docs/solutions/2026-08-20-code-block-slash-production-regression-shape.md +492 -0
- package/corpus/templates/content/docs/solutions/2026-08-20-code-block-surface-background-shape.md +273 -0
- package/dist/deploy/build.js +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/action-routes.js +3 -2
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/request-origin.d.ts +6 -0
- package/dist/server/request-origin.js +12 -0
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/shared/mcp-embed-headers.d.ts +1 -1
- package/dist/shared/mcp-embed-headers.js +10 -3
- 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
|
-
|
|
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#
|
|
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
|
-
|
|
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
|
-
|
|
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>(
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
? "
|
|
2041
|
-
: "Google sign-in timed out.
|
|
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
|
-
? "
|
|
2045
|
-
: "Google sign-in
|
|
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
|
-
? "
|
|
2103
|
-
: "
|
|
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
|
-
: "
|
|
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 ||
|
|
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" ?
|
|
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
|
-
: "
|
|
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
|
|
3707
|
-
rolling Rewind archive
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
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
|
-
|
|
3773
|
-
|
|
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
|
-
|
|
3779
|
-
|
|
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 || "
|
|
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"
|
|
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=
|
|
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
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
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
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
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(
|
|
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
|
|
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
|
-
<
|
|
4867
|
-
<div className="signin-
|
|
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="
|
|
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
|
-
<
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
|
|
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
|
-
|
|
5261
|
+
setEmail(e.target.value);
|
|
4900
5262
|
setError(null);
|
|
5263
|
+
setFieldErrors((current) => ({ ...current, email: undefined }));
|
|
4901
5264
|
}}
|
|
4902
|
-
required
|
|
4903
5265
|
/>
|
|
4904
|
-
|
|
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"
|
|
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 [
|
|
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 [,
|
|
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
|
|
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
|
-
...
|
|
6019
|
+
...previous,
|
|
5635
6020
|
...patch,
|
|
5636
6021
|
};
|
|
5637
6022
|
const version = ++screenMemoryMutationVersionRef.current;
|
|
5638
6023
|
screenMemoryMutationRef.current += 1;
|
|
5639
|
-
screenMemoryRef.current =
|
|
5640
|
-
setScreenMemory(
|
|
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
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
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 ?? "
|
|
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
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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:
|
|
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 ?? "
|
|
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 ?? "
|
|
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:
|
|
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 ?? "
|
|
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
|
-
|
|
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
|
|
6078
|
-
|
|
6079
|
-
|
|
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(
|
|
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 ?? "
|
|
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 ?? "
|
|
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
|
-
: "
|
|
6592
|
+
: "Cleanup is off until Builder.io is connected.";
|
|
6182
6593
|
}
|
|
6183
6594
|
if (providerStatus[byokProvider]) return null;
|
|
6184
|
-
return
|
|
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
|
|
6193
|
-
?
|
|
6194
|
-
: updateStatus.state === "
|
|
6195
|
-
?
|
|
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
|
-
:
|
|
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
|
|
6636
|
+
if (surface === "memory") {
|
|
6207
6637
|
return (
|
|
6208
|
-
<div className="setup rewind-
|
|
6209
|
-
<div className="
|
|
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=
|
|
6230
|
-
onClick={
|
|
6642
|
+
className="setup-back"
|
|
6643
|
+
onClick={onCancel}
|
|
6644
|
+
aria-label="Back"
|
|
6231
6645
|
>
|
|
6232
|
-
<
|
|
6233
|
-
<span>Also remember your microphone and sound from the Mac</span>
|
|
6646
|
+
<IconArrowLeft size={18} stroke={1.75} />
|
|
6234
6647
|
</button>
|
|
6235
|
-
|
|
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
|
-
|
|
6295
|
-
|
|
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
|
-
|
|
6403
|
-
|
|
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
|
-
|
|
6414
|
-
|
|
6415
|
-
|
|
6416
|
-
|
|
6417
|
-
|
|
6418
|
-
|
|
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
|
-
|
|
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="
|
|
6439
|
-
<
|
|
6440
|
-
<
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
<
|
|
6452
|
-
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
|
|
6458
|
-
|
|
6459
|
-
|
|
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
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
|
|
6473
|
-
|
|
6474
|
-
|
|
6475
|
-
|
|
6476
|
-
|
|
6477
|
-
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
6487
|
-
|
|
6488
|
-
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
6495
|
-
|
|
6496
|
-
|
|
6497
|
-
|
|
6498
|
-
|
|
6499
|
-
|
|
6500
|
-
|
|
6501
|
-
|
|
6502
|
-
|
|
6503
|
-
|
|
6504
|
-
|
|
6505
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
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
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
6523
|
-
|
|
6524
|
-
|
|
6525
|
-
|
|
6526
|
-
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
6535
|
-
|
|
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
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
6553
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
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
|
-
</
|
|
6565
|
-
|
|
7119
|
+
</SettingsGroup>
|
|
7120
|
+
|
|
7121
|
+
{rewindOn ? (
|
|
6566
7122
|
<>
|
|
6567
|
-
<
|
|
6568
|
-
|
|
6569
|
-
|
|
6570
|
-
|
|
6571
|
-
|
|
6572
|
-
|
|
6573
|
-
|
|
6574
|
-
|
|
6575
|
-
|
|
6576
|
-
|
|
6577
|
-
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
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
|
-
<
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
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
|
-
|
|
7153
|
+
))}
|
|
6757
7154
|
</div>
|
|
6758
|
-
|
|
6759
|
-
</
|
|
6760
|
-
<
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
6764
|
-
|
|
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
|
-
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
|
|
6771
|
-
|
|
6772
|
-
|
|
6773
|
-
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
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
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6868
|
-
</
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
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
|
-
|
|
6896
|
-
</
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
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
|
-
|
|
6944
|
-
</
|
|
6945
|
-
|
|
6946
|
-
|
|
6947
|
-
|
|
6948
|
-
|
|
6949
|
-
|
|
6950
|
-
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
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
|
-
|
|
6968
|
-
|
|
6969
|
-
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
|
|
6974
|
-
|
|
6975
|
-
|
|
6976
|
-
|
|
6977
|
-
|
|
6978
|
-
|
|
6979
|
-
|
|
6980
|
-
|
|
6981
|
-
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
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
|
-
|
|
7003
|
-
</
|
|
7004
|
-
|
|
7005
|
-
|
|
7006
|
-
|
|
7007
|
-
|
|
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
|
|
7340
|
+
function renderAdvancedSettings() {
|
|
7128
7341
|
return (
|
|
7129
|
-
<div className="
|
|
7130
|
-
<
|
|
7131
|
-
|
|
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
|
|
7346
|
+
description="The server Clips signs in and uploads to"
|
|
7137
7347
|
control={
|
|
7138
|
-
<
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
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
|
-
</
|
|
7389
|
+
</SettingsGroup>
|
|
7158
7390
|
|
|
7159
|
-
<
|
|
7160
|
-
|
|
7161
|
-
|
|
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
|
-
<
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
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
|
-
|
|
7235
|
-
</
|
|
7503
|
+
{apiKeyMessage.text}
|
|
7504
|
+
</p>
|
|
7236
7505
|
) : null}
|
|
7237
|
-
</
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
</DesktopSettingsGroup>
|
|
7506
|
+
</SettingsRow>
|
|
7507
|
+
</>
|
|
7508
|
+
) : null}
|
|
7241
7509
|
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
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
|
-
<
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
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
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
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
|
-
<
|
|
7314
|
-
|
|
7315
|
-
|
|
7316
|
-
|
|
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
|
-
{
|
|
7383
|
-
<
|
|
7384
|
-
|
|
7385
|
-
|
|
7386
|
-
|
|
7387
|
-
|
|
7388
|
-
|
|
7389
|
-
|
|
7390
|
-
|
|
7391
|
-
|
|
7392
|
-
|
|
7393
|
-
|
|
7394
|
-
|
|
7395
|
-
|
|
7396
|
-
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7400
|
-
|
|
7401
|
-
|
|
7402
|
-
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
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} · {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
|
-
|
|
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
|
-
|
|
7418
|
-
<
|
|
7419
|
-
|
|
7420
|
-
|
|
7620
|
+
<>
|
|
7621
|
+
<SettingsSwitch
|
|
7622
|
+
checked={regionGuides.enabled}
|
|
7623
|
+
onCheckedChange={setRegionGuidesEnabled}
|
|
7421
7624
|
label="Show screen region guides while recording"
|
|
7422
7625
|
/>
|
|
7423
|
-
<
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
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
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
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
|
-
<
|
|
7444
|
-
|
|
7445
|
-
className="settings-text-button"
|
|
7644
|
+
<SettingsActionButton
|
|
7645
|
+
emphasis="quiet"
|
|
7446
7646
|
onClick={clearRegionGuidePreset}
|
|
7447
7647
|
>
|
|
7448
|
-
Clear
|
|
7449
|
-
</
|
|
7648
|
+
Clear preset
|
|
7649
|
+
</SettingsActionButton>
|
|
7450
7650
|
) : null}
|
|
7451
|
-
|
|
7651
|
+
</div>
|
|
7452
7652
|
) : null}
|
|
7453
|
-
</
|
|
7454
|
-
</
|
|
7653
|
+
</SettingsRow>
|
|
7654
|
+
</SettingsGroup>
|
|
7455
7655
|
|
|
7456
|
-
<
|
|
7457
|
-
|
|
7458
|
-
|
|
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
|
-
<
|
|
7465
|
-
|
|
7466
|
-
|
|
7467
|
-
|
|
7661
|
+
<SettingsSwitch
|
|
7662
|
+
checked={showInScreenCapture}
|
|
7663
|
+
onCheckedChange={setShowInScreenCapture}
|
|
7664
|
+
label="Show Clips in screen captures"
|
|
7468
7665
|
/>
|
|
7469
7666
|
}
|
|
7470
7667
|
/>
|
|
7471
|
-
<
|
|
7472
|
-
label="Open Clips"
|
|
7473
|
-
description="
|
|
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
|
-
|
|
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
|
-
<
|
|
7484
|
-
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
7490
|
-
|
|
7491
|
-
|
|
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="
|
|
7499
|
-
<
|
|
7500
|
-
|
|
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="
|
|
7748
|
+
description="Transcribes and summarizes meetings from your calendar"
|
|
7506
7749
|
control={
|
|
7507
|
-
<
|
|
7508
|
-
|
|
7509
|
-
|
|
7510
|
-
label="
|
|
7750
|
+
<SettingsSwitch
|
|
7751
|
+
checked={meetingsEnabled}
|
|
7752
|
+
onCheckedChange={setMeetingsEnabled}
|
|
7753
|
+
label="Meeting notes"
|
|
7511
7754
|
/>
|
|
7512
7755
|
}
|
|
7513
7756
|
/>
|
|
7514
7757
|
{meetingsEnabled ? (
|
|
7515
7758
|
<>
|
|
7516
|
-
<
|
|
7517
|
-
label="
|
|
7518
|
-
description="
|
|
7759
|
+
<SettingsRow
|
|
7760
|
+
label="When a meeting starts"
|
|
7761
|
+
description="Choose how note taking begins"
|
|
7519
7762
|
control={
|
|
7520
|
-
<
|
|
7521
|
-
|
|
7522
|
-
className="setup-select"
|
|
7763
|
+
<SettingsSelect
|
|
7764
|
+
ariaLabel="When a meeting starts"
|
|
7523
7765
|
value={meetingTranscriptionMode}
|
|
7524
|
-
|
|
7766
|
+
onValueChange={(value) =>
|
|
7525
7767
|
setMeetingTranscriptionMode(
|
|
7526
|
-
|
|
7768
|
+
value as MeetingTranscriptionMode,
|
|
7527
7769
|
)
|
|
7528
7770
|
}
|
|
7529
|
-
|
|
7530
|
-
|
|
7531
|
-
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
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
|
-
<
|
|
7539
|
-
label="Meeting
|
|
7540
|
-
description="Show
|
|
7779
|
+
<SettingsRow
|
|
7780
|
+
label="Meeting notifications"
|
|
7781
|
+
description="Show a notification before scheduled meetings and when a call is detected"
|
|
7541
7782
|
control={
|
|
7542
|
-
<
|
|
7543
|
-
|
|
7544
|
-
|
|
7545
|
-
label="
|
|
7783
|
+
<SettingsSwitch
|
|
7784
|
+
checked={showMeetingWidgetEnabled}
|
|
7785
|
+
onCheckedChange={setShowMeetingWidgetEnabled}
|
|
7786
|
+
label="Meeting notifications"
|
|
7546
7787
|
/>
|
|
7547
7788
|
}
|
|
7548
7789
|
/>
|
|
7549
7790
|
</>
|
|
7550
7791
|
) : null}
|
|
7551
|
-
</
|
|
7792
|
+
</SettingsGroup>
|
|
7552
7793
|
</div>
|
|
7553
7794
|
);
|
|
7554
7795
|
}
|
|
7555
7796
|
|
|
7556
7797
|
function renderDictationSettings() {
|
|
7557
7798
|
return (
|
|
7558
|
-
<div className="
|
|
7559
|
-
<
|
|
7560
|
-
|
|
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} · {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="
|
|
7803
|
+
description="Turns speech into polished text in any app"
|
|
7629
7804
|
control={
|
|
7630
|
-
<
|
|
7631
|
-
|
|
7632
|
-
|
|
7633
|
-
label="
|
|
7805
|
+
<SettingsSwitch
|
|
7806
|
+
checked={voiceEnabled}
|
|
7807
|
+
onCheckedChange={setVoiceEnabled}
|
|
7808
|
+
label="Voice dictation"
|
|
7634
7809
|
/>
|
|
7635
7810
|
}
|
|
7636
7811
|
/>
|
|
7637
7812
|
{voiceEnabled ? (
|
|
7638
7813
|
<>
|
|
7639
|
-
<
|
|
7640
|
-
label="
|
|
7641
|
-
description=
|
|
7814
|
+
<SettingsRow
|
|
7815
|
+
label="Shortcut"
|
|
7816
|
+
description="Shortcut to start dictation"
|
|
7642
7817
|
control={
|
|
7643
|
-
<
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
7648
|
-
|
|
7649
|
-
|
|
7650
|
-
|
|
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
|
-
|
|
7709
|
-
|
|
7710
|
-
|
|
7711
|
-
|
|
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
|
-
{
|
|
7752
|
-
<
|
|
7753
|
-
|
|
7754
|
-
|
|
7755
|
-
|
|
7756
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
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=
|
|
7860
|
+
description="Choose how the shortcut starts and stops dictation"
|
|
7841
7861
|
control={
|
|
7842
|
-
<
|
|
7843
|
-
|
|
7844
|
-
className="setup-select"
|
|
7862
|
+
<SettingsSelect
|
|
7863
|
+
ariaLabel="Dictation mode"
|
|
7845
7864
|
value={voiceMode}
|
|
7846
|
-
|
|
7847
|
-
onVoiceModeChange(
|
|
7865
|
+
onValueChange={(value) =>
|
|
7866
|
+
onVoiceModeChange(value as VoiceMode)
|
|
7848
7867
|
}
|
|
7849
|
-
|
|
7850
|
-
|
|
7851
|
-
|
|
7852
|
-
|
|
7853
|
-
|
|
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
|
-
</
|
|
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
|
-
|
|
7885
|
+
icon: ReactNode;
|
|
7886
|
+
alert?: boolean;
|
|
7868
7887
|
}> = [
|
|
7869
|
-
{
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
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
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7897
|
-
|
|
7898
|
-
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
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
|
-
|
|
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=
|
|
7930
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
|
|
8056
|
-
|
|
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
|
-
|
|
8060
|
-
|
|
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
|
-
|
|
8065
|
-
|
|
8066
|
-
|
|
8067
|
-
}
|
|
8068
|
-
|
|
8069
|
-
|
|
8070
|
-
|
|
8071
|
-
|
|
8072
|
-
|
|
8073
|
-
}
|
|
8074
|
-
|
|
8075
|
-
|
|
8076
|
-
|
|
8077
|
-
}
|
|
8078
|
-
|
|
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
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
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
|
-
<
|
|
8238
|
-
<IconAlertTriangle
|
|
8239
|
-
|
|
8240
|
-
|
|
8241
|
-
|
|
8242
|
-
|
|
8243
|
-
|
|
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
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
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="
|
|
8265
|
-
<span
|
|
8070
|
+
<div className="grid gap-1.5">
|
|
8071
|
+
<span>
|
|
8266
8072
|
Downloading… {status.downloadedMb} / {status.totalMb} MB ({pct}%)
|
|
8267
8073
|
</span>
|
|
8268
|
-
<
|
|
8269
|
-
className="
|
|
8270
|
-
|
|
8271
|
-
|
|
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="
|
|
8281
|
-
<
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
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
|
-
</
|
|
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="
|
|
8358
|
-
<
|
|
8172
|
+
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
|
8173
|
+
<SettingsKeycap
|
|
8359
8174
|
ref={buttonRef}
|
|
8360
|
-
|
|
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
|
|
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
|
-
?
|
|
8226
|
+
? compactShortcutLabel(value)
|
|
8413
8227
|
: placeholder}
|
|
8414
|
-
</
|
|
8228
|
+
</SettingsKeycap>
|
|
8415
8229
|
{value ? (
|
|
8416
|
-
<
|
|
8417
|
-
|
|
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
|
-
</
|
|
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
|
|
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}
|