| 1 | import { UiSettingsSyncSection } from "./UiSettingsSyncSection"; |
| 2 | import { performDesktopUpdate } from "../desktopUpdate.actions"; |
| 3 | import { useTerminalPlacement } from "~/hooks/useTerminalPlacement"; |
| 4 | import { SettingsGroup } from "./SettingsGroup"; |
| 5 | import { useScopedSettingsWriteAllowed } from "./useScopedSettings"; |
| 6 | import { Spinner } from "~/components/ui/spinner"; |
| 7 | import { NotificationSettings } from "./NotificationSettings"; |
| 8 | import { PRIVACY_POLICY_URL } from "../../legalLinks"; |
| 9 | import { ArchiveIcon, ArchiveX, CheckIcon, ChevronRightIcon, SettingsIcon } from "lucide-react"; |
| 10 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 11 | import type { CSSProperties, ReactNode } from "react"; |
| 12 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 13 | import { |
| 14 | type BackgroundActivityProfile, |
| 15 | type DesktopUpdateChannel, |
| 16 | ProviderDriverKind, |
| 17 | type ProviderInstanceId, |
| 18 | type ScopedThreadRef, |
| 19 | type SidebarProjectGroupingMode, |
| 20 | } from "@t3tools/contracts"; |
| 21 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 22 | import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 23 | import { |
| 24 | isAtomCommandInterrupted, |
| 25 | settlePromise, |
| 26 | squashAtomCommandFailure, |
| 27 | } from "@t3tools/client-runtime/state/runtime"; |
| 28 | import { |
| 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"; |
| 54 | import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; |
| 55 | import { createModelSelection } from "@t3tools/shared/model"; |
| 56 | import * as Duration from "effect/Duration"; |
| 57 | import * as Equal from "effect/Equal"; |
| 58 | import * as Schema from "effect/Schema"; |
| 59 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 60 | import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; |
| 61 | import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; |
| 62 | import { |
| 63 | canCheckForUpdate, |
| 64 | getDesktopUpdateButtonTooltip, |
| 65 | isDesktopUpdateButtonDisabled, |
| 66 | resolveDesktopUpdateButtonAction, |
| 67 | } from "../../components/desktopUpdate.logic"; |
| 68 | import { ProviderModelPicker } from "../chat/ProviderModelPicker"; |
| 69 | import { TraitsPicker } from "../chat/TraitsPicker"; |
| 70 | import { |
| 71 | resolveEnvironmentIdentificationPillLabel, |
| 72 | useEnvironmentStageLabel, |
| 73 | } from "../SidebarStageBackdrop"; |
| 74 | import { isElectron } from "../../env"; |
| 75 | import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; |
| 76 | import { useCustomThemes } from "../../hooks/useCustomThemes"; |
| 77 | import { |
| 78 | readAppearanceModePreference, |
| 79 | readThemeHalves, |
| 80 | readThemePreference, |
| 81 | useTheme, |
| 82 | } from "../../hooks/useTheme"; |
| 83 | import { useLocalStorage } from "../../hooks/useLocalStorage"; |
| 84 | import { |
| 85 | useScopedSettings, |
| 86 | useScopedSettingsMixed, |
| 87 | useUpdateScopedSettings, |
| 88 | } from "./useScopedSettings"; |
| 89 | import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; |
| 90 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 91 | import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; |
| 92 | import { useThreadActions } from "../../hooks/useThreadActions"; |
| 93 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 94 | import { |
| 95 | getCustomModelOptionsByInstance, |
| 96 | resolveAppModelSelectionState, |
| 97 | selectsPlanAgent, |
| 98 | } from "../../modelSelection"; |
| 99 | import { |
| 100 | applyProviderInstanceSettings, |
| 101 | deriveProviderInstanceEntries, |
| 102 | sortProviderInstanceEntries, |
| 103 | } from "../../providerInstances"; |
| 104 | import { ensureLocalApi, readLocalApi } from "../../localApi"; |
| 105 | import { isMacPlatform } from "../../lib/utils"; |
| 106 | import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; |
| 107 | import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; |
| 108 | import { formatRelativeTimeLabel } from "../../timestampFormat"; |
| 109 | import { Button } from "../ui/button"; |
| 110 | import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; |
| 111 | import { |
| 112 | Dialog, |
| 113 | DialogDescription, |
| 114 | DialogFooter, |
| 115 | DialogHeader, |
| 116 | DialogPanel, |
| 117 | DialogPopup, |
| 118 | DialogTitle, |
| 119 | } from "../ui/dialog"; |
| 120 | import { DraftInput } from "../ui/draft-input"; |
| 121 | import { Input } from "../ui/input"; |
| 122 | import { |
| 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"; |
| 132 | import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; |
| 133 | import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; |
| 134 | import { |
| 135 | NumberField, |
| 136 | NumberFieldDecrement, |
| 137 | NumberFieldGroup, |
| 138 | NumberFieldIncrement, |
| 139 | NumberFieldInput, |
| 140 | } from "../ui/number-field"; |
| 141 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| 142 | import { Switch } from "../ui/switch"; |
| 143 | import { ScopedSwitch } from "./ScopedSwitch"; |
| 144 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 145 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 146 | import { ThemeLibrary } from "./ThemeSettings"; |
| 147 | import { |
| 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"; |
| 162 | import { |
| 163 | PolicyTooltip, |
| 164 | SETTINGS_PICKER_TRIGGER_CLASSNAME, |
| 165 | SettingResetButton, |
| 166 | SettingsPageContainer, |
| 167 | SettingsRow, |
| 168 | SettingsSection, |
| 169 | useSettingsSearchTarget, |
| 170 | useSettingsSearchTargetId, |
| 171 | } from "./settingsLayout"; |
| 172 | import { searchableSetting } from "./settingsSearch"; |
| 173 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 174 | import { PanelAnimationsPreview } from "./PanelAnimationsPreview"; |
| 175 | |
| 176 | const ENVIRONMENT_IDENTIFICATION_LABELS: Record<EnvironmentIdentificationMode, string> = { |
| 177 | artwork: "Artwork", |
| 178 | pill: "Version pill", |
| 179 | none: "None", |
| 180 | }; |
| 181 | |
| 182 | const RESPONSE_STREAMING_MODE_LABELS: Record<ResponseStreamingMode, string> = { |
| 183 | turn: "Wait for the full response", |
| 184 | paragraph: "Show finished paragraphs", |
| 185 | }; |
| 186 | |
| 187 | const 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 | |
| 192 | const SIDEBAR_PROJECT_SORT_ORDER_LABELS: Record<SidebarProjectSortOrder, string> = { |
| 193 | updated_at: "Last user message", |
| 194 | created_at: "Created at", |
| 195 | manual: "Manual", |
| 196 | }; |
| 197 | const isSidebarProjectSortOrder = Schema.is(SidebarProjectSortOrder); |
| 198 | |
| 199 | const TIMESTAMP_FORMAT_LABELS = { |
| 200 | locale: "System default", |
| 201 | "12-hour": "12-hour", |
| 202 | "24-hour": "24-hour", |
| 203 | } as const; |
| 204 | |
| 205 | const CHAT_WIDTH_LABELS: Record<ChatWidth, string> = { |
| 206 | comfortable: "Comfortable", |
| 207 | wide: "Wide", |
| 208 | full: "Full", |
| 209 | }; |
| 210 | |
| 211 | const DIFF_LAYOUT_LABELS: Record<DiffLayout, string> = { |
| 212 | stacked: "Stacked", |
| 213 | split: "Split", |
| 214 | }; |
| 215 | |
| 216 | const QUIT_CONFIRMATION_MODE_LABELS: Record<QuitConfirmationMode, string> = { |
| 217 | direct: "Direct", |
| 218 | hold: "Hold", |
| 219 | "double-click": "Double press", |
| 220 | }; |
| 221 | |
| 222 | const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record<BackgroundActivityProfile, string> = { |
| 223 | balanced: "Balanced", |
| 224 | performance: "Performance", |
| 225 | "battery-saver": "Battery saver", |
| 226 | }; |
| 227 | |
| 228 | type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; |
| 229 | |
| 230 | const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfileOption, string> = { |
| 231 | ...BACKGROUND_ACTIVITY_PROFILE_LABELS, |
| 232 | advanced: "Advanced", |
| 233 | }; |
| 234 | |
| 235 | const 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 | |
| 241 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals."; |
| 242 | |
| 243 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 244 | const 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 | |
| 258 | function resetBackgroundActivitySettings() { |
| 259 | return { |
| 260 | backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, |
| 261 | }; |
| 262 | } |
| 263 | |
| 264 | function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { |
| 265 | return { |
| 266 | backgroundActivity: { |
| 267 | schemaVersion: 1 as const, |
| 268 | profile, |
| 269 | overrides: {}, |
| 270 | }, |
| 271 | }; |
| 272 | } |
| 273 | |
| 274 | function 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 | |
| 286 | function 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 | |
| 454 | export 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 | |
| 817 | function 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 | |
| 1095 | export 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 | |
| 1463 | function 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 | |
| 1473 | function 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 | |
| 1492 | function 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 | |
| 1523 | function 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 | |
| 1554 | function 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 | |
| 1595 | function 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 | |
| 1636 | function 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 | |
| 1665 | function 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 | |
| 1691 | function 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 | */ |
| 1708 | function 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. |
| 1740 | const 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 | */ |
| 1755 | function 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 | |
| 1793 | function 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 | |
| 1991 | const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; |
| 1992 | |
| 1993 | function 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. |
| 2037 | const 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 | */ |
| 2048 | function 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 | |
| 2127 | export 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 | |
| 3422 | export 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 | } |