1import { UiSettingsSyncSection } from "./UiSettingsSyncSection";
2import { performDesktopUpdate } from "../desktopUpdate.actions";
3import { useTerminalPlacement } from "~/hooks/useTerminalPlacement";
4import { SettingsGroup } from "./SettingsGroup";
5import { useScopedSettingsWriteAllowed } from "./useScopedSettings";
6import { Spinner } from "~/components/ui/spinner";
7import { NotificationSettings } from "./NotificationSettings";
8import { PRIVACY_POLICY_URL } from "../../legalLinks";
9import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react";
10import { Link, useNavigate } from "@tanstack/react-router";
11import type { CSSProperties, ReactNode } from "react";
12import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
13import {
14 type BackgroundActivityProfile,
15 type DesktopUpdateChannel,
16 ProviderDriverKind,
17 type ProviderInstanceId,
18 type ScopedThreadRef,
19 type SidebarProjectGroupingMode,
20} from "@t3tools/contracts";
21import { scopeThreadRef } from "@t3tools/client-runtime/environment";
22import { presentThreadShell } from "@t3tools/client-runtime/state/shell";
23import {
24 isAtomCommandInterrupted,
25 settlePromise,
26 squashAtomCommandFailure,
27} from "@t3tools/client-runtime/state/runtime";
28import {
29 DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
30 DEFAULT_UNIFIED_SETTINGS,
31 type ChatWidth,
32 type DiffLayout,
33 type EnvironmentIdentificationMode,
34 MAX_APPEARANCE_CONTRAST,
35 MAX_CODE_FONT_SIZE,
36 MAX_GLASS_OPACITY,
37 MAX_INTERFACE_FONT_SIZE,
38 MAX_PANEL_ANIMATION_DURATION_MS,
39 MAX_PROMPT_FONT_SIZE,
40 MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
41 MAX_TERMINAL_FONT_SIZE,
42 MIN_CODE_FONT_SIZE,
43 MIN_APPEARANCE_CONTRAST,
44 MIN_GLASS_OPACITY,
45 MIN_INTERFACE_FONT_SIZE,
46 MIN_PANEL_ANIMATION_DURATION_MS,
47 MIN_PROMPT_FONT_SIZE,
48 MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
49 type ResponseStreamingMode,
50 MIN_TERMINAL_FONT_SIZE,
51 type QuitConfirmationMode,
52 SidebarProjectSortOrder,
53} from "@t3tools/contracts/settings";
54import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
55import { createModelSelection } from "@t3tools/shared/model";
56import * as Duration from "effect/Duration";
57import * as Equal from "effect/Equal";
58import * as Schema from "effect/Schema";
59import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
60import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
61import { CliCommandSettingsRow } from "./CliCommandSettingsRow";
62import {
63 canCheckForUpdate,
64 getDesktopUpdateButtonTooltip,
65 isDesktopUpdateButtonDisabled,
66 resolveDesktopUpdateButtonAction,
67} from "../../components/desktopUpdate.logic";
68import { ProviderModelPicker } from "../chat/ProviderModelPicker";
69import { TraitsPicker } from "../chat/TraitsPicker";
70import {
71 resolveEnvironmentIdentificationPillLabel,
72 useEnvironmentStageLabel,
73} from "../SidebarStageBackdrop";
74import { isElectron } from "../../env";
75import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing";
76import { useCustomThemes } from "../../hooks/useCustomThemes";
77import {
78 readAppearanceModePreference,
79 readThemeHalves,
80 readThemePreference,
81 useTheme,
82} from "../../hooks/useTheme";
83import { useLocalStorage } from "../../hooks/useLocalStorage";
84import {
85 useScopedSettings,
86 useScopedSettingsMixed,
87 useUpdateScopedSettings,
88} from "./useScopedSettings";
89import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
90import { useSettingsScope } from "./SettingsScopeContext";
91import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
92import { useThreadActions } from "../../hooks/useThreadActions";
93import { useDesktopUpdateState } from "../../state/desktopUpdate";
94import {
95 getCustomModelOptionsByInstance,
96 resolveAppModelSelectionState,
97 selectsPlanAgent,
98} from "../../modelSelection";
99import {
100 applyProviderInstanceSettings,
101 deriveProviderInstanceEntries,
102 sortProviderInstanceEntries,
103} from "../../providerInstances";
104import { ensureLocalApi, readLocalApi } from "../../localApi";
105import { isMacPlatform } from "../../lib/utils";
106import { EMPTY_SERVER_PROVIDERS } from "../../state/server";
107import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState";
108import { formatRelativeTimeLabel } from "../../timestampFormat";
109import { Button } from "../ui/button";
110import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
111import {
112 Dialog,
113 DialogDescription,
114 DialogFooter,
115 DialogHeader,
116 DialogPanel,
117 DialogPopup,
118 DialogTitle,
119} from "../ui/dialog";
120import { DraftInput } from "../ui/draft-input";
121import { Input } from "../ui/input";
122import {
123 DEFAULT_CODE_FONT_STACK,
124 DEFAULT_SANS_FONT_STACK,
125 isFontFamilyAvailable,
126 isMonospaceFamily,
127 resolveDefaultFamilyLabel,
128 resolveTerminalFontPreference,
129 resolveTerminalFontSizePreference,
130 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
131} from "../../appearanceFonts";
132import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews";
133import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker";
134import {
135 NumberField,
136 NumberFieldDecrement,
137 NumberFieldGroup,
138 NumberFieldIncrement,
139 NumberFieldInput,
140} from "../ui/number-field";
141import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
142import { Switch } from "../ui/switch";
143import { ScopedSwitch } from "./ScopedSwitch";
144import { stackedThreadToast, toastManager } from "../ui/toast";
145import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
146import { ThemeLibrary } from "./ThemeSettings";
147import {
148 backgroundActivityOverrideSettings,
149 backgroundActivitySharedPolicySettings,
150 durationToSeconds,
151 getChangedBrowserSettingLabels,
152 getChangedTypographySettingLabels,
153 normalizeIntervalSeconds,
154 PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
155 hasChangedBackgroundActivitySettings,
156 isProjectGroupingEnabled,
157 projectGroupingModeFromToggle,
158 readLastEnabledProjectGroupingMode,
159 rememberEnabledProjectGroupingMode,
160 resolveBackgroundActivityProfileOption,
161} from "./SettingsPanels.logic";
162import {
163 PolicyTooltip,
164 SETTINGS_PICKER_TRIGGER_CLASSNAME,
165 SettingResetButton,
166 SettingsPageContainer,
167 SettingsRow,
168 SettingsSection,
169 useSettingsSearchTarget,
170 useSettingsSearchTargetId,
171} from "./settingsLayout";
172import { searchableSetting } from "./settingsSearch";
173import { ProjectFavicon } from "../ProjectFavicon";
174import { PanelAnimationsPreview } from "./PanelAnimationsPreview";
175
176const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = {
177 artwork: "Artwork",
178 pill: "Version pill",
179 none: "None",
180};
181
182const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = {
183 turn: "Wait for the full response",
184 paragraph: "Show finished paragraphs",
185};
186
187const RESPONSE_STREAMING_MODE_DESCRIPTIONS: Record<ResponseStreamingMode, string> = {
188 turn: "Text appears once the agent finishes its turn.",
189 paragraph: "Each paragraph or code block appears as soon as it is complete.",
190};
191
192const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string> = {
193 updated_at: "Last user message",
194 created_at: "Created at",
195 manual: "Manual",
196};
197const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder);
198
199const TIMESTAMP_FORMAT_LABELS = {
200 locale: "System default",
201 "12-hour": "12-hour",
202 "24-hour": "24-hour",
203} as const;
204
205const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = {
206 comfortable: "Comfortable",
207 wide: "Wide",
208 full: "Full",
209};
210
211const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = {
212 stacked: "Stacked",
213 split: "Split",
214};
215
216const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = {
217 direct: "Direct",
218 hold: "Hold",
219 "double-click": "Double press",
220};
221
222const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = {
223 balanced: "Balanced",
224 performance: "Performance",
225 "battery-saver": "Battery saver",
226};
227
228type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced";
229
230const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = {
231 ...BACKGROUND_ACTIVITY_PROFILE_LABELS,
232 advanced: "Advanced",
233};
234
235const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = {
236 balanced: "Pauses probes for idle clients, locked hosts, or low power mode.",
237 performance: "Allows scoped background probes while any subscribed client remains connected.",
238 "battery-saver": "Also pauses background probes when the host or client is on battery.",
239};
240
241const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals.";
242
243const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
244const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
245 readonly key:
246 | "pauseWhenHostLocked"
247 | "pauseWhenHostLowPower"
248 | "pauseWhenClientLowPower"
249 | "pauseWhenOnBattery";
250 readonly label: string;
251}> = [
252 { key: "pauseWhenHostLocked", label: "Pause when host is locked" },
253 { key: "pauseWhenHostLowPower", label: "Pause on host low power" },
254 { key: "pauseWhenClientLowPower", label: "Pause on client low power" },
255 { key: "pauseWhenOnBattery", label: "Pause on battery" },
256];
257
258function resetBackgroundActivitySettings() {
259 return {
260 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
261 };
262}
263
264function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) {
265 return {
266 backgroundActivity: {
267 schemaVersion: 1 as const,
268 profile,
269 overrides: {},
270 },
271 };
272}
273
274function AboutVersionTitle() {
275 const updateState = useDesktopUpdateState();
276 const version = updateState?.uiVersion ?? updateState?.currentVersion ?? APP_VERSION;
277
278 return (
279 <span className="inline-flex items-baseline gap-2">
280 <span>Version</span>
281 <code className="text-2xs font-medium text-muted-foreground">{version}</code>
282 </span>
283 );
284}
285
286function AboutVersionSection() {
287 const updateState = useDesktopUpdateState();
288 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);
289 const [isUpdateActionPending, setIsUpdateActionPending] = useState(false);
290
291 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
292 const selectedUpdateChannel = updateState?.channel ?? "latest";
293 const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;
294 // Show the beta app links as soon as someone picks Nightly, before the update installs.
295 const showNightlyMobileBeta =
296 IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly");
297
298 const handleUpdateChannelChange = useCallback(
299 (channel: DesktopUpdateChannel) => {
300 const bridge = window.desktopBridge;
301 if (
302 !bridge ||
303 typeof bridge.setUpdateChannel !== "function" ||
304 channel === selectedUpdateChannel
305 ) {
306 return;
307 }
308
309 setIsChangingUpdateChannel(true);
310 void bridge
311 .setUpdateChannel(channel)
312 .catch((error: unknown) => {
313 toastManager.add(
314 stackedThreadToast({
315 type: "error",
316 title: "Could not change update track",
317 description: error instanceof Error ? error.message : "Update track change failed.",
318 }),
319 );
320 })
321 .finally(() => {
322 setIsChangingUpdateChannel(false);
323 });
324 },
325 [selectedUpdateChannel],
326 );
327
328 const handleButtonClick = useCallback(async () => {
329 const bridge = window.desktopBridge;
330 if (!bridge) return;
331
332 if (!updateState || isUpdateActionPending) return;
333 setIsUpdateActionPending(true);
334 try {
335 await performDesktopUpdate(bridge, updateState);
336 } finally {
337 setIsUpdateActionPending(false);
338 }
339 }, [isUpdateActionPending, updateState]);
340
341 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
342 const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
343 const buttonDisabled =
344 action === "none"
345 ? !canCheckForUpdate(updateState)
346 : isDesktopUpdateButtonDisabled(updateState);
347 const actionLabel: Record<string, string> = {
348 download: "Download",
349 install: updateState?.installKind === "ui" ? "Reload" : "Restart",
350 };
351 const statusLabel: Record<string, string> = {
352 checking: "Checking…",
353 downloading: "Downloading…",
354 "up-to-date": "Up to Date",
355 };
356 const buttonLabel =
357 actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates";
358 const description =
359 action === "download" || action === "install"
360 ? "Update available."
361 : "Current version of the application.";
362
363 return (
364 <>
365 <SettingsRow
366 id={searchableSetting("app-version").id}
367 title={<AboutVersionTitle />}
368 description={description}
369 control={
370 <Tooltip>
371 <TooltipTrigger
372 render={
373 <Button
374 size="sm"
375 variant="outline"
376 disabled={buttonDisabled || isUpdateActionPending}
377 onClick={handleButtonClick}
378 >
379 {buttonLabel}
380 </Button>
381 }
382 />
383 {buttonTooltip ? <TooltipPopup>{buttonTooltip}</TooltipPopup> : null}
384 </Tooltip>
385 }
386 />
387 {hasDesktopBridge ? (
388 <SettingsRow
389 {...searchableSetting("update-track")}
390 description="Use stable releases or nightly builds. Switch back anytime."
391 control={
392 <Select
393 value={selectedUpdateChannel}
394 onValueChange={(value) => {
395 handleUpdateChannelChange(value as DesktopUpdateChannel);
396 }}
397 >
398 <SelectTrigger
399 size="sm"
400 className="w-full sm:w-40"
401 aria-label="Update track"
402 disabled={isChangingUpdateChannel}
403 >
404 <SelectValue>
405 {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"}
406 </SelectValue>
407 </SelectTrigger>
408 <SelectPopup align="end" alignItemWithTrigger={false}>
409 <SelectItem hideIndicator value="latest">
410 Stable
411 </SelectItem>
412 <SelectItem hideIndicator value="nightly">
413 Nightly
414 </SelectItem>
415 </SelectPopup>
416 </Select>
417 }
418 />
419 ) : selectedHostedAppChannel ? (
420 <SettingsRow
421 {...searchableSetting("update-track")}
422 description="Switches the hosted app release channel."
423 control={
424 <Select
425 value={selectedHostedAppChannel}
426 onValueChange={(value) => {
427 if (value === selectedHostedAppChannel) return;
428 window.location.assign(
429 buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }),
430 );
431 }}
432 >
433 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Update track">
434 <SelectValue>{HOSTED_APP_CHANNEL_LABEL}</SelectValue>
435 </SelectTrigger>
436 <SelectPopup align="end" alignItemWithTrigger={false}>
437 <SelectItem hideIndicator value="latest">
438 Latest
439 </SelectItem>
440 <SelectItem hideIndicator value="nightly">
441 Nightly
442 </SelectItem>
443 </SelectPopup>
444 </Select>
445 }
446 />
447 ) : null}
448 {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null}
449 {hasDesktopBridge ? <CliCommandSettingsRow /> : null}
450 </>
451 );
452}
453
454export function useSettingsRestore(onRestored?: () => void) {
455 const {
456 theme,
457 setTheme,
458 followSystem,
459 setFollowSystem,
460 setThemeHalf,
461 clearThemeHalves,
462 themeHalves,
463 } = useTheme();
464 const settings = useScopedSettings();
465 const updateSettings = useUpdateScopedSettings();
466
467 const isTextGenerationModelDirty = !Equal.equals(
468 settings.textGenerationModelSelection ?? null,
469 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
470 );
471 const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings);
472
473 const changedSettingLabels = useMemo(
474 () => [
475 ...(theme !== "system" ? ["Theme"] : []),
476 ...(!followSystem ? ["Follow system"] : []),
477 ...(themeHalves !== null ? ["Theme mix"] : []),
478 ...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
479 ? ["Contrast"]
480 : []),
481 ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
482 ...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
483 ? ["Diff colors"]
484 : []),
485 ...(settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? ["Chat width"] : []),
486 ...(settings.panelAnimationDurationMs !== DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs
487 ? ["Panel animations"]
488 : []),
489 ...(settings.environmentIdentificationMode !==
490 DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode
491 ? ["Environment identification"]
492 : []),
493 ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
494 ? ["Time format"]
495 : []),
496 ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode
497 ? ["Thread notifications"]
498 : []),
499 ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled
500 ? ["In-app notifications"]
501 : []),
502 ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount
503 ? ["Visible threads"]
504 : []),
505 ...(settings.sidebarProjectGroupingMode !==
506 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode
507 ? ["Project Grouping"]
508 : []),
509 ...(settings.sidebarProjectSortOrder !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder
510 ? ["Project order"]
511 : []),
512 ...(settings.sidebarWorkingShelfEnabled !==
513 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled
514 ? ["Working section"]
515 : []),
516 ...(settings.sidebarAutoSettleAfterDays !==
517 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
518 ? ["Auto-settle inactive threads"]
519 : []),
520 ...(settings.sidebarAutoSettleKeepPullRequests !==
521 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests
522 ? ["Keep threads with a PR"]
523 : []),
524 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
525 ? ["Auto-settle merged threads"]
526 : []),
527 ...(settings.autoResumeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads
528 ? ["Auto-resume limited threads"]
529 : []),
530 ...(settings.snoozeLimitedThreads !== DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads
531 ? ["Snooze limited threads"]
532 : []),
533 ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []),
534 ...(settings.persistComposerContextStrip !==
535 DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip
536 ? ["Composer context"]
537 : []),
538 ...getChangedTypographySettingLabels(settings),
539 ...(settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed
540 ? ["Default diff file state"]
541 : []),
542 ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace
543 ? ["Diff whitespace changes"]
544 : []),
545 ...(settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? ["Diff layout"] : []),
546 ...(settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled
547 ? ["Proactive panels"]
548 : []),
549 ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu
550 ? ["Show skills in slash menu"]
551 : []),
552 ...(settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll
553 ? ["Collapse composer on scroll"]
554 : []),
555 ...(settings.composerRichTextEnabled !== DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled
556 ? ["Rich text composer"]
557 : []),
558 ...(settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? ["Send shortcut"] : []),
559 ...(settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior
560 ? ["Follow-up behavior"]
561 : []),
562 ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled
563 ? ["Context window indicator"]
564 : []),
565 ...(settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode
566 ? ["Response streaming"]
567 : []),
568 ...(settings.enableProviderUpdateChecks !==
569 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks
570 ? ["Provider update checks"]
571 : []),
572 ...(settings.continueThreadsAfterServerUpdate !==
573 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
574 ? ["Continue threads after restarts"]
575 : []),
576 ...(isBackgroundActivityDirty ? ["Background activity"] : []),
577 ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
578 ? ["New thread mode"]
579 : []),
580 ...(settings.newWorktreesStartFromOrigin !==
581 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin
582 ? ["New worktrees start from origin"]
583 : []),
584 ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory
585 ? ["Add project base directory"]
586 : []),
587 ...(settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin
588 ? ["Unpin confirmation"]
589 : []),
590 ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive
591 ? ["Archive confirmation"]
592 : []),
593 ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete
594 ? ["Delete confirmation"]
595 : []),
596 ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit shortcut"] : []),
597 ...(isTextGenerationModelDirty ? ["Text generation model"] : []),
598 ...getChangedBrowserSettingLabels(settings),
599 ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess
600 ? ["Agent browser access"]
601 : []),
602 ],
603 [
604 isTextGenerationModelDirty,
605 isBackgroundActivityDirty,
606 settings.browserDefaultViewport,
607 settings.browserDefaultZoomFactor,
608 settings.browserDefaultAppearance,
609 settings.browserRecordingFrameRate,
610 settings.browserRecordingShowKeyPresses,
611 settings.browserRecordingShowMousePresses,
612 settings.browserLinkTarget,
613 settings.browserAutoShowFloatingPreview,
614 settings.appearanceContrast,
615 settings.diffColorScheme,
616 settings.chatWidth,
617 settings.enableAgentBrowserAccess,
618 settings.confirmQuit,
619 settings.confirmThreadArchive,
620 settings.confirmThreadDelete,
621 settings.confirmThreadUnpin,
622 settings.composerCollapseOnScroll,
623 settings.composerRichTextEnabled,
624 settings.sendShortcut,
625 settings.followUpBehavior,
626 settings.addProjectBaseDirectory,
627 settings.defaultThreadEnvMode,
628 settings.newWorktreesStartFromOrigin,
629 settings.diffFilesCollapsed,
630 settings.diffIgnoreWhitespace,
631 settings.diffLayout,
632 settings.proactivePanelsEnabled,
633 settings.environmentIdentificationMode,
634 settings.contextWindowMeterEnabled,
635 settings.fontFamilyCode,
636 settings.fontFamilyComposer,
637 settings.fontFamilySans,
638 settings.fontFamilyTerminal,
639 settings.fontSizeCode,
640 settings.fontSizeInterface,
641 settings.fontSizePrompt,
642 settings.fontSizeTerminal,
643 settings.glassOpacity,
644 settings.panelAnimationDurationMs,
645 settings.responseStreamingMode,
646 settings.persistComposerContextStrip,
647 settings.enableProviderUpdateChecks,
648 settings.continueThreadsAfterServerUpdate,
649 settings.sidebarAutoSettleAfterDays,
650 settings.sidebarAutoSettleKeepPullRequests,
651 settings.sidebarAutoSettleOnMerge,
652 settings.autoResumeLimitedThreads,
653 settings.snoozeLimitedThreads,
654 settings.sidebarProjectGroupingMode,
655 settings.sidebarProjectSortOrder,
656 settings.sidebarWorkingShelfEnabled,
657 settings.sidebarThreadPreviewCount,
658 settings.showSkillsInSlashMenu,
659 settings.timestampFormat,
660 settings.notificationMode,
661 settings.inAppNotificationsEnabled,
662 settings.wordWrap,
663 followSystem,
664 theme,
665 themeHalves,
666 ],
667 );
668
669 const restoreDefaults = useCallback(async () => {
670 if (changedSettingLabels.length === 0) return;
671 const api = readLocalApi();
672 const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm(
673 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
674 "\n",
675 ),
676 { variant: "destructive" },
677 );
678 if (!confirmed) return;
679
680 // Only touch the theme keys that are actually dirty, so a theme-storage
681 // failure cannot block restoring unrelated settings. Preferences are
682 // re-read after the confirmation dialog: they may have changed (another
683 // tab, an OS flip) while it was open, and rollback must restore the live
684 // values rather than the ones captured at render time.
685 let previousTheme = theme;
686 try {
687 previousTheme = readThemePreference();
688 } catch {
689 // Storage is unreadable; the render-time value is the best rollback.
690 }
691 // The mix may have changed while the confirmation dialog was open; both
692 // the dirty check and the rollback must see the live value.
693 const liveHalves = readThemeHalves();
694 const needsThemeReset = previousTheme !== "system";
695 const needsMixReset = liveHalves !== null;
696 // Same for the appearance mode: trusting the render-time value would skip
697 // the reset and report success while a non-system mode stayed in storage.
698 const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system";
699 const notifyThemeRestoreFailure = () => {
700 toastManager.add(
701 stackedThreadToast({
702 type: "error",
703 title: "Couldn’t restore theme settings",
704 description: "Try again.",
705 }),
706 );
707 };
708 // Rollback restores the base preference first (which clears any mix) and
709 // then re-applies the captured mix on top, so no failure path can leave
710 // the pair of keys half-restored.
711 const previousHalves = liveHalves;
712 const rollbackThemeState = () => {
713 if (needsThemeReset) setTheme(previousTheme);
714 if (previousHalves?.light) setThemeHalf("light", previousHalves.light);
715 if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark);
716 };
717 if (needsThemeReset && !setTheme("system")) {
718 notifyThemeRestoreFailure();
719 return;
720 }
721 if (needsMixReset && !clearThemeHalves()) {
722 rollbackThemeState();
723 notifyThemeRestoreFailure();
724 return;
725 }
726 if (needsFollowSystemReset && !setFollowSystem(true)) {
727 rollbackThemeState();
728 notifyThemeRestoreFailure();
729 return;
730 }
731 updateSettings({
732 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
733 diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
734 chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
735 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
736 notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode,
737 inAppNotificationsEnabled: DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled,
738 wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap,
739 persistComposerContextStrip: DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip,
740 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
741 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
742 diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout,
743 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
744 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
745 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
746 composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
747 sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut,
748 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
749 contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled,
750 environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode,
751 glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity,
752 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
753 sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount,
754 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
755 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
756 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
757 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
758 sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests,
759 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
760 autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,
761 snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
762 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
763 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
764 continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
765 backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
766 backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
767 automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
768 providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval,
769 defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode,
770 newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
771 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
772 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
773 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
774 confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
775 confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit,
776 textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
777 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
778 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
779 fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode,
780 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,
781 fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface,
782 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
783 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
784 fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
785 browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
786 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
787 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
788 browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate,
789 browserRecordingShowKeyPresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowKeyPresses,
790 browserRecordingShowMousePresses: DEFAULT_UNIFIED_SETTINGS.browserRecordingShowMousePresses,
791 browserLinkTarget: DEFAULT_UNIFIED_SETTINGS.browserLinkTarget,
792 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
793 // Re-granted like any other default. The confirmation dialog lists it by
794 // name, so a user restoring defaults is told the agent regains access
795 // rather than discovering it later.
796 enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess,
797 });
798 onRestored?.();
799 }, [
800 changedSettingLabels,
801 clearThemeHalves,
802 onRestored,
803 setFollowSystem,
804 setTheme,
805 setThemeHalf,
806 theme,
807 themeHalves,
808 updateSettings,
809 ]);
810
811 return {
812 changedSettingLabels,
813 restoreDefaults,
814 };
815}
816
817function BackgroundActivityAdvancedDialog({
818 open,
819 onOpenChange,
820}: {
821 readonly open: boolean;
822 readonly onOpenChange: (open: boolean) => void;
823}) {
824 const canWriteSettings = useScopedSettingsWriteAllowed();
825 const settings = useScopedSettings();
826 const updateSettings = useUpdateScopedSettings();
827 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
828 const activeProfile = resolvedBackgroundActivity.profile;
829 const automaticGitFetchIntervalSeconds = durationToSeconds(
830 resolvedBackgroundActivity.automaticGitFetchInterval,
831 );
832 const providerHealthRefreshIntervalSeconds = durationToSeconds(
833 resolvedBackgroundActivity.providerHealthRefreshInterval,
834 );
835 const hostPowerMonitorActiveIntervalSeconds = durationToSeconds(
836 resolvedBackgroundActivity.hostPowerMonitorActiveInterval,
837 );
838 const hostPowerMonitorIdleIntervalSeconds = durationToSeconds(
839 resolvedBackgroundActivity.hostPowerMonitorIdleInterval,
840 );
841
842 return (
843 <Dialog open={open && canWriteSettings} onOpenChange={onOpenChange}>
844 <DialogPopup className="max-w-xl">
845 <DialogHeader>
846 <DialogTitle>Background Activity</DialogTitle>
847 <DialogDescription>
848 Tune the shared power policy and the background intervals that feed it.
849 </DialogDescription>
850 </DialogHeader>
851 <DialogPanel>
852 <fieldset
853 disabled={!canWriteSettings}
854 className="min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground"
855 >
856 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
857 <div className="min-w-0 space-y-1">
858 <div className="text-sm font-medium">Shared policy</div>
859 <p className="text-xs leading-relaxed text-muted-foreground">
860 Controls whether background work may run after a subscribed interval fires.
861 </p>
862 </div>
863 <Select
864 value={activeProfile}
865 onValueChange={(value) => {
866 if (
867 value === "balanced" ||
868 value === "performance" ||
869 value === "battery-saver"
870 ) {
871 updateSettings({
872 backgroundActivity: backgroundActivitySharedPolicySettings(settings, value),
873 });
874 }
875 }}
876 >
877 <SelectTrigger
878 size="sm"
879 className="w-full sm:w-40"
880 aria-label="Shared background policy"
881 >
882 <SelectValue>{BACKGROUND_ACTIVITY_PROFILE_LABELS[activeProfile]}</SelectValue>
883 </SelectTrigger>
884 <SelectPopup align="end" alignItemWithTrigger={false}>
885 <SelectItem hideIndicator value="balanced">
886 {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced}
887 </SelectItem>
888 <SelectItem hideIndicator value="performance">
889 {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance}
890 </SelectItem>
891 <SelectItem hideIndicator value="battery-saver">
892 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
893 </SelectItem>
894 </SelectPopup>
895 </Select>
896 </div>
897
898 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
899 <div className="min-w-0 space-y-1">
900 <div className="text-sm font-medium">
901 {searchableSetting("git-fetch-interval").title}
902 </div>
903 <p className="text-xs leading-relaxed text-muted-foreground">
904 Refresh remote branch status in the background.
905 </p>
906 </div>
907 <div className="flex shrink-0 items-center gap-2">
908 <NumberField
909 value={automaticGitFetchIntervalSeconds}
910 min={0}
911 step={5}
912 size="sm"
913 className="w-32"
914 onValueChange={(value) =>
915 updateSettings(
916 backgroundActivityOverrideSettings(
917 settings.backgroundActivity,
918 resolvedBackgroundActivity,
919 {
920 automaticGitFetchInterval: Duration.seconds(
921 normalizeIntervalSeconds(value),
922 ),
923 },
924 ),
925 )
926 }
927 >
928 <NumberFieldGroup>
929 <NumberFieldDecrement aria-label="Decrease Git fetch interval" />
930 <NumberFieldInput aria-label="Git fetch interval in seconds" />
931 <NumberFieldIncrement aria-label="Increase Git fetch interval" />
932 </NumberFieldGroup>
933 </NumberField>
934 <span className="text-xs text-muted-foreground">seconds</span>
935 </div>
936 </div>
937
938 <div className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
939 <div className="min-w-0 space-y-1">
940 <div className="text-sm font-medium">Provider health interval</div>
941 <p className="text-xs leading-relaxed text-muted-foreground">
942 Refresh provider availability, versions, and auth state. Model catalog updates
943 follow Provider update checks.
944 </p>
945 </div>
946 <div className="flex shrink-0 items-center gap-2">
947 <NumberField
948 value={providerHealthRefreshIntervalSeconds}
949 min={0}
950 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
951 size="sm"
952 className="w-32"
953 onValueChange={(value) =>
954 updateSettings(
955 backgroundActivityOverrideSettings(
956 settings.backgroundActivity,
957 resolvedBackgroundActivity,
958 {
959 providerHealthRefreshInterval: Duration.seconds(
960 normalizeIntervalSeconds(value),
961 ),
962 },
963 ),
964 )
965 }
966 >
967 <NumberFieldGroup>
968 <NumberFieldDecrement aria-label="Decrease provider health interval" />
969 <NumberFieldInput aria-label="Provider health interval in seconds" />
970 <NumberFieldIncrement aria-label="Increase provider health interval" />
971 </NumberFieldGroup>
972 </NumberField>
973 <span className="text-xs text-muted-foreground">seconds</span>
974 </div>
975 </div>
976
977 <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
978 <div className="min-w-0 space-y-1">
979 <div className="text-sm font-medium">Host power monitor</div>
980 <p className="text-xs leading-relaxed text-muted-foreground">
981 Poll host power state while clients are active.
982 </p>
983 </div>
984 <div className="flex shrink-0 items-center gap-2">
985 <NumberField
986 value={hostPowerMonitorActiveIntervalSeconds}
987 min={5}
988 step={5}
989 size="sm"
990 className="w-32"
991 onValueChange={(value) =>
992 updateSettings(
993 backgroundActivityOverrideSettings(
994 settings.backgroundActivity,
995 resolvedBackgroundActivity,
996 {
997 hostPowerMonitorActiveInterval: Duration.seconds(
998 normalizeIntervalSeconds(value, 5),
999 ),
1000 },
1001 ),
1002 )
1003 }
1004 >
1005 <NumberFieldGroup>
1006 <NumberFieldDecrement aria-label="Decrease active host power interval" />
1007 <NumberFieldInput aria-label="Active host power interval in seconds" />
1008 <NumberFieldIncrement aria-label="Increase active host power interval" />
1009 </NumberFieldGroup>
1010 </NumberField>
1011 <span className="text-xs text-muted-foreground">seconds</span>
1012 </div>
1013 </div>
1014
1015 <div className="flex flex-col gap-3 border-t px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
1016 <div className="min-w-0 space-y-1">
1017 <div className="text-sm font-medium">Idle host monitor</div>
1018 <p className="text-xs leading-relaxed text-muted-foreground">
1019 Poll host power state when no foreground client is active.
1020 </p>
1021 </div>
1022 <div className="flex shrink-0 items-center gap-2">
1023 <NumberField
1024 value={hostPowerMonitorIdleIntervalSeconds}
1025 min={5}
1026 step={30}
1027 size="sm"
1028 className="w-32"
1029 onValueChange={(value) =>
1030 updateSettings(
1031 backgroundActivityOverrideSettings(
1032 settings.backgroundActivity,
1033 resolvedBackgroundActivity,
1034 {
1035 hostPowerMonitorIdleInterval: Duration.seconds(
1036 normalizeIntervalSeconds(value, 5),
1037 ),
1038 },
1039 ),
1040 )
1041 }
1042 >
1043 <NumberFieldGroup>
1044 <NumberFieldDecrement aria-label="Decrease idle host power interval" />
1045 <NumberFieldInput aria-label="Idle host power interval in seconds" />
1046 <NumberFieldIncrement aria-label="Increase idle host power interval" />
1047 </NumberFieldGroup>
1048 </NumberField>
1049 <span className="text-xs text-muted-foreground">seconds</span>
1050 </div>
1051 </div>
1052
1053 <div className="grid gap-0 border-t sm:grid-cols-2">
1054 {BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => (
1055 <label
1056 key={key}
1057 className="flex items-center justify-between gap-3 border-b px-4 py-3 last:border-b-0 sm:border-r sm:even:border-r-0"
1058 >
1059 <span className="text-sm font-medium">{label}</span>
1060 <Switch
1061 checked={resolvedBackgroundActivity[key]}
1062 onCheckedChange={(checked) =>
1063 updateSettings(
1064 backgroundActivityOverrideSettings(
1065 settings.backgroundActivity,
1066 resolvedBackgroundActivity,
1067 {
1068 [key]: Boolean(checked),
1069 },
1070 ),
1071 )
1072 }
1073 aria-label={label}
1074 />
1075 </label>
1076 ))}
1077 </div>
1078 </fieldset>
1079 </DialogPanel>
1080 <DialogFooter>
1081 <Button
1082 variant="outline"
1083 disabled={!canWriteSettings}
1084 onClick={() => updateSettings(resetBackgroundActivitySettings())}
1085 >
1086 Reset all
1087 </Button>
1088 <Button onClick={() => onOpenChange(false)}>Done</Button>
1089 </DialogFooter>
1090 </DialogPopup>
1091 </Dialog>
1092 );
1093}
1094
1095export function AppearanceSettingsPanel() {
1096 const {
1097 appearanceMode,
1098 refreshTheme,
1099 resolvedTheme,
1100 setAppearanceMode,
1101 setTheme,
1102 setThemeHalf,
1103 theme,
1104 themeHalves,
1105 } = useTheme();
1106 const customThemes = useCustomThemes();
1107 const [isImportThemeOpen, setIsImportThemeOpen] = useState(false);
1108 const settings = useScopedSettings();
1109 const updateSettings = useUpdateScopedSettings();
1110 const environmentStageLabel = useEnvironmentStageLabel();
1111 const showEnvironmentIdentification =
1112 resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null;
1113 const glassOpacityRatio =
1114 (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY);
1115 const glassOpacitySliderStyle = {
1116 "--settings-slider-progress": `${glassOpacityRatio * 100}%`,
1117 "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`,
1118 } as CSSProperties;
1119 const appearanceContrastRatio =
1120 (settings.appearanceContrast - MIN_APPEARANCE_CONTRAST) /
1121 (MAX_APPEARANCE_CONTRAST - MIN_APPEARANCE_CONTRAST);
1122 const appearanceContrastSliderStyle = {
1123 "--settings-slider-progress": `${appearanceContrastRatio * 100}%`,
1124 "--settings-slider-fill-offset": `${0.5 - appearanceContrastRatio}rem`,
1125 } as CSSProperties;
1126 const panelAnimationDurationRatio =
1127 (settings.panelAnimationDurationMs - MIN_PANEL_ANIMATION_DURATION_MS) /
1128 (MAX_PANEL_ANIMATION_DURATION_MS - MIN_PANEL_ANIMATION_DURATION_MS);
1129 const panelAnimationDurationSliderStyle = {
1130 "--settings-slider-progress": `${panelAnimationDurationRatio * 100}%`,
1131 "--settings-slider-fill-offset": `${0.5 - panelAnimationDurationRatio}rem`,
1132 } as CSSProperties;
1133
1134 return (
1135 <SettingsPageContainer>
1136 <SettingsSection id="appearance" title="Colors & themes" variant="plain" hideTitle>
1137 <div id={searchableSetting("theme").id}>
1138 <ThemeLibrary
1139 appearanceMode={appearanceMode}
1140 customThemes={customThemes}
1141 initialAppearance={resolvedTheme}
1142 refreshTheme={refreshTheme}
1143 isImportOpen={isImportThemeOpen}
1144 setAppearanceMode={setAppearanceMode}
1145 setTheme={setTheme}
1146 setThemeHalf={setThemeHalf}
1147 theme={theme}
1148 themeHalves={themeHalves}
1149 onImportOpenChange={setIsImportThemeOpen}
1150 />
1151 </div>
1152 </SettingsSection>
1153
1154 <SettingsSection id="appearance-interface" title="Interface">
1155 <SettingsRow
1156 {...searchableSetting("setting-appearance-contrast")}
1157 description="Adjust the contrast of colors and borders across the interface."
1158 resetAction={
1159 settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast ? (
1160 <SettingResetButton
1161 label="contrast"
1162 onClick={() =>
1163 updateSettings({
1164 appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
1165 })
1166 }
1167 />
1168 ) : null
1169 }
1170 control={
1171 <div className="flex w-full items-center gap-3 sm:w-52">
1172 <output
1173 className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
1174 htmlFor="appearance-contrast"
1175 >
1176 {settings.appearanceContrast}%
1177 </output>
1178 <input
1179 aria-label="Contrast"
1180 className="settings-slider min-w-0 flex-1"
1181 id="appearance-contrast"
1182 max={MAX_APPEARANCE_CONTRAST}
1183 min={MIN_APPEARANCE_CONTRAST}
1184 onChange={(event) => {
1185 const appearanceContrast = Number(event.currentTarget.value);
1186 if (
1187 Number.isInteger(appearanceContrast) &&
1188 appearanceContrast >= MIN_APPEARANCE_CONTRAST &&
1189 appearanceContrast <= MAX_APPEARANCE_CONTRAST
1190 ) {
1191 updateSettings({ appearanceContrast });
1192 }
1193 }}
1194 step={5}
1195 style={appearanceContrastSliderStyle}
1196 type="range"
1197 value={settings.appearanceContrast}
1198 />
1199 </div>
1200 }
1201 />
1202
1203 <SettingsRow
1204 {...searchableSetting("setting-glass-opacity")}
1205 description="Higher values make menus, dialogs, and the composer more solid."
1206 resetAction={
1207 settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? (
1208 <SettingResetButton
1209 label="glass opacity"
1210 onClick={() =>
1211 updateSettings({ glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity })
1212 }
1213 />
1214 ) : null
1215 }
1216 control={
1217 <div className="flex w-full items-center gap-3 sm:w-52">
1218 <output
1219 className="min-w-12 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
1220 htmlFor="glass-opacity"
1221 >
1222 {settings.glassOpacity}%
1223 </output>
1224 <input
1225 aria-label="Glass opacity"
1226 className="settings-slider min-w-0 flex-1"
1227 id="glass-opacity"
1228 max={MAX_GLASS_OPACITY}
1229 min={MIN_GLASS_OPACITY}
1230 onChange={(event) => {
1231 const glassOpacity = Number(event.currentTarget.value);
1232 if (
1233 Number.isInteger(glassOpacity) &&
1234 glassOpacity >= MIN_GLASS_OPACITY &&
1235 glassOpacity <= MAX_GLASS_OPACITY
1236 ) {
1237 updateSettings({ glassOpacity });
1238 }
1239 }}
1240 step={5}
1241 style={glassOpacitySliderStyle}
1242 type="range"
1243 value={settings.glassOpacity}
1244 />
1245 </div>
1246 }
1247 />
1248
1249 {showEnvironmentIdentification ? (
1250 <SettingsRow
1251 {...searchableSetting("environment-identification")}
1252 description="Choose how Dev and Nightly environments are identified."
1253 resetAction={
1254 settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (
1255 <SettingResetButton
1256 label="environment identification"
1257 onClick={() =>
1258 updateSettings({
1259 environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE,
1260 })
1261 }
1262 />
1263 ) : null
1264 }
1265 control={
1266 <Select
1267 value={settings.environmentIdentificationMode}
1268 onValueChange={(value) => {
1269 if (value === "artwork" || value === "pill" || value === "none") {
1270 updateSettings({ environmentIdentificationMode: value });
1271 }
1272 }}
1273 >
1274 <SelectTrigger
1275 size="sm"
1276 className="w-full sm:w-40"
1277 aria-label="Environment identification"
1278 >
1279 <SelectValue>
1280 {ENVIRONMENT_IDENTIFICATION_LABELS[settings.environmentIdentificationMode]}
1281 </SelectValue>
1282 </SelectTrigger>
1283 <SelectPopup align="end" alignItemWithTrigger={false}>
1284 {Object.entries(ENVIRONMENT_IDENTIFICATION_LABELS).map(([value, label]) => (
1285 <SelectItem hideIndicator key={value} value={value}>
1286 {label}
1287 </SelectItem>
1288 ))}
1289 </SelectPopup>
1290 </Select>
1291 }
1292 />
1293 ) : null}
1294
1295 <SettingsRow
1296 {...searchableSetting("diff-color-scheme")}
1297 description="Choose colors for additions and deletions, including change counts."
1298 resetAction={
1299 settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme ? (
1300 <SettingResetButton
1301 label="diff colors"
1302 onClick={() =>
1303 updateSettings({ diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme })
1304 }
1305 />
1306 ) : null
1307 }
1308 control={
1309 <div className="w-full sm:w-40">
1310 <Select
1311 value={settings.diffColorScheme}
1312 onValueChange={(value) => {
1313 if (value === "red-green" || value === "blue-orange")
1314 updateSettings({ diffColorScheme: value });
1315 }}
1316 >
1317 <SelectTrigger size="sm" className="w-full min-w-0" aria-label="Diff colors">
1318 <span
1319 aria-hidden="true"
1320 className={
1321 settings.diffColorScheme === "blue-orange"
1322 ? "flex shrink-0 flex-row-reverse gap-1"
1323 : "flex shrink-0 gap-1"
1324 }
1325 >
1326 <span className="size-2 rounded-full bg-diff-deletion" />
1327 <span className="size-2 rounded-full bg-diff-addition" />
1328 </span>
1329 <SelectValue>
1330 {settings.diffColorScheme === "blue-orange" ? "Blue & orange" : "Red & green"}
1331 </SelectValue>
1332 </SelectTrigger>
1333 <SelectPopup align="end" alignItemWithTrigger={false}>
1334 <SelectItem value="red-green">Red & green (default)</SelectItem>
1335 <SelectItem value="blue-orange">Blue & orange</SelectItem>
1336 </SelectPopup>
1337 </Select>
1338 </div>
1339 }
1340 />
1341
1342 <SettingsRow
1343 {...searchableSetting("composer-context")}
1344 description="Keep branch and worktree controls below the composer after a thread starts."
1345 resetAction={
1346 settings.persistComposerContextStrip !==
1347 DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip ? (
1348 <SettingResetButton
1349 label="composer context"
1350 onClick={() =>
1351 updateSettings({
1352 persistComposerContextStrip:
1353 DEFAULT_UNIFIED_SETTINGS.persistComposerContextStrip,
1354 })
1355 }
1356 />
1357 ) : null
1358 }
1359 control={
1360 <Switch
1361 checked={settings.persistComposerContextStrip}
1362 onCheckedChange={(checked) =>
1363 updateSettings({ persistComposerContextStrip: Boolean(checked) })
1364 }
1365 aria-label="Keep composer context visible in active threads"
1366 />
1367 }
1368 />
1369
1370 <SettingsRow
1371 {...searchableSetting("chat-width")}
1372 description="Set how wide messages and the composer can grow on large screens."
1373 resetAction={
1374 settings.chatWidth !== DEFAULT_UNIFIED_SETTINGS.chatWidth ? (
1375 <SettingResetButton
1376 label="chat width"
1377 onClick={() => updateSettings({ chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth })}
1378 />
1379 ) : null
1380 }
1381 control={
1382 <div className="w-full sm:w-40">
1383 <Select
1384 value={settings.chatWidth}
1385 onValueChange={(value) => {
1386 if (value === "comfortable" || value === "wide" || value === "full")
1387 updateSettings({ chatWidth: value });
1388 }}
1389 >
1390 <SelectTrigger size="sm" className="w-full min-w-0" aria-label="Chat width">
1391 <SelectValue>{CHAT_WIDTH_LABELS[settings.chatWidth]}</SelectValue>
1392 </SelectTrigger>
1393 <SelectPopup align="end" alignItemWithTrigger={false}>
1394 <SelectItem value="comfortable">Comfortable (default)</SelectItem>
1395 <SelectItem value="wide">Wide</SelectItem>
1396 <SelectItem value="full">Full</SelectItem>
1397 </SelectPopup>
1398 </Select>
1399 </div>
1400 }
1401 />
1402 </SettingsSection>
1403
1404 <SettingsSection id="motion" title="Motion">
1405 <SettingsRow
1406 {...searchableSetting("panel-animations")}
1407 description="Set how fast panels open and close."
1408 control={
1409 <div className="grid w-full grid-cols-[5rem_minmax(0,1fr)] items-center gap-3 sm:w-auto sm:grid-cols-[7rem_13rem] sm:gap-4">
1410 <PanelAnimationsPreview durationMs={settings.panelAnimationDurationMs} />
1411 <div className="flex w-full items-center gap-3">
1412 <output
1413 className="min-w-16 rounded-md bg-muted px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-foreground"
1414 htmlFor="panel-animation-duration"
1415 >
1416 {settings.panelAnimationDurationMs} ms
1417 </output>
1418 <input
1419 aria-label="Panel animation duration"
1420 className="settings-slider min-w-0 flex-1"
1421 id="panel-animation-duration"
1422 max={MAX_PANEL_ANIMATION_DURATION_MS}
1423 min={MIN_PANEL_ANIMATION_DURATION_MS}
1424 onChange={(event) => {
1425 const panelAnimationDurationMs = Number(event.currentTarget.value);
1426 if (
1427 Number.isInteger(panelAnimationDurationMs) &&
1428 panelAnimationDurationMs >= MIN_PANEL_ANIMATION_DURATION_MS &&
1429 panelAnimationDurationMs <= MAX_PANEL_ANIMATION_DURATION_MS
1430 ) {
1431 updateSettings({ panelAnimationDurationMs });
1432 }
1433 }}
1434 step={25}
1435 style={panelAnimationDurationSliderStyle}
1436 type="range"
1437 value={settings.panelAnimationDurationMs}
1438 />
1439 </div>
1440 </div>
1441 }
1442 resetAction={
1443 settings.panelAnimationDurationMs !==
1444 DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs ? (
1445 <SettingResetButton
1446 label="panel animations"
1447 onClick={() =>
1448 updateSettings({
1449 panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs,
1450 })
1451 }
1452 />
1453 ) : null
1454 }
1455 />
1456 </SettingsSection>
1457
1458 <TypographySection />
1459 </SettingsPageContainer>
1460 );
1461}
1462
1463function subscribeToFontLoading(onChange: () => void) {
1464 document.fonts.addEventListener("loadingdone", onChange);
1465 document.fonts.addEventListener("loadingerror", onChange);
1466
1467 return () => {
1468 document.fonts.removeEventListener("loadingdone", onChange);
1469 document.fonts.removeEventListener("loadingerror", onChange);
1470 };
1471}
1472
1473function useFontDefaultFamilies() {
1474 const settings = useScopedSettings();
1475 const sans = useSyncExternalStore(
1476 subscribeToFontLoading,
1477 () => resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default",
1478 );
1479 const code = useSyncExternalStore(
1480 subscribeToFontLoading,
1481 () => resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace",
1482 );
1483
1484 return {
1485 sans,
1486 code,
1487 // The composer inherits whatever the interface preference resolves to.
1488 interfaceFamily: settings.fontFamilySans.trim() || sans,
1489 };
1490}
1491
1492function InterfaceFontRow({ preview }: { preview?: ReactNode }) {
1493 const settings = useScopedSettings();
1494 const updateSettings = useUpdateScopedSettings();
1495 const defaults = useFontDefaultFamilies();
1496 return (
1497 <FontFamilySettingsRow
1498 {...searchableSetting("interface-font")}
1499 description="Everything outside code blocks and the terminal."
1500 defaultFamily={defaults.sans}
1501 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilySans}
1502 value={settings.fontFamilySans}
1503 onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })}
1504 onReset={() =>
1505 updateSettings({
1506 fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans,
1507 fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface,
1508 })
1509 }
1510 size={{
1511 label: "Interface font size",
1512 min: MIN_INTERFACE_FONT_SIZE,
1513 max: MAX_INTERFACE_FONT_SIZE,
1514 value: settings.fontSizeInterface,
1515 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface,
1516 onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }),
1517 }}
1518 {...(preview !== undefined ? { preview } : {})}
1519 />
1520 );
1521}
1522
1523function PromptFontRow() {
1524 const settings = useScopedSettings();
1525 const updateSettings = useUpdateScopedSettings();
1526 const defaults = useFontDefaultFamilies();
1527 return (
1528 <FontFamilySettingsRow
1529 {...searchableSetting("prompt-font")}
1530 description="Only the box you write prompts in. Mono works well here."
1531 defaultFamily={defaults.interfaceFamily}
1532 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer}
1533 value={settings.fontFamilyComposer}
1534 onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })}
1535 onReset={() =>
1536 updateSettings({
1537 fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer,
1538 fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
1539 })
1540 }
1541 size={{
1542 label: "Prompt font size",
1543 min: MIN_PROMPT_FONT_SIZE,
1544 max: MAX_PROMPT_FONT_SIZE,
1545 value: settings.fontSizePrompt,
1546 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt,
1547 onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }),
1548 }}
1549 preview={<PromptFontPreview />}
1550 />
1551 );
1552}
1553
1554function CodeFontRow({
1555 title,
1556 description = "Code blocks, diffs, and file previews.",
1557 preview,
1558}: {
1559 title?: string;
1560 description?: string;
1561 preview?: ReactNode;
1562}) {
1563 const settings = useScopedSettings();
1564 const updateSettings = useUpdateScopedSettings();
1565 const defaults = useFontDefaultFamilies();
1566 return (
1567 <FontFamilySettingsRow
1568 {...searchableSetting("code-font")}
1569 {...(title !== undefined ? { title } : {})}
1570 description={description}
1571 defaultFamily={defaults.code}
1572 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyCode}
1573 value={settings.fontFamilyCode}
1574 onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })}
1575 onReset={() =>
1576 updateSettings({
1577 fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode,
1578 fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
1579 })
1580 }
1581 requireMonospace
1582 size={{
1583 label: "Code font size",
1584 min: MIN_CODE_FONT_SIZE,
1585 max: MAX_CODE_FONT_SIZE,
1586 value: settings.fontSizeCode,
1587 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode,
1588 onChange: (fontSizeCode) => updateSettings({ fontSizeCode }),
1589 }}
1590 preview={preview ?? <CodeFontPreview />}
1591 />
1592 );
1593}
1594
1595function TerminalFontRow() {
1596 const settings = useScopedSettings();
1597 const updateSettings = useUpdateScopedSettings();
1598 const defaults = useFontDefaultFamilies();
1599 return (
1600 <FontFamilySettingsRow
1601 {...searchableSetting("terminal-font")}
1602 description="Terminal output, independent from code blocks and diffs."
1603 defaultFamily={defaults.code}
1604 defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal}
1605 value={settings.fontFamilyTerminal}
1606 onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })}
1607 onReset={() =>
1608 updateSettings({
1609 fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal,
1610 fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
1611 })
1612 }
1613 requireMonospace
1614 size={{
1615 label: "Terminal font size",
1616 min: MIN_TERMINAL_FONT_SIZE,
1617 max: MAX_TERMINAL_FONT_SIZE,
1618 value: settings.fontSizeTerminal,
1619 defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal,
1620 onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }),
1621 }}
1622 preview={
1623 <TerminalFontPreview
1624 family={resolveTerminalFontPreference({
1625 advanced: true,
1626 code: settings.fontFamilyCode,
1627 terminal: settings.fontFamilyTerminal,
1628 })}
1629 size={settings.fontSizeTerminal}
1630 />
1631 }
1632 />
1633 );
1634}
1635
1636function FontSmoothingRow() {
1637 const settings = useScopedSettings();
1638 const updateSettings = useUpdateScopedSettings();
1639 if (!isMacPlatform(navigator.platform)) return null;
1640 return (
1641 <SettingsRow
1642 {...searchableSetting("font-smoothing")}
1643 description="Use thinner grayscale text smoothing instead of the macOS default."
1644 resetAction={
1645 settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? (
1646 <SettingResetButton
1647 label="font smoothing"
1648 onClick={() =>
1649 updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing })
1650 }
1651 />
1652 ) : null
1653 }
1654 control={
1655 <Switch
1656 checked={settings.fontSmoothing}
1657 onCheckedChange={(checked) => updateSettings({ fontSmoothing: Boolean(checked) })}
1658 aria-label="Font smoothing"
1659 />
1660 }
1661 />
1662 );
1663}
1664
1665function WordWrapRow() {
1666 const settings = useScopedSettings();
1667 const updateSettings = useUpdateScopedSettings();
1668 return (
1669 <SettingsRow
1670 {...searchableSetting("word-wrap")}
1671 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."
1672 resetAction={
1673 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
1674 <SettingResetButton
1675 label="word wrapping"
1676 onClick={() => updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })}
1677 />
1678 ) : null
1679 }
1680 control={
1681 <Switch
1682 checked={settings.wordWrap}
1683 onCheckedChange={(checked) => updateSettings({ wordWrap: Boolean(checked) })}
1684 aria-label="Wrap code, tables, diffs, and file previews by default"
1685 />
1686 }
1687 />
1688 );
1689}
1690
1691function FontSettingsGroup() {
1692 return (
1693 <>
1694 <InterfaceFontRow />
1695 <PromptFontRow />
1696 <CodeFontRow />
1697 <TerminalFontRow />
1698 <FontSmoothingRow />
1699 </>
1700 );
1701}
1702
1703/**
1704 * The two-font view: one sans, one monospace. The prompt follows the
1705 * interface font and the terminal follows the monospace font, so the demos
1706 * under each row show every surface the choice reaches.
1707 */
1708function SimpleFontRows() {
1709 const settings = useScopedSettings();
1710 return (
1711 <>
1712 <InterfaceFontRow preview={<PromptFontPreview />} />
1713 <CodeFontRow
1714 title="Monospace font"
1715 description="Code blocks, diffs, file previews, and the terminal."
1716 preview={
1717 <>
1718 <CodeFontPreview />
1719 <TerminalFontPreview
1720 family={resolveTerminalFontPreference({
1721 advanced: false,
1722 code: settings.fontFamilyCode,
1723 terminal: settings.fontFamilyTerminal,
1724 })}
1725 size={resolveTerminalFontSizePreference({
1726 advanced: false,
1727 code: settings.fontSizeCode,
1728 terminal: settings.fontSizeTerminal,
1729 })}
1730 />
1731 </>
1732 }
1733 />
1734 </>
1735 );
1736}
1737
1738// Font smoothing only renders on macOS, so a search jump to it elsewhere
1739// must not flip the section - the target would never mount to be scrolled to.
1740const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet<string> = new Set([
1741 "prompt-font",
1742 "terminal-font",
1743 ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform)
1744 ? ["font-smoothing"]
1745 : []),
1746]);
1747
1748/**
1749 * The two-font view by default - one sans, one monospace, each cascading to
1750 * every surface it reaches - with an Advanced switch in the section header
1751 * that reveals the per-surface override rows. The choice persists locally,
1752 * and a settings-search jump to an override row flips Advanced on so the
1753 * target exists to scroll to.
1754 */
1755function TypographySection() {
1756 const [advanced, setAdvanced] = useLocalStorage(
1757 TYPOGRAPHY_ADVANCED_STORAGE_KEY,
1758 false,
1759 Schema.Boolean,
1760 );
1761 const searchTargetId = useSettingsSearchTargetId();
1762 // Flip Advanced on once per search jump so the hidden target can mount and
1763 // scroll; tracking the handled id lets the user turn it back off without
1764 // the still-set target immediately re-expanding the section.
1765 const lastExpandedTargetRef = useRef<string | null>(null);
1766 useEffect(() => {
1767 if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return;
1768 if (lastExpandedTargetRef.current === searchTargetId) return;
1769 lastExpandedTargetRef.current = searchTargetId;
1770 setAdvanced(true);
1771 }, [searchTargetId, setAdvanced]);
1772 return (
1773 <SettingsSection
1774 id="typography"
1775 title="Typography"
1776 headerAction={
1777 <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground">
1778 Advanced
1779 <Switch
1780 checked={advanced}
1781 onCheckedChange={(checked) => setAdvanced(Boolean(checked))}
1782 aria-label="Show advanced typography settings"
1783 />
1784 </label>
1785 }
1786 >
1787 {advanced ? <FontSettingsGroup /> : <SimpleFontRows />}
1788 <WordWrapRow />
1789 </SettingsSection>
1790 );
1791}
1792
1793function FontFamilySettingsRow({
1794 id,
1795 title,
1796 description,
1797 defaultFamily,
1798 defaultValue,
1799 preview,
1800 value,
1801 onValueChange,
1802 onReset,
1803 requireMonospace = false,
1804 size,
1805}: {
1806 id?: string;
1807 title: string;
1808 description: string;
1809 /** What an unset preference renders as, e.g. "Menlo". */
1810 defaultFamily: string;
1811 /** The persisted family value supplied by the unified settings defaults. */
1812 defaultValue: string;
1813 preview?: ReactNode;
1814 value: string;
1815 onValueChange: (value: string) => void;
1816 onReset: () => void;
1817 requireMonospace?: boolean;
1818 size: {
1819 label: string;
1820 min: number;
1821 max: number;
1822 value: number;
1823 defaultValue: number;
1824 onChange: (v: number) => void;
1825 };
1826}) {
1827 const trimmed = value.trim();
1828 // The fallback input edits a draft; the preference only commits once typing
1829 // pauses and the text probes as an available font (or is an explicit
1830 // clear), so the current font holds and nothing reflows mid-word.
1831 const [draft, setDraft] = useState(value);
1832 const [draftSettled, setDraftSettled] = useState(true);
1833 const commitTimerRef = useRef<number | null>(null);
1834 const lastValueRef = useRef(value);
1835 if (lastValueRef.current !== value) {
1836 // The committed value changed externally (hydration, reset, picker
1837 // selection); adopt it and drop any pending commit of a stale draft.
1838 lastValueRef.current = value;
1839 if (commitTimerRef.current !== null) {
1840 window.clearTimeout(commitTimerRef.current);
1841 commitTimerRef.current = null;
1842 }
1843 setDraft(value);
1844 setDraftSettled(true);
1845 }
1846 useEffect(
1847 () => () => {
1848 if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current);
1849 },
1850 [],
1851 );
1852 const acceptsFamily = (candidate: string) =>
1853 isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate));
1854 const commitDraft = (next: string) => {
1855 setDraftSettled(true);
1856 // A rejected name stays in the field, flagged: the terminal would silently
1857 // fall back to its default, so the row must not claim it took the value.
1858 if (next.trim().length === 0 || acceptsFamily(next)) {
1859 onValueChange(next);
1860 }
1861 };
1862 const flushDraft = () => {
1863 if (commitTimerRef.current === null) return;
1864 window.clearTimeout(commitTimerRef.current);
1865 commitTimerRef.current = null;
1866 commitDraft(draft);
1867 };
1868 const draftTrimmed = draft.trim();
1869 // Flag an unknown name only once typing pauses, and never for an empty
1870 // field - that is the starting state, not a rejected entry.
1871 const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed;
1872 const resetToDefault = () => {
1873 if (commitTimerRef.current !== null) {
1874 window.clearTimeout(commitTimerRef.current);
1875 commitTimerRef.current = null;
1876 }
1877 setDraft(defaultValue);
1878 setDraftSettled(true);
1879 onReset();
1880 };
1881 const resetAction =
1882 value !== defaultValue || size.value !== size.defaultValue ? (
1883 <SettingResetButton label={title.toLowerCase()} onClick={resetToDefault} />
1884 ) : null;
1885 const fontEnumeration = useFontEnumeration();
1886 // Everyone starts on the plain input; focusing it is the user gesture that
1887 // runs font discovery. Where the engine can enumerate, the control then
1888 // upgrades to the picker - popped open when the swap happens under focus,
1889 // so the interaction continues without a second click.
1890 const inputFocusedRef = useRef(false);
1891 const familyControl =
1892 fontEnumeration.status === "granted" ? (
1893 <FontFamilyPicker
1894 ariaLabel={`${title} family`}
1895 defaultFamily={defaultFamily}
1896 selectedFamily={trimmed}
1897 requireMonospace={requireMonospace}
1898 initialOpen={inputFocusedRef.current}
1899 onSelect={onValueChange}
1900 />
1901 ) : (
1902 <Input
1903 size="sm"
1904 aria-label={`${title} family`}
1905 aria-invalid={draftPending || undefined}
1906 autoCapitalize="off"
1907 autoComplete="off"
1908 className="min-w-0 flex-1"
1909 maxLength={200}
1910 onFocus={() => {
1911 inputFocusedRef.current = true;
1912 discoverInstalledFonts();
1913 }}
1914 onBlur={() => {
1915 inputFocusedRef.current = false;
1916 flushDraft();
1917 }}
1918 onChange={(event) => {
1919 const next = event.currentTarget.value;
1920 setDraft(next);
1921 setDraftSettled(false);
1922 if (commitTimerRef.current !== null) {
1923 window.clearTimeout(commitTimerRef.current);
1924 }
1925 commitTimerRef.current = window.setTimeout(() => {
1926 commitTimerRef.current = null;
1927 commitDraft(next);
1928 }, 400);
1929 }}
1930 onKeyDown={(event) => {
1931 if (event.key === "Enter") flushDraft();
1932 if (event.key === "Escape") {
1933 // Discard uncommitted typing without closing the settings page,
1934 // which is what an unhandled Escape does.
1935 event.preventDefault();
1936 event.stopPropagation();
1937 if (commitTimerRef.current !== null) {
1938 window.clearTimeout(commitTimerRef.current);
1939 commitTimerRef.current = null;
1940 }
1941 setDraft(value);
1942 setDraftSettled(true);
1943 }
1944 }}
1945 placeholder={defaultFamily}
1946 spellCheck={false}
1947 value={draft}
1948 />
1949 );
1950 const control = (
1951 <div className="flex w-full items-center gap-2 sm:w-auto">
1952 <div className="min-w-0 flex-1 sm:w-44 sm:flex-none">{familyControl}</div>
1953 <Select
1954 value={String(size.value)}
1955 onValueChange={(next) => {
1956 if (typeof next !== "string") return;
1957 const parsed = Number(next);
1958 if (Number.isInteger(parsed) && parsed >= size.min && parsed <= size.max) {
1959 size.onChange(parsed);
1960 }
1961 }}
1962 >
1963 <SelectTrigger size="sm" className="w-22 shrink-0" aria-label={size.label}>
1964 <SelectValue>{size.value} px</SelectValue>
1965 </SelectTrigger>
1966 <SelectPopup align="end" alignItemWithTrigger={false}>
1967 {Array.from({ length: size.max - size.min + 1 }, (_, index) => size.min + index).map(
1968 (px) => (
1969 <SelectItem hideIndicator key={px} value={String(px)}>
1970 {px} px
1971 </SelectItem>
1972 ),
1973 )}
1974 </SelectPopup>
1975 </Select>
1976 </div>
1977 );
1978 return (
1979 <SettingsRow
1980 {...(id !== undefined ? { id } : {})}
1981 title={title}
1982 description={description}
1983 resetAction={resetAction}
1984 control={control}
1985 >
1986 {preview}
1987 </SettingsRow>
1988 );
1989}
1990
1991const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3;
1992
1993function AutoSettleDaysInput({
1994 value,
1995 onCommit,
1996}: {
1997 value: number;
1998 onCommit: (days: number) => void;
1999}) {
2000 // Local draft so the field can be emptied mid-edit; the setting only moves
2001 // on valid input and snaps back to the persisted value on blur.
2002 const [draft, setDraft] = useState(String(value));
2003 useEffect(() => {
2004 setDraft(String(value));
2005 }, [value]);
2006
2007 return (
2008 <Input
2009 size="sm"
2010 type="number"
2011 min={MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
2012 max={MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS}
2013 className="w-full sm:w-24"
2014 value={draft}
2015 onChange={(event) => {
2016 setDraft(event.target.value);
2017 // Number(), not parseInt: "3.5" must be rejected (not truncated to a
2018 // committed 3 while the field shows 3.5) — commit only when the
2019 // persisted value matches the displayed one.
2020 const parsed = Number(event.target.value);
2021 if (
2022 Number.isInteger(parsed) &&
2023 parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
2024 parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
2025 ) {
2026 onCommit(parsed);
2027 }
2028 }}
2029 onBlur={() => setDraft(String(value))}
2030 aria-label="Days of inactivity before auto-settle"
2031 />
2032 );
2033}
2034
2035// The legacy rows sit behind the fold, so a settings-search jump has to
2036// expand the section before its target can mount and scroll.
2037const LEGACY_FEATURE_TARGET_IDS: ReadonlySet<string> = new Set([
2038 "legacy-plan-mode",
2039 "legacy-context-window-indicator",
2040 "legacy-sidebar",
2041]);
2042
2043/**
2044 * Retired features kept only for users who still depend on them. Collapsed by
2045 * default so they stay out of the everyday settings path; a settings-search
2046 * jump to one of the rows unfolds the section.
2047 */
2048function LegacyFeaturesSection() {
2049 const settings = useScopedSettings();
2050 const updateSettings = useUpdateScopedSettings();
2051 const [open, setOpen] = useState(false);
2052 const searchTargetId = useSettingsSearchTargetId();
2053 const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features");
2054 // Unfold once per search jump; tracking the handled id lets the user fold
2055 // the section back up without the still-set target immediately reopening it.
2056 const lastExpandedTargetRef = useRef<string | null>(null);
2057 useEffect(() => {
2058 if (searchTargetId === null) {
2059 // A handled jump clears the target; forgetting it here lets a later
2060 // jump to the same row expand the section again.
2061 lastExpandedTargetRef.current = null;
2062 return;
2063 }
2064 if (!LEGACY_FEATURE_TARGET_IDS.has(searchTargetId)) return;
2065 if (lastExpandedTargetRef.current === searchTargetId) return;
2066 lastExpandedTargetRef.current = searchTargetId;
2067 setOpen(true);
2068 }, [searchTargetId]);
2069
2070 return (
2071 <section id="legacy-features" ref={targetRef} tabIndex={-1} className="space-y-2.5">
2072 <Collapsible open={open} onOpenChange={setOpen}>
2073 <CollapsibleTrigger className="group flex min-h-8 w-full items-center gap-2 px-3 sm:px-4">
2074 <h2 className="text-sm font-normal text-foreground/70 transition-colors group-hover:text-foreground">
2075 Legacy features
2076 </h2>
2077 <ChevronRightIcon className="size-4 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" />
2078 </CollapsibleTrigger>
2079 <CollapsiblePanel>
2080 <SettingsGroup>
2081 <SettingsRow
2082 {...searchableSetting("legacy-plan-mode")}
2083 description="Restore Build/Plan, /plan, /default, and Shift+Tab. Off uses build mode."
2084 control={
2085 <Switch
2086 checked={settings.planModeEnabled}
2087 onCheckedChange={(checked) => {
2088 updateSettings({ planModeEnabled: Boolean(checked) });
2089 }}
2090 aria-label="Plan mode (legacy)"
2091 />
2092 }
2093 />
2094 <SettingsRow
2095 {...searchableSetting("legacy-context-window-indicator")}
2096 description="Shows context window usage as a circular indicator in the composer."
2097 control={
2098 <Switch
2099 checked={settings.contextWindowMeterEnabled}
2100 onCheckedChange={(checked) =>
2101 updateSettings({ contextWindowMeterEnabled: Boolean(checked) })
2102 }
2103 aria-label="Context window indicator (legacy)"
2104 />
2105 }
2106 />
2107 <SettingsRow
2108 {...searchableSetting("legacy-sidebar")}
2109 description="Restore per-project thread trees instead of the default flat sidebar."
2110 control={
2111 <Switch
2112 checked={settings.legacySidebarEnabled}
2113 onCheckedChange={(checked) =>
2114 updateSettings({ legacySidebarEnabled: Boolean(checked) })
2115 }
2116 aria-label="Sidebar (legacy)"
2117 />
2118 }
2119 />
2120 </SettingsGroup>
2121 </CollapsiblePanel>
2122 </Collapsible>
2123 </section>
2124 );
2125}
2126
2127export function GeneralSettingsPanel() {
2128 const [terminalPlacement, setTerminalPlacement] = useTerminalPlacement();
2129 const modifierLabel = isMacPlatform(navigator.platform) ? "⌘" : "Ctrl";
2130 const sendShortcutOptions = [
2131 { value: "enter", label: "Enter" },
2132 { value: "mod-enter-multiline", label: `${modifierLabel} + Enter for multiline prompts` },
2133 { value: "mod-enter", label: `${modifierLabel} + Enter always` },
2134 ] as const;
2135 const settings = useScopedSettings();
2136 const updateSettings = useUpdateScopedSettings();
2137 const navigate = useNavigate();
2138 const { scope, environment, connectedEnvironments, target } = useSettingsScope();
2139 // The representative environment supplies the provider list for pickers;
2140 // a fanned-out model choice is validated against every target before it
2141 // is written. Per-machine tuning (background activity overrides) still
2142 // needs exactly one environment.
2143 const environmentId = environment?.environmentId ?? null;
2144 const isEnvironmentScope = scope.environmentIds.length === 1 && environmentId !== null;
2145 const hasServerTargets = connectedEnvironments.length > 0;
2146 const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false);
2147 const mixedResponseStreamingMode = useScopedSettingsMixed(["responseStreamingMode"]);
2148 const lastEnabledProjectGroupingMode = useRef<SidebarProjectGroupingMode>(
2149 readLastEnabledProjectGroupingMode(),
2150 );
2151 const serverProviders = environment?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS;
2152 const supportsAutoSettlement =
2153 connectedEnvironments.length > 0 &&
2154 connectedEnvironments.every(
2155 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
2156 );
2157 const supportsAutoSettlementScope = connectedEnvironments.every(
2158 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true,
2159 );
2160 const supportsRestartContinuation =
2161 connectedEnvironments.length > 0 &&
2162 connectedEnvironments.every(
2163 (target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true,
2164 );
2165
2166 const textGenerationProviders = serverProviders.filter(
2167 (provider) => provider.supportsTextGeneration !== false,
2168 );
2169 const textGenerationModelSelection = resolveAppModelSelectionState(
2170 settings,
2171 textGenerationProviders,
2172 );
2173 const textGenInstanceId = textGenerationModelSelection.instanceId;
2174 const textGenModel = textGenerationModelSelection.model;
2175 const textGenModelOptions = textGenerationModelSelection.options;
2176 const textGenerationModelInstanceEntries = sortProviderInstanceEntries(
2177 applyProviderInstanceSettings(
2178 deriveProviderInstanceEntries(textGenerationProviders),
2179 settings,
2180 target?.projectId ?? null,
2181 ),
2182 );
2183 const hasTextGenerationProvider = textGenerationModelInstanceEntries.some(
2184 (entry) => entry.enabled && entry.isAvailable,
2185 );
2186 const textGenInstanceEntry = textGenerationModelInstanceEntries.find(
2187 (entry) => entry.instanceId === textGenInstanceId,
2188 );
2189 const textGenProvider: ProviderDriverKind =
2190 textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
2191 const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
2192 settings,
2193 textGenerationProviders,
2194 textGenInstanceId,
2195 textGenModel,
2196 );
2197 const isTextGenerationModelDirty = !Equal.equals(
2198 settings.textGenerationModelSelection ?? null,
2199 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null,
2200 );
2201 const textGenerationModelDisabledReason = useScopedModelDisabledReason(
2202 settings,
2203 textGenerationModelInstanceEntries,
2204 );
2205 const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings);
2206 const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile;
2207 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
2208 const mixedBackgroundActivity = useScopedSettingsMixed(["backgroundActivity"]);
2209 const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
2210 const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
2211 const backgroundActivityDescription =
2212 backgroundActivityProfileOption === "advanced"
2213 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${
2214 BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile]
2215 }.`
2216 : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile];
2217 const canResetBackgroundActivity = !Equal.equals(
2218 settings.backgroundActivity,
2219 DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
2220 );
2221
2222 return (
2223 <SettingsPageContainer>
2224 <UiSettingsSyncSection />
2225 <ProjectDefaultsSettings category="general" />
2226 <SettingsSection id="organization" title="Organization">
2227 <SettingsRow
2228 {...searchableSetting("project-grouping")}
2229 description="Combine matching repositories across environments."
2230 resetAction={
2231 settings.sidebarProjectGroupingMode !==
2232 DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? (
2233 <SettingResetButton
2234 label="project grouping"
2235 onClick={() =>
2236 updateSettings({
2237 sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode,
2238 })
2239 }
2240 />
2241 ) : null
2242 }
2243 control={
2244 <Switch
2245 checked={isProjectGroupingEnabled(settings.sidebarProjectGroupingMode)}
2246 onCheckedChange={(checked) => {
2247 if (!checked && settings.sidebarProjectGroupingMode !== "separate") {
2248 lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode;
2249 rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode);
2250 }
2251 updateSettings({
2252 sidebarProjectGroupingMode: projectGroupingModeFromToggle(
2253 checked,
2254 lastEnabledProjectGroupingMode.current,
2255 ),
2256 });
2257 }}
2258 aria-label="Project grouping"
2259 />
2260 }
2261 />
2262 <SettingsRow
2263 {...searchableSetting("project-order")}
2264 description="Order of projects in the sidebar project picker and command palette."
2265 resetAction={
2266 settings.sidebarProjectSortOrder !==
2267 DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder ? (
2268 <SettingResetButton
2269 label="project order"
2270 onClick={() =>
2271 updateSettings({
2272 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
2273 })
2274 }
2275 />
2276 ) : null
2277 }
2278 control={
2279 <Select
2280 value={settings.sidebarProjectSortOrder}
2281 onValueChange={(value) => {
2282 if (isSidebarProjectSortOrder(value)) {
2283 updateSettings({ sidebarProjectSortOrder: value });
2284 }
2285 }}
2286 >
2287 <SelectTrigger size="sm" className="w-full sm:w-44" aria-label="Project order">
2288 <SelectValue>
2289 {SIDEBAR_PROJECT_SORT_ORDER_LABELS[settings.sidebarProjectSortOrder]}
2290 </SelectValue>
2291 </SelectTrigger>
2292 <SelectPopup align="end" alignItemWithTrigger={false}>
2293 {SidebarProjectSortOrder.literals.map((sortOrder) => (
2294 <SelectItem hideIndicator key={sortOrder} value={sortOrder}>
2295 {SIDEBAR_PROJECT_SORT_ORDER_LABELS[sortOrder]}
2296 </SelectItem>
2297 ))}
2298 </SelectPopup>
2299 </Select>
2300 }
2301 />
2302
2303 <SettingsRow
2304 serverScoped
2305 {...searchableSetting("auto-resume-limited-threads")}
2306 description="Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation."
2307 settingKeys={["autoResumeLimitedThreads"]}
2308 control={
2309 <ScopedSwitch
2310 settingKeys={["autoResumeLimitedThreads"]}
2311 checked={settings.autoResumeLimitedThreads}
2312 onCheckedChange={(checked) =>
2313 updateSettings({ autoResumeLimitedThreads: Boolean(checked) })
2314 }
2315 aria-label="Auto-resume limited threads"
2316 />
2317 }
2318 />
2319 <SettingsRow
2320 serverScoped
2321 {...searchableSetting("snooze-limited-threads")}
2322 description="Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake."
2323 settingKeys={["snoozeLimitedThreads"]}
2324 control={
2325 <ScopedSwitch
2326 settingKeys={["snoozeLimitedThreads"]}
2327 checked={settings.snoozeLimitedThreads}
2328 onCheckedChange={(checked) =>
2329 updateSettings({ snoozeLimitedThreads: Boolean(checked) })
2330 }
2331 aria-label="Snooze limited threads"
2332 />
2333 }
2334 />
2335
2336 <SettingsRow
2337 {...searchableSetting("working-shelf")}
2338 description="Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you."
2339 resetAction={
2340 settings.sidebarWorkingShelfEnabled !==
2341 DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? (
2342 <SettingResetButton
2343 label="working section"
2344 onClick={() =>
2345 updateSettings({
2346 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
2347 })
2348 }
2349 />
2350 ) : null
2351 }
2352 control={
2353 <Switch
2354 checked={settings.sidebarWorkingShelfEnabled}
2355 onCheckedChange={(checked) =>
2356 updateSettings({ sidebarWorkingShelfEnabled: Boolean(checked) })
2357 }
2358 aria-label="Working section (beta)"
2359 />
2360 }
2361 />
2362
2363 {supportsAutoSettlement ? (
2364 <>
2365 <SettingsRow
2366 serverScoped
2367 settingKeys={["sidebarAutoSettleOnMerge"]}
2368 {...searchableSetting("auto-settle-merged-threads")}
2369 description="Settle a thread when its pull request merges. Closed pull requests still settle automatically."
2370 resetAction={
2371 settings.sidebarAutoSettleOnMerge !==
2372 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? (
2373 <SettingResetButton
2374 label="auto-settle on merge"
2375 onClick={() =>
2376 updateSettings({
2377 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
2378 })
2379 }
2380 />
2381 ) : null
2382 }
2383 control={
2384 <ScopedSwitch
2385 settingKeys={["sidebarAutoSettleOnMerge"]}
2386 checked={settings.sidebarAutoSettleOnMerge}
2387 onCheckedChange={(checked) =>
2388 updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) })
2389 }
2390 aria-label="Auto-settle merged threads"
2391 />
2392 }
2393 />
2394
2395 <SettingsRow
2396 serverScoped
2397 settingKeys={["sidebarAutoSettleAfterDays"]}
2398 {...searchableSetting("auto-settle-inactive-threads")}
2399 description="Sidebar threads with no activity for this long settle automatically."
2400 resetAction={
2401 settings.sidebarAutoSettleAfterDays !==
2402 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? (
2403 <SettingResetButton
2404 label="auto-settle"
2405 onClick={() =>
2406 updateSettings({
2407 sidebarAutoSettleAfterDays:
2408 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
2409 })
2410 }
2411 />
2412 ) : null
2413 }
2414 control={
2415 <ScopedSwitch
2416 settingKeys={["sidebarAutoSettleAfterDays"]}
2417 checked={settings.sidebarAutoSettleAfterDays !== null}
2418 onCheckedChange={(checked) =>
2419 updateSettings({
2420 sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null,
2421 })
2422 }
2423 aria-label="Auto-settle inactive threads"
2424 />
2425 }
2426 />
2427 {settings.sidebarAutoSettleAfterDays !== null ? (
2428 <SettingsRow
2429 serverScoped
2430 settingKeys={["sidebarAutoSettleAfterDays"]}
2431 title={searchableSetting("days-before-auto-settle").title}
2432 description="Any new activity un-settles a thread automatically."
2433 control={
2434 <AutoSettleDaysInput
2435 value={settings.sidebarAutoSettleAfterDays}
2436 onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
2437 />
2438 }
2439 />
2440 ) : null}
2441 {settings.sidebarAutoSettleAfterDays !== null && supportsAutoSettlementScope ? (
2442 <SettingsRow
2443 serverScoped
2444 settingKeys={["sidebarAutoSettleKeepPullRequests"]}
2445 title="Keep threads with a PR"
2446 description="Inactivity does not settle threads with a pull request. Merge and close still do."
2447 control={
2448 <ScopedSwitch
2449 settingKeys={["sidebarAutoSettleKeepPullRequests"]}
2450 checked={settings.sidebarAutoSettleKeepPullRequests}
2451 onCheckedChange={(checked) =>
2452 updateSettings({ sidebarAutoSettleKeepPullRequests: checked })
2453 }
2454 aria-label="Keep threads with a PR"
2455 />
2456 }
2457 />
2458 ) : null}
2459 </>
2460 ) : null}
2461 </SettingsSection>
2462
2463 <SettingsSection id="behavior" title="Behavior">
2464 <NotificationSettings />
2465 <SettingsRow
2466 {...searchableSetting("in-app-notifications")}
2467 description="Show a toast when another thread finishes, fails, or needs input or approval while this app has focus."
2468 control={
2469 <Switch
2470 checked={settings.inAppNotificationsEnabled}
2471 onCheckedChange={(checked) => updateSettings({ inAppNotificationsEnabled: checked })}
2472 aria-label="In-app notifications"
2473 />
2474 }
2475 />
2476 <SettingsRow
2477 {...searchableSetting("time-format")}
2478 description="System default follows your browser or OS clock preference."
2479 resetAction={
2480 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (
2481 <SettingResetButton
2482 label="time format"
2483 onClick={() =>
2484 updateSettings({
2485 timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat,
2486 })
2487 }
2488 />
2489 ) : null
2490 }
2491 control={
2492 <Select
2493 value={settings.timestampFormat}
2494 onValueChange={(value) => {
2495 if (value === "locale" || value === "12-hour" || value === "24-hour") {
2496 updateSettings({ timestampFormat: value });
2497 }
2498 }}
2499 >
2500 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Timestamp format">
2501 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
2502 </SelectTrigger>
2503 <SelectPopup align="end" alignItemWithTrigger={false}>
2504 <SelectItem hideIndicator value="locale">
2505 {TIMESTAMP_FORMAT_LABELS.locale}
2506 </SelectItem>
2507 <SelectItem hideIndicator value="12-hour">
2508 {TIMESTAMP_FORMAT_LABELS["12-hour"]}
2509 </SelectItem>
2510 <SelectItem hideIndicator value="24-hour">
2511 {TIMESTAMP_FORMAT_LABELS["24-hour"]}
2512 </SelectItem>
2513 </SelectPopup>
2514 </Select>
2515 }
2516 />
2517 <SettingsRow
2518 serverScoped
2519 settingKeys={["responseStreamingMode"]}
2520 {...searchableSetting("response-streaming")}
2521 description={
2522 mixedResponseStreamingMode
2523 ? "The selected targets use different streaming modes."
2524 : RESPONSE_STREAMING_MODE_DESCRIPTIONS[settings.responseStreamingMode]
2525 }
2526 resetAction={
2527 settings.responseStreamingMode !== DEFAULT_UNIFIED_SETTINGS.responseStreamingMode ? (
2528 <SettingResetButton
2529 label="response streaming"
2530 onClick={() =>
2531 updateSettings({
2532 responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
2533 })
2534 }
2535 />
2536 ) : null
2537 }
2538 control={
2539 <Select
2540 value={mixedResponseStreamingMode ? null : settings.responseStreamingMode}
2541 onValueChange={(value) => {
2542 if (value === "turn" || value === "paragraph") {
2543 updateSettings({ responseStreamingMode: value });
2544 }
2545 }}
2546 >
2547 <SelectTrigger size="sm" className="w-full sm:w-56" aria-label="Response streaming">
2548 <SelectValue>
2549 {(value: ResponseStreamingMode | null) =>
2550 value === null ? "Mixed" : RESPONSE_STREAMING_MODE_LABELS[value]
2551 }
2552 </SelectValue>
2553 </SelectTrigger>
2554 <SelectPopup align="end" alignItemWithTrigger={false}>
2555 <SelectItem hideIndicator value="turn">
2556 {RESPONSE_STREAMING_MODE_LABELS.turn}
2557 </SelectItem>
2558 <SelectItem hideIndicator value="paragraph">
2559 {RESPONSE_STREAMING_MODE_LABELS.paragraph}
2560 </SelectItem>
2561 </SelectPopup>
2562 </Select>
2563 }
2564 />
2565 <SettingsRow
2566 {...searchableSetting("hide-whitespace-changes")}
2567 description="Set whether the diff panel ignores whitespace-only edits by default."
2568 resetAction={
2569 settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (
2570 <SettingResetButton
2571 label="diff whitespace changes"
2572 onClick={() =>
2573 updateSettings({
2574 diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace,
2575 })
2576 }
2577 />
2578 ) : null
2579 }
2580 control={
2581 <Switch
2582 checked={settings.diffIgnoreWhitespace}
2583 onCheckedChange={(checked) =>
2584 updateSettings({ diffIgnoreWhitespace: Boolean(checked) })
2585 }
2586 aria-label="Hide whitespace changes by default"
2587 />
2588 }
2589 />
2590 <SettingsRow
2591 {...searchableSetting("default-diff-file-state")}
2592 description="Start with files expanded or collapsed when opening diffs or a pull request's Code tab."
2593 resetAction={
2594 settings.diffFilesCollapsed !== DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed ? (
2595 <SettingResetButton
2596 label="default diff file state"
2597 onClick={() =>
2598 updateSettings({
2599 diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed,
2600 })
2601 }
2602 />
2603 ) : null
2604 }
2605 control={
2606 <Select
2607 value={settings.diffFilesCollapsed ? "collapsed" : "expanded"}
2608 onValueChange={(value) => {
2609 if (value === "expanded" || value === "collapsed") {
2610 updateSettings({ diffFilesCollapsed: value === "collapsed" });
2611 }
2612 }}
2613 >
2614 <SelectTrigger
2615 size="sm"
2616 className="w-full sm:w-40"
2617 aria-label="Default diff file state"
2618 >
2619 <SelectValue>{settings.diffFilesCollapsed ? "Collapsed" : "Expanded"}</SelectValue>
2620 </SelectTrigger>
2621 <SelectPopup align="end" alignItemWithTrigger={false}>
2622 <SelectItem hideIndicator value="expanded">
2623 Expanded
2624 </SelectItem>
2625 <SelectItem hideIndicator value="collapsed">
2626 Collapsed
2627 </SelectItem>
2628 </SelectPopup>
2629 </Select>
2630 }
2631 />
2632 <SettingsRow
2633 {...searchableSetting("diff-layout")}
2634 description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too."
2635 resetAction={
2636 settings.diffLayout !== DEFAULT_UNIFIED_SETTINGS.diffLayout ? (
2637 <SettingResetButton
2638 label="diff layout"
2639 onClick={() => updateSettings({ diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout })}
2640 />
2641 ) : null
2642 }
2643 control={
2644 <Select
2645 value={settings.diffLayout}
2646 onValueChange={(value) => {
2647 if (value === "stacked" || value === "split") {
2648 updateSettings({ diffLayout: value });
2649 }
2650 }}
2651 >
2652 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Diff layout">
2653 <SelectValue>{DIFF_LAYOUT_LABELS[settings.diffLayout]}</SelectValue>
2654 </SelectTrigger>
2655 <SelectPopup align="end" alignItemWithTrigger={false}>
2656 <SelectItem hideIndicator value="stacked">
2657 {DIFF_LAYOUT_LABELS.stacked}
2658 </SelectItem>
2659 <SelectItem hideIndicator value="split">
2660 {DIFF_LAYOUT_LABELS.split}
2661 </SelectItem>
2662 </SelectPopup>
2663 </Select>
2664 }
2665 />
2666
2667 <SettingsRow
2668 {...searchableSetting("terminal-position")}
2669 resetAction={
2670 terminalPlacement !== "right" ? (
2671 <SettingResetButton
2672 label="terminal position"
2673 onClick={() => setTerminalPlacement("right")}
2674 />
2675 ) : null
2676 }
2677 description="Where new terminal sessions open. Existing sessions stay in their panel."
2678 control={
2679 <Select
2680 value={terminalPlacement}
2681 onValueChange={(value) => {
2682 if (value === "right" || value === "bottom") setTerminalPlacement(value);
2683 }}
2684 >
2685 <SelectTrigger size="sm" className="w-full sm:w-40" aria-label="Terminal position">
2686 <SelectValue>{terminalPlacement === "right" ? "Right" : "Bottom"}</SelectValue>
2687 </SelectTrigger>
2688 <SelectPopup align="end" alignItemWithTrigger={false}>
2689 <SelectItem hideIndicator value="right">
2690 Right
2691 </SelectItem>
2692 <SelectItem hideIndicator value="bottom">
2693 Bottom
2694 </SelectItem>
2695 </SelectPopup>
2696 </Select>
2697 }
2698 />
2699
2700 <SettingsRow
2701 {...searchableSetting("proactive-panels")}
2702 description="Open linked pull requests first. Otherwise, open Changes for edits to at least 3 files or 50 lines."
2703 resetAction={
2704 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (
2705 <SettingResetButton
2706 label="proactive panels"
2707 onClick={() =>
2708 updateSettings({
2709 proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled,
2710 })
2711 }
2712 />
2713 ) : null
2714 }
2715 control={
2716 <Switch
2717 checked={settings.proactivePanelsEnabled}
2718 onCheckedChange={(checked) =>
2719 updateSettings({ proactivePanelsEnabled: Boolean(checked) })
2720 }
2721 aria-label="Proactive panels"
2722 />
2723 }
2724 />
2725
2726 <SettingsRow
2727 {...searchableSetting("skills-in-slash-menu")}
2728 description="Also include skills in the / command menu. Skills always appear when you type $."
2729 resetAction={
2730 settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? (
2731 <SettingResetButton
2732 label="skills in slash menu"
2733 onClick={() =>
2734 updateSettings({
2735 showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu,
2736 })
2737 }
2738 />
2739 ) : null
2740 }
2741 control={
2742 <Switch
2743 checked={settings.showSkillsInSlashMenu}
2744 onCheckedChange={(checked) =>
2745 updateSettings({ showSkillsInSlashMenu: Boolean(checked) })
2746 }
2747 aria-label="Show skills in slash menu"
2748 />
2749 }
2750 />
2751
2752 <SettingsRow
2753 {...searchableSetting("composer-rich-text")}
2754 description="Show formatted Markdown as you type."
2755 resetAction={
2756 settings.composerRichTextEnabled !==
2757 DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled ? (
2758 <SettingResetButton
2759 label="rich text composer"
2760 onClick={() =>
2761 updateSettings({
2762 composerRichTextEnabled: DEFAULT_UNIFIED_SETTINGS.composerRichTextEnabled,
2763 })
2764 }
2765 />
2766 ) : null
2767 }
2768 control={
2769 <Switch
2770 checked={settings.composerRichTextEnabled}
2771 onCheckedChange={(checked) =>
2772 updateSettings({ composerRichTextEnabled: Boolean(checked) })
2773 }
2774 aria-label="Rich text composer"
2775 />
2776 }
2777 />
2778
2779 <SettingsRow
2780 {...searchableSetting("composer-collapse")}
2781 description="Rest the composer of an existing thread into a single line when you scroll the conversation. Focus the composer or start typing to expand it again."
2782 resetAction={
2783 settings.composerCollapseOnScroll !==
2784 DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll ? (
2785 <SettingResetButton
2786 label="collapse composer on scroll"
2787 onClick={() =>
2788 updateSettings({
2789 composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll,
2790 })
2791 }
2792 />
2793 ) : null
2794 }
2795 control={
2796 <Switch
2797 checked={settings.composerCollapseOnScroll}
2798 onCheckedChange={(checked) =>
2799 updateSettings({ composerCollapseOnScroll: Boolean(checked) })
2800 }
2801 aria-label="Collapse composer on scroll"
2802 />
2803 }
2804 />
2805
2806 <SettingsRow
2807 {...searchableSetting("send-shortcut")}
2808 description="Choose when Enter sends a prompt or inserts a new line"
2809 resetAction={
2810 settings.sendShortcut !== DEFAULT_UNIFIED_SETTINGS.sendShortcut ? (
2811 <SettingResetButton
2812 label="send shortcut"
2813 onClick={() =>
2814 updateSettings({ sendShortcut: DEFAULT_UNIFIED_SETTINGS.sendShortcut })
2815 }
2816 />
2817 ) : null
2818 }
2819 control={
2820 <Select
2821 value={settings.sendShortcut}
2822 onValueChange={(value) => {
2823 const option = sendShortcutOptions.find((option) => option.value === value);
2824 if (option) updateSettings({ sendShortcut: option.value });
2825 }}
2826 >
2827 <SelectTrigger
2828 size="sm"
2829 className="w-auto min-w-0 max-w-full"
2830 aria-label="Send shortcut"
2831 >
2832 <SelectValue>
2833 {
2834 sendShortcutOptions.find((option) => option.value === settings.sendShortcut)
2835 ?.label
2836 }
2837 </SelectValue>
2838 </SelectTrigger>
2839 <SelectPopup align="end" alignItemWithTrigger={false}>
2840 {sendShortcutOptions.map((option) => (
2841 <SelectItem key={option.value} value={option.value}>
2842 <span className="flex items-center justify-between gap-4">
2843 {option.label}
2844 {settings.sendShortcut === option.value && <CheckIcon aria-hidden="true" />}
2845 </span>
2846 </SelectItem>
2847 ))}
2848 </SelectPopup>
2849 </Select>
2850 }
2851 />
2852
2853 <SettingsRow
2854 {...searchableSetting("follow-up-behavior")}
2855 description={
2856 "Queue follow-ups while the agent runs or steer the current run. " +
2857 (settings.sendShortcut === "mod-enter-multiline"
2858 ? `Press ${modifierLabel} + Enter for single-line prompts or ${modifierLabel} + Shift + Enter for multiline prompts to do the opposite for one message.`
2859 : `Press ${modifierLabel}${settings.sendShortcut === "mod-enter" ? " + Shift" : ""} + Enter to do the opposite for one message.`)
2860 }
2861 resetAction={
2862 settings.followUpBehavior !== DEFAULT_UNIFIED_SETTINGS.followUpBehavior ? (
2863 <SettingResetButton
2864 label="follow-up behavior"
2865 onClick={() =>
2866 updateSettings({
2867 followUpBehavior: DEFAULT_UNIFIED_SETTINGS.followUpBehavior,
2868 })
2869 }
2870 />
2871 ) : null
2872 }
2873 control={
2874 <Select
2875 value={settings.followUpBehavior}
2876 onValueChange={(value) => {
2877 if (value === "queue" || value === "steer") {
2878 updateSettings({ followUpBehavior: value });
2879 }
2880 }}
2881 >
2882 <SelectTrigger size="sm" className="w-auto min-w-0" aria-label="Follow-up behavior">
2883 <SelectValue>
2884 {settings.followUpBehavior === "queue" ? "Queue" : "Steer"}
2885 </SelectValue>
2886 </SelectTrigger>
2887 <SelectPopup align="end" alignItemWithTrigger={false}>
2888 <SelectItem value="queue">Queue</SelectItem>
2889 <SelectItem value="steer">Steer</SelectItem>
2890 </SelectPopup>
2891 </Select>
2892 }
2893 />
2894
2895 <SettingsRow
2896 serverScoped
2897 settingKeys={["enableProviderUpdateChecks"]}
2898 {...searchableSetting("provider-update-checks")}
2899 description="Check installed provider CLIs for newer available versions and fetch model catalog updates. When off, new models may not appear."
2900 resetAction={
2901 settings.enableProviderUpdateChecks !==
2902 DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? (
2903 <SettingResetButton
2904 label="provider update checks"
2905 onClick={() =>
2906 updateSettings({
2907 enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
2908 })
2909 }
2910 />
2911 ) : null
2912 }
2913 control={
2914 <ScopedSwitch
2915 settingKeys={["enableProviderUpdateChecks"]}
2916 checked={settings.enableProviderUpdateChecks}
2917 onCheckedChange={(checked) =>
2918 updateSettings({ enableProviderUpdateChecks: Boolean(checked) })
2919 }
2920 aria-label="Check provider versions"
2921 />
2922 }
2923 />
2924
2925 <SettingsRow
2926 {...searchableSetting("continue-threads-after-server-update")}
2927 serverScoped
2928 settingKeys={["continueThreadsAfterServerUpdate"]}
2929 description="Automatically resume interrupted threads after an update, crash, or machine restart on the selected environments."
2930 status={
2931 !supportsRestartContinuation
2932 ? "All selected connected environments must support restart continuation."
2933 : undefined
2934 }
2935 resetAction={
2936 supportsRestartContinuation &&
2937 settings.continueThreadsAfterServerUpdate !==
2938 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? (
2939 <SettingResetButton
2940 label="continue threads after restarts"
2941 onClick={() =>
2942 updateSettings({
2943 continueThreadsAfterServerUpdate:
2944 DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
2945 })
2946 }
2947 />
2948 ) : null
2949 }
2950 control={
2951 <ScopedSwitch
2952 settingKeys={["continueThreadsAfterServerUpdate"]}
2953 checked={settings.continueThreadsAfterServerUpdate}
2954 disabled={!supportsRestartContinuation}
2955 onCheckedChange={(checked) =>
2956 updateSettings({ continueThreadsAfterServerUpdate: Boolean(checked) })
2957 }
2958 aria-label="Continue threads after restarts"
2959 />
2960 }
2961 />
2962
2963 <SettingsRow
2964 serverScoped
2965 settingKeys={["backgroundActivity"]}
2966 id={searchableSetting("background-activity").id}
2967 title={
2968 <span className="inline-flex items-center gap-1.5">
2969 {searchableSetting("background-activity").title}
2970 <PolicyTooltip>
2971 This shared policy gates background work such as Git refreshes and provider health
2972 probes after their individual intervals elapse.
2973 </PolicyTooltip>
2974 </span>
2975 }
2976 description={backgroundActivityDescription}
2977 resetAction={
2978 canResetBackgroundActivity ? (
2979 <SettingResetButton
2980 label="background activity"
2981 onClick={() => updateSettings(resetBackgroundActivitySettings())}
2982 />
2983 ) : null
2984 }
2985 control={
2986 <>
2987 <Select
2988 value={mixedBackgroundActivity ? null : backgroundActivityProfileOption}
2989 onValueChange={(value) => {
2990 if (value === "advanced") {
2991 if (isEnvironmentScope) setBackgroundActivityDialogOpen(true);
2992 return;
2993 }
2994 if (
2995 value === "balanced" ||
2996 value === "performance" ||
2997 value === "battery-saver"
2998 ) {
2999 updateSettings(backgroundActivityProfileSettings(value));
3000 }
3001 }}
3002 >
3003 <SelectTrigger
3004 size="sm"
3005 className="w-full sm:w-40"
3006 aria-label="Background activity profile"
3007 >
3008 <SelectValue>
3009 {(value: BackgroundActivityProfileOption | null) =>
3010 value === null ? "Mixed" : BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS[value]
3011 }
3012 </SelectValue>
3013 </SelectTrigger>
3014 <SelectPopup align="end" alignItemWithTrigger={false}>
3015 <SelectItem hideIndicator value="balanced">
3016 {BACKGROUND_ACTIVITY_PROFILE_LABELS.balanced}
3017 </SelectItem>
3018 <SelectItem hideIndicator value="performance">
3019 {BACKGROUND_ACTIVITY_PROFILE_LABELS.performance}
3020 </SelectItem>
3021 <SelectItem hideIndicator value="battery-saver">
3022 {BACKGROUND_ACTIVITY_PROFILE_LABELS["battery-saver"]}
3023 </SelectItem>
3024 <SelectItem hideIndicator value="advanced" disabled={!isEnvironmentScope}>
3025 {isEnvironmentScope
3026 ? BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced
3027 : `${BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS.advanced} (one environment)`}
3028 </SelectItem>
3029 </SelectPopup>
3030 </Select>
3031 {backgroundActivityProfileOption === "advanced" && isEnvironmentScope ? (
3032 <Tooltip>
3033 <TooltipTrigger
3034 render={
3035 <Button
3036 size="icon-sm"
3037 variant="outline"
3038 aria-label="Configure advanced background activity"
3039 onClick={() => setBackgroundActivityDialogOpen(true)}
3040 >
3041 <SettingsIcon className="size-4" />
3042 </Button>
3043 }
3044 />
3045 <TooltipPopup side="top">Configure background activity</TooltipPopup>
3046 </Tooltip>
3047 ) : null}
3048 <BackgroundActivityAdvancedDialog
3049 open={backgroundActivityDialogOpen && isEnvironmentScope}
3050 onOpenChange={setBackgroundActivityDialogOpen}
3051 />
3052 </>
3053 }
3054 />
3055 </SettingsSection>
3056
3057 <SettingsSection id="projects-and-threads" title="Projects & threads">
3058 <SettingsRow
3059 serverScoped
3060 settingKeys={["newWorktreesStartFromOrigin"]}
3061 {...searchableSetting("start-from-origin")}
3062 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
3063 resetAction={
3064 settings.newWorktreesStartFromOrigin !==
3065 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? (
3066 <SettingResetButton
3067 label="new worktrees start from origin"
3068 onClick={() =>
3069 updateSettings({
3070 newWorktreesStartFromOrigin:
3071 DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin,
3072 })
3073 }
3074 />
3075 ) : null
3076 }
3077 control={
3078 <ScopedSwitch
3079 settingKeys={["newWorktreesStartFromOrigin"]}
3080 checked={settings.newWorktreesStartFromOrigin}
3081 onCheckedChange={(checked) =>
3082 updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) })
3083 }
3084 aria-label="Start new worktrees from origin by default"
3085 />
3086 }
3087 />
3088 <SettingsRow
3089 serverScoped
3090 settingKeys={["addProjectBaseDirectory"]}
3091 {...searchableSetting("add-project-starts-in")}
3092 description='Leave empty to use "~/" when the Add Project browser opens.'
3093 resetAction={
3094 settings.addProjectBaseDirectory !==
3095 DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? (
3096 <SettingResetButton
3097 label="add project base directory"
3098 onClick={() =>
3099 updateSettings({
3100 addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory,
3101 })
3102 }
3103 />
3104 ) : null
3105 }
3106 control={
3107 <DraftInput
3108 size="sm"
3109 className="w-full sm:w-72"
3110 value={mixedAddProjectBaseDirectory ? "" : settings.addProjectBaseDirectory}
3111 onCommit={(next) => updateSettings({ addProjectBaseDirectory: next })}
3112 placeholder={mixedAddProjectBaseDirectory ? "Mixed" : "~/"}
3113 spellCheck={false}
3114 aria-label="Add project base directory"
3115 />
3116 }
3117 />
3118 </SettingsSection>
3119
3120 <SettingsSection id="confirmations" title="Confirmations">
3121 <SettingsRow
3122 {...searchableSetting("unpin-confirmation")}
3123 description="Ask before unpinning a thread from the pinned section."
3124 resetAction={
3125 settings.confirmThreadUnpin !== DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin ? (
3126 <SettingResetButton
3127 label="unpin confirmation"
3128 onClick={() =>
3129 updateSettings({
3130 confirmThreadUnpin: DEFAULT_UNIFIED_SETTINGS.confirmThreadUnpin,
3131 })
3132 }
3133 />
3134 ) : null
3135 }
3136 control={
3137 <Switch
3138 checked={settings.confirmThreadUnpin}
3139 onCheckedChange={(checked) =>
3140 updateSettings({ confirmThreadUnpin: Boolean(checked) })
3141 }
3142 aria-label="Confirm thread unpinning"
3143 />
3144 }
3145 />
3146
3147 <SettingsRow
3148 {...searchableSetting("archive-confirmation")}
3149 description="Require a second click on the inline archive action before a thread is archived."
3150 resetAction={
3151 settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? (
3152 <SettingResetButton
3153 label="archive confirmation"
3154 onClick={() =>
3155 updateSettings({
3156 confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive,
3157 })
3158 }
3159 />
3160 ) : null
3161 }
3162 control={
3163 <Switch
3164 checked={settings.confirmThreadArchive}
3165 onCheckedChange={(checked) =>
3166 updateSettings({ confirmThreadArchive: Boolean(checked) })
3167 }
3168 aria-label="Confirm thread archiving"
3169 />
3170 }
3171 />
3172
3173 <SettingsRow
3174 {...searchableSetting("delete-confirmation")}
3175 description="Ask before deleting a thread and its chat history."
3176 resetAction={
3177 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (
3178 <SettingResetButton
3179 label="delete confirmation"
3180 onClick={() =>
3181 updateSettings({
3182 confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete,
3183 })
3184 }
3185 />
3186 ) : null
3187 }
3188 control={
3189 <Switch
3190 checked={settings.confirmThreadDelete}
3191 onCheckedChange={(checked) =>
3192 updateSettings({ confirmThreadDelete: Boolean(checked) })
3193 }
3194 aria-label="Confirm thread deletion"
3195 />
3196 }
3197 />
3198
3199 {isElectron ? (
3200 <SettingsRow
3201 {...searchableSetting("quit-confirmation")}
3202 description="Hold mode also quits on two quick presses."
3203 resetAction={
3204 settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? (
3205 <SettingResetButton
3206 label="quit shortcut behavior"
3207 onClick={() =>
3208 updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit })
3209 }
3210 />
3211 ) : null
3212 }
3213 control={
3214 <Select
3215 value={settings.confirmQuit}
3216 onValueChange={(value) => {
3217 if (value === "direct" || value === "hold" || value === "double-click") {
3218 updateSettings({ confirmQuit: value });
3219 }
3220 }}
3221 >
3222 <SelectTrigger
3223 size="sm"
3224 className="w-full sm:w-40"
3225 aria-label="Quit shortcut behavior"
3226 >
3227 <SelectValue>{QUIT_CONFIRMATION_MODE_LABELS[settings.confirmQuit]}</SelectValue>
3228 </SelectTrigger>
3229 <SelectPopup align="end" alignItemWithTrigger={false}>
3230 {Object.entries(QUIT_CONFIRMATION_MODE_LABELS).map(([value, label]) => (
3231 <SelectItem hideIndicator key={value} value={value}>
3232 {label}
3233 </SelectItem>
3234 ))}
3235 </SelectPopup>
3236 </Select>
3237 }
3238 />
3239 ) : null}
3240 </SettingsSection>
3241
3242 <SettingsSection id="text-generation" title="Text generation">
3243 <SettingsRow
3244 serverScoped
3245 settingKeys={["textGenerationModelSelection"]}
3246 {...searchableSetting("text-generation-model")}
3247 description="Used for thread titles and other generated text on connected devices with this provider. Source control can override it."
3248 resetAction={
3249 hasServerTargets && isTextGenerationModelDirty ? (
3250 <SettingResetButton
3251 label="text generation model"
3252 onClick={() =>
3253 updateSettings({
3254 textGenerationModelSelection:
3255 DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection,
3256 })
3257 }
3258 />
3259 ) : null
3260 }
3261 control={
3262 !hasServerTargets ? (
3263 <span className="text-sm text-muted-foreground">
3264 Connect an environment to choose its text generation model.
3265 </span>
3266 ) : !hasTextGenerationProvider ? (
3267 <span className="text-sm text-muted-foreground">
3268 No text generation providers available.
3269 </span>
3270 ) : (
3271 <div className="flex flex-wrap items-center justify-end gap-1.5">
3272 <ProviderModelPicker
3273 environmentId={environmentId}
3274 activeInstanceId={textGenInstanceId}
3275 model={textGenModel}
3276 lockedProvider={null}
3277 instanceEntries={textGenerationModelInstanceEntries}
3278 modelOptionsByInstance={textGenerationModelOptionsByInstance}
3279 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
3280 {...(mixedTextGenerationModel ? { triggerLabel: "Mixed" } : {})}
3281 getModelDisabledReason={textGenerationModelDisabledReason}
3282 {...(environmentId
3283 ? {
3284 onOpenProviderSetup: (instanceId: ProviderInstanceId) => {
3285 void navigate({
3286 to: "/settings/providers",
3287 search: { environmentId, instanceId },
3288 });
3289 },
3290 }
3291 : {})}
3292 onInstanceModelChange={(instanceId, model) => {
3293 const reason = textGenerationModelDisabledReason(instanceId, model);
3294 if (reason) {
3295 toastManager.add({
3296 type: "error",
3297 title: "Text generation model not saved",
3298 description: reason,
3299 });
3300 return;
3301 }
3302 updateSettings({
3303 textGenerationModelSelection: resolveAppModelSelectionState(
3304 {
3305 ...settings,
3306 textGenerationModelSelection: createModelSelection(instanceId, model),
3307 },
3308 textGenerationProviders,
3309 ),
3310 });
3311 }}
3312 />
3313 {textGenInstanceEntry ? (
3314 <TraitsPicker
3315 provider={textGenProvider}
3316 models={
3317 // Use the exact instance's models (rather than the
3318 // first-kind-match) so a custom text-gen instance like
3319 // `codex_personal` gets its own model list, not the
3320 // default Codex one.
3321 textGenInstanceEntry?.models ?? []
3322 }
3323 model={textGenModel}
3324 prompt=""
3325 onPromptChange={() => {}}
3326 modelOptions={textGenModelOptions}
3327 allowPromptInjectedEffort={false}
3328 planModeEnabled={
3329 settings.planModeEnabled || selectsPlanAgent(textGenModelOptions)
3330 }
3331 triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME}
3332 onModelOptionsChange={(nextOptions) => {
3333 updateSettings({
3334 textGenerationModelSelection: resolveAppModelSelectionState(
3335 {
3336 ...settings,
3337 textGenerationModelSelection: createModelSelection(
3338 textGenInstanceId,
3339 textGenModel,
3340 nextOptions,
3341 ),
3342 },
3343 textGenerationProviders,
3344 ),
3345 });
3346 }}
3347 />
3348 ) : null}
3349 </div>
3350 )
3351 }
3352 />
3353 </SettingsSection>
3354
3355 <SettingsSection id="about" title="About">
3356 {isElectron || HOSTED_APP_CHANNEL ? (
3357 <AboutVersionSection />
3358 ) : (
3359 <>
3360 <SettingsRow
3361 id={searchableSetting("app-version").id}
3362 title={<AboutVersionTitle />}
3363 description="Current version of the application."
3364 />
3365 {IS_NIGHTLY_BUILD ? <NightlyMobileBetaRow /> : null}
3366 </>
3367 )}
3368 <SettingsRow
3369 {...searchableSetting("privacy-policy")}
3370 description="How we handle your data, including the anonymous usage data T3 Code collects."
3371 control={
3372 <Button
3373 render={<a href={PRIVACY_POLICY_URL} target="_blank" rel="noreferrer noopener" />}
3374 size="sm"
3375 variant="outline"
3376 >
3377 View policy
3378 </Button>
3379 }
3380 />
3381 </SettingsSection>
3382 <SettingsSection title="Diagnostics">
3383 <SettingsRow
3384 {...searchableSetting("diagnostics")}
3385 description={
3386 isEnvironmentScope
3387 ? "Inspect processes, resource use, and logs on this environment."
3388 : "Inspect processes, resource use, and logs on one environment at a time."
3389 }
3390 control={
3391 <Button
3392 render={
3393 <Link to="/settings/diagnostics" search={{ machine: environmentId ?? undefined }} />
3394 }
3395 size="sm"
3396 variant="outline"
3397 >
3398 View diagnostics
3399 </Button>
3400 }
3401 />
3402 <SettingsRow
3403 {...searchableSetting("open-source-licenses")}
3404 description="Notices for dependencies, assets, and optional tools used by The Knife."
3405 control={
3406 <Button
3407 render={<Link to="/settings/open-source-licenses" />}
3408 size="sm"
3409 variant="outline"
3410 >
3411 View licenses
3412 </Button>
3413 }
3414 />
3415 </SettingsSection>
3416
3417 <LegacyFeaturesSection />
3418 </SettingsPageContainer>
3419 );
3420}
3421
3422export function ArchivedThreadsPanel() {
3423 const { scope } = useSettingsScope();
3424 const { unarchiveThread, confirmAndDeleteThread } = useThreadActions();
3425 const {
3426 snapshots: archivedSnapshots,
3427 error: archiveError,
3428 isLoading: isLoadingArchive,
3429 refresh: refreshArchivedThreads,
3430 } = useArchivedThreadSnapshots(scope.environmentIds);
3431
3432 const archivedGroups = useMemo(() => {
3433 const selectedProjectKeys =
3434 scope.kind === "project" || scope.kind === "checkout"
3435 ? new Set(scope.members.map((member) => `${member.environmentId}:${member.id}`))
3436 : null;
3437 const projectsByEnvironmentAndId = new Map(
3438 archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
3439 snapshot.projects
3440 .filter(
3441 (project) =>
3442 selectedProjectKeys === null ||
3443 selectedProjectKeys.has(`${environmentId}:${project.id}`),
3444 )
3445 .map(
3446 (project) => [`${environmentId}:${project.id}`, { ...project, environmentId }] as const,
3447 ),
3448 ),
3449 );
3450 const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) =>
3451 snapshot.threads.map((thread) => presentThreadShell(environmentId, thread)),
3452 );
3453
3454 const archivedProjects = Array.from(projectsByEnvironmentAndId.values());
3455 const groups: Array<{
3456 readonly project: (typeof archivedProjects)[number];
3457 readonly threads: Array<(typeof threads)[number]>;
3458 }> = [];
3459 for (const project of archivedProjects) {
3460 const projectThreads: Array<(typeof threads)[number]> = [];
3461 for (const thread of threads) {
3462 if (thread.projectId === project.id && thread.environmentId === project.environmentId) {
3463 projectThreads.push(thread);
3464 }
3465 }
3466 if (projectThreads.length > 0) {
3467 groups.push({
3468 project,
3469 threads: projectThreads.toSorted((left, right) => {
3470 const leftKey = left.archivedAt ?? left.createdAt;
3471 const rightKey = right.archivedAt ?? right.createdAt;
3472 return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id);
3473 }),
3474 });
3475 }
3476 }
3477 return groups;
3478 }, [archivedSnapshots, scope]);
3479
3480 const handleArchivedThreadContextMenu = useCallback(
3481 async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
3482 const api = readLocalApi();
3483 if (!api) return;
3484 const clicked = await api.contextMenu.show(
3485 [
3486 { id: "unarchive", label: "Unarchive" },
3487 { id: "delete", label: "Delete", destructive: true },
3488 ],
3489 position,
3490 );
3491
3492 if (clicked === "unarchive") {
3493 const result = await unarchiveThread(threadRef);
3494 if (result._tag === "Success") {
3495 refreshArchivedThreads();
3496 } else if (!isAtomCommandInterrupted(result)) {
3497 const error = squashAtomCommandFailure(result);
3498 toastManager.add(
3499 stackedThreadToast({
3500 type: "error",
3501 title: "Failed to unarchive thread",
3502 description: error instanceof Error ? error.message : "An error occurred.",
3503 }),
3504 );
3505 }
3506 return;
3507 }
3508
3509 if (clicked === "delete") {
3510 const result = await confirmAndDeleteThread(threadRef);
3511 if (result._tag === "Success") {
3512 refreshArchivedThreads();
3513 } else if (!isAtomCommandInterrupted(result)) {
3514 const error = squashAtomCommandFailure(result);
3515 toastManager.add(
3516 stackedThreadToast({
3517 type: "error",
3518 title: "Failed to delete thread",
3519 description: error instanceof Error ? error.message : "An error occurred.",
3520 }),
3521 );
3522 }
3523 }
3524 },
3525 [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread],
3526 );
3527
3528 return (
3529 <SettingsPageContainer>
3530 {archivedGroups.length === 0 ? (
3531 <SettingsSection
3532 id={isLoadingArchive ? undefined : searchableSetting("archive").id}
3533 title={searchableSetting("archive").title}
3534 >
3535 <SettingsRow
3536 title={
3537 <span className="inline-flex items-center gap-2">
3538 {isLoadingArchive ? (
3539 <Spinner size="sm" tone="muted" />
3540 ) : (
3541 <ArchiveIcon className="size-3.5 text-muted-foreground" />
3542 )}
3543 {isLoadingArchive
3544 ? "Loading archived threads"
3545 : archiveError
3546 ? "Could not load archived threads"
3547 : "No archived threads"}
3548 </span>
3549 }
3550 description={
3551 isLoadingArchive
3552 ? "Checking connected environments."
3553 : (archiveError ?? "Archived threads will appear here.")
3554 }
3555 />
3556 </SettingsSection>
3557 ) : (
3558 archivedGroups.map(({ project, threads: projectThreads }, index) => (
3559 <SettingsSection
3560 key={`${project.environmentId}:${project.id}`}
3561 id={index === 0 ? searchableSetting("archive").id : undefined}
3562 title={project.title}
3563 icon={<ProjectFavicon project={project} />}
3564 >
3565 {projectThreads.map((thread) => (
3566 <SettingsRow
3567 key={thread.id}
3568 onContextMenu={(event) => {
3569 event.preventDefault();
3570 void (async () => {
3571 const result = await settlePromise(() =>
3572 handleArchivedThreadContextMenu(
3573 scopeThreadRef(thread.environmentId, thread.id),
3574 {
3575 x: event.clientX,
3576 y: event.clientY,
3577 },
3578 ),
3579 );
3580 if (result._tag === "Failure") {
3581 const error = squashAtomCommandFailure(result);
3582 toastManager.add(
3583 stackedThreadToast({
3584 type: "error",
3585 title: "Archived thread action failed",
3586 description:
3587 error instanceof Error ? error.message : "An error occurred.",
3588 }),
3589 );
3590 }
3591 })();
3592 }}
3593 title={thread.title}
3594 description={
3595 <>
3596 Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)}
3597 {" \u00b7 Created "}
3598 {formatRelativeTimeLabel(thread.createdAt)}
3599 </>
3600 }
3601 control={
3602 <Button
3603 type="button"
3604 variant="outline"
3605 size="xs"
3606 className="shrink-0"
3607 onClick={() => {
3608 void (async () => {
3609 const result = await unarchiveThread(
3610 scopeThreadRef(thread.environmentId, thread.id),
3611 );
3612 if (result._tag === "Success") {
3613 refreshArchivedThreads();
3614 return;
3615 }
3616 if (!isAtomCommandInterrupted(result)) {
3617 const error = squashAtomCommandFailure(result);
3618 toastManager.add(
3619 stackedThreadToast({
3620 type: "error",
3621 title: "Failed to unarchive thread",
3622 description:
3623 error instanceof Error ? error.message : "An error occurred.",
3624 }),
3625 );
3626 }
3627 })();
3628 }}
3629 >
3630 <ArchiveX className="size-3.5" />
3631 <span>Unarchive</span>
3632 </Button>
3633 }
3634 />
3635 ))}
3636 </SettingsSection>
3637 ))
3638 )}
3639 </SettingsPageContainer>
3640 );
3641}