import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; import { Link } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { type BackgroundActivityProfile, type DesktopUpdateChannel, ProviderDriverKind, type ScopedThreadRef, type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, DEFAULT_UNIFIED_SETTINGS, type EnvironmentIdentificationMode, MAX_CODE_FONT_SIZE, MAX_GLASS_OPACITY, MAX_INTERFACE_FONT_SIZE, MAX_PROMPT_FONT_SIZE, MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MAX_TERMINAL_FONT_SIZE, MIN_CODE_FONT_SIZE, MIN_GLASS_OPACITY, MIN_INTERFACE_FONT_SIZE, MIN_PROMPT_FONT_SIZE, MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, MIN_TERMINAL_FONT_SIZE, } from "@t3tools/contracts/settings"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { createModelSelection } from "@t3tools/shared/model"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveEnvironmentIdentificationPillLabel, useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; import { isElectron } from "../../env"; import { buildHostedChannelSelectionUrl, type HostedAppChannel } from "../../hostedPairing"; import { useCustomThemes } from "../../hooks/useCustomThemes"; import { readAppearanceModePreference, readThemeHalves, readThemePreference, useTheme, } from "../../hooks/useTheme"; import { useLocalStorage } from "../../hooks/useLocalStorage"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; import { useDesktopUpdateState } from "../../state/desktopUpdate"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { primaryServerObservabilityAtom, primaryServerProvidersAtom } from "../../state/server"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Dialog, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "../ui/dialog"; import { DraftInput } from "../ui/draft-input"; import { Input } from "../ui/input"; import { DEFAULT_CODE_FONT_STACK, DEFAULT_SANS_FONT_STACK, isFontFamilyAvailable, isMonospaceFamily, resolveDefaultFamilyLabel, resolveTerminalFontPreference, resolveTerminalFontSizePreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, NumberFieldDecrement, NumberFieldGroup, NumberFieldIncrement, NumberFieldInput, } from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { ThemeLibrary } from "./ThemeSettings"; import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, durationToSeconds, formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, readLastEnabledProjectGroupingMode, rememberEnabledProjectGroupingMode, resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; import { PolicyTooltip, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { ProjectFavicon } from "../ProjectFavicon"; const ENVIRONMENT_IDENTIFICATION_LABELS: Record = { artwork: "Artwork", pill: "Version pill", none: "None", }; const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", "24-hour": "24-hour", } as const; const BACKGROUND_ACTIVITY_PROFILE_LABELS: Record = { balanced: "Balanced", performance: "Performance", "battery-saver": "Battery saver", }; type BackgroundActivityProfileOption = BackgroundActivityProfile | "advanced"; const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record = { ...BACKGROUND_ACTIVITY_PROFILE_LABELS, advanced: "Advanced", }; const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record = { balanced: "Pauses background probes when clients are idle, the host is locked, or low power mode is active.", performance: "Allows scoped background probes while any subscribed client remains connected.", "battery-saver": "Also pauses background probes when the host or client is on battery.", }; const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom background intervals with the selected shared power policy."; const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ readonly key: | "pauseWhenHostLocked" | "pauseWhenHostLowPower" | "pauseWhenClientLowPower" | "pauseWhenOnBattery"; readonly label: string; }> = [ { key: "pauseWhenHostLocked", label: "Pause when host is locked" }, { key: "pauseWhenHostLowPower", label: "Pause on host low power" }, { key: "pauseWhenClientLowPower", label: "Pause on client low power" }, { key: "pauseWhenOnBattery", label: "Pause on battery" }, ]; function resetBackgroundActivitySettings() { return { backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, }; } function backgroundActivityProfileSettings(profile: BackgroundActivityProfile) { return { backgroundActivity: { schemaVersion: 1 as const, profile, overrides: {}, }, }; } function AboutVersionTitle() { return ( Version {APP_VERSION} ); } function AboutVersionSection() { const updateState = useDesktopUpdateState(); const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false); const [isUpdateActionPending, setIsUpdateActionPending] = useState(false); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL; const handleUpdateChannelChange = useCallback( (channel: DesktopUpdateChannel) => { const bridge = window.desktopBridge; if ( !bridge || typeof bridge.setUpdateChannel !== "function" || channel === selectedUpdateChannel ) { return; } setIsChangingUpdateChannel(true); void bridge .setUpdateChannel(channel) .catch((error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not change update track", description: error instanceof Error ? error.message : "Update track change failed.", }), ); }) .finally(() => { setIsChangingUpdateChannel(false); }); }, [selectedUpdateChannel], ); const handleButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge) return; const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; if (action === "download") { void bridge.downloadUpdate().catch((error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not download update", description: error instanceof Error ? error.message : "Download failed.", }), ); }); return; } if (action === "install") { if (isUpdateActionPending) return; setIsUpdateActionPending(true); let confirmed = false; try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage( updateState ?? { availableVersion: null, downloadedVersion: null }, ), ); } catch (error) { setIsUpdateActionPending(false); toastManager.add( stackedThreadToast({ type: "error", title: "Could not confirm update", description: error instanceof Error ? error.message : "Update confirmation failed.", }), ); return; } if (!confirmed) { setIsUpdateActionPending(false); return; } void bridge .installUpdate() .catch((error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not install update", description: error instanceof Error ? error.message : "Install failed.", }), ); }) .finally(() => setIsUpdateActionPending(false)); return; } if (typeof bridge.checkForUpdate !== "function") return; void bridge .checkForUpdate() .then((result) => { if (!result.checked) { toastManager.add( stackedThreadToast({ type: "error", title: "Could not check for updates", description: result.state.message ?? "Automatic updates are not available in this build.", }), ); } }) .catch((error: unknown) => { toastManager.add( stackedThreadToast({ type: "error", title: "Could not check for updates", description: error instanceof Error ? error.message : "Update check failed.", }), ); }); }, [isUpdateActionPending, updateState]); const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none"; const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null; const buttonDisabled = action === "none" ? !canCheckForUpdate(updateState) : isDesktopUpdateButtonDisabled(updateState); const actionLabel: Record = { download: "Download", install: "Install" }; const statusLabel: Record = { checking: "Checking…", downloading: "Downloading…", "up-to-date": "Up to Date", }; const buttonLabel = actionLabel[action] ?? statusLabel[updateState?.status ?? ""] ?? "Check for Updates"; const description = action === "download" || action === "install" ? "Update available." : "Current version of the application."; return ( <> } description={description} control={ {buttonLabel} } /> {buttonTooltip ? {buttonTooltip} : null} } /> {hasDesktopBridge ? ( { handleUpdateChannelChange(value as DesktopUpdateChannel); }} > {selectedUpdateChannel === "nightly" ? "Nightly" : "Stable"} Stable Nightly } /> ) : selectedHostedAppChannel ? ( { if (value === selectedHostedAppChannel) return; window.location.assign( buildHostedChannelSelectionUrl({ channel: value as HostedAppChannel }), ); }} > {HOSTED_APP_CHANNEL_LABEL} Latest Nightly } /> ) : null} ); } export function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme, followSystem, setFollowSystem, setThemeHalf, clearThemeHalves, themeHalves, } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); const isBackgroundActivityDirty = hasChangedBackgroundActivitySettings(settings); const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), ...(!followSystem ? ["Follow system"] : []), ...(themeHalves !== null ? ["Theme mix"] : []), ...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []), ...(settings.environmentIdentificationMode !== DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode ? ["Environment identification"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), ...(settings.sidebarProjectGroupingMode !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), ...(settings.enableLegacyTokenStreaming !== DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming ? ["Stream token by token"] : []), ...(settings.enableProviderUpdateChecks !== DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] : []), ...(isBackgroundActivityDirty ? ["Background activity"] : []), ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ? ["New thread mode"] : []), ...(settings.newWorktreesStartFromOrigin !== DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin ? ["New worktrees start from origin"] : []), ...(settings.addProjectBaseDirectory !== DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory ? ["Add project base directory"] : []), ...(settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ["Archive confirmation"] : []), ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ["Delete confirmation"] : []), ...(settings.confirmQuit !== DEFAULT_UNIFIED_SETTINGS.confirmQuit ? ["Quit confirmation"] : []), ...(isTextGenerationModelDirty ? ["Text generation model"] : []), ...getChangedBrowserSettingLabels(settings), ...(settings.enableAgentBrowserAccess !== DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess ? ["Agent browser access"] : []), ], [ isTextGenerationModelDirty, isBackgroundActivityDirty, settings.browserDefaultViewport, settings.browserDefaultZoomFactor, settings.browserDefaultAppearance, settings.browserAutoShowFloatingPreview, settings.enableAgentBrowserAccess, settings.confirmQuit, settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, settings.diffIgnoreWhitespace, settings.environmentIdentificationMode, settings.fontFamilyCode, settings.fontFamilyComposer, settings.fontFamilySans, settings.fontFamilyTerminal, settings.fontSizeCode, settings.fontSizeInterface, settings.fontSizePrompt, settings.fontSizeTerminal, settings.glassOpacity, settings.enableLegacyTokenStreaming, settings.enableProviderUpdateChecks, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, followSystem, theme, themeHalves, ], ); const restoreDefaults = useCallback(async () => { if (changedSettingLabels.length === 0) return; const api = readLocalApi(); const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( "\n", ), { variant: "destructive" }, ); if (!confirmed) return; // Only touch the theme keys that are actually dirty, so a theme-storage // failure cannot block restoring unrelated settings. Preferences are // re-read after the confirmation dialog: they may have changed (another // tab, an OS flip) while it was open, and rollback must restore the live // values rather than the ones captured at render time. let previousTheme = theme; try { previousTheme = readThemePreference(); } catch { // Storage is unreadable; the render-time value is the best rollback. } // The mix may have changed while the confirmation dialog was open; both // the dirty check and the rollback must see the live value. const liveHalves = readThemeHalves(); const needsThemeReset = previousTheme !== "system"; const needsMixReset = liveHalves !== null; // Same for the appearance mode: trusting the render-time value would skip // the reset and report success while a non-system mode stayed in storage. const needsFollowSystemReset = readAppearanceModePreference(previousTheme) !== "system"; const notifyThemeRestoreFailure = () => { toastManager.add( stackedThreadToast({ type: "error", title: "Couldn’t restore theme settings", description: "Try again.", }), ); }; // Rollback restores the base preference first (which clears any mix) and // then re-applies the captured mix on top, so no failure path can leave // the pair of keys half-restored. const previousHalves = liveHalves; const rollbackThemeState = () => { if (needsThemeReset) setTheme(previousTheme); if (previousHalves?.light) setThemeHalf("light", previousHalves.light); if (previousHalves?.dark) setThemeHalf("dark", previousHalves.dark); }; if (needsThemeReset && !setTheme("system")) { notifyThemeRestoreFailure(); return; } if (needsMixReset && !clearThemeHalves()) { rollbackThemeState(); notifyThemeRestoreFailure(); return; } if (needsFollowSystemReset && !setFollowSystem(true)) { rollbackThemeState(); notifyThemeRestoreFailure(); return; } updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, enableLegacyTokenStreaming: DEFAULT_UNIFIED_SETTINGS.enableLegacyTokenStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, providerHealthRefreshInterval: DEFAULT_UNIFIED_SETTINGS.providerHealthRefreshInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit, textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport, browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor, browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance, browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview, // Re-granted like any other default. The confirmation dialog lists it by // name, so a user restoring defaults is told the agent regains access // rather than discovering it later. enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, }); onRestored?.(); }, [ changedSettingLabels, clearThemeHalves, onRestored, setFollowSystem, setTheme, setThemeHalf, theme, themeHalves, updateSettings, ]); return { changedSettingLabels, restoreDefaults, }; } function BackgroundActivityAdvancedDialog({ open, onOpenChange, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; }) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const activeProfile = resolvedBackgroundActivity.profile; const automaticGitFetchIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.automaticGitFetchInterval, ); const providerHealthRefreshIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.providerHealthRefreshInterval, ); const hostPowerMonitorActiveIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.hostPowerMonitorActiveInterval, ); const hostPowerMonitorIdleIntervalSeconds = durationToSeconds( resolvedBackgroundActivity.hostPowerMonitorIdleInterval, ); return ( Background Activity Tune the shared power policy and the background intervals that feed it.
Shared policy

Controls whether background work may run after a subscribed interval fires.

Git fetch interval

Refresh remote branch status in the background.

updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { automaticGitFetchInterval: Duration.seconds( normalizeIntervalSeconds(value), ), }, ), ) } > seconds
Provider health interval

Refresh provider availability, versions, auth state, and model metadata.

updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { providerHealthRefreshInterval: Duration.seconds( normalizeIntervalSeconds(value), ), }, ), ) } > seconds
Host power monitor

Poll host power state while clients are active.

updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { hostPowerMonitorActiveInterval: Duration.seconds( normalizeIntervalSeconds(value, 5), ), }, ), ) } > seconds
Idle host monitor

Poll host power state when no foreground client is active.

updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { hostPowerMonitorIdleInterval: Duration.seconds( normalizeIntervalSeconds(value, 5), ), }, ), ) } > seconds
{BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES.map(({ key, label }) => ( ))}
); } export function AppearanceSettingsPanel() { const { appearanceMode, refreshTheme, resolvedTheme, setAppearanceMode, setTheme, setThemeHalf, theme, themeHalves, } = useTheme(); const customThemes = useCustomThemes(); const [isImportThemeOpen, setIsImportThemeOpen] = useState(false); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const environmentStageLabel = useEnvironmentStageLabel(); const showEnvironmentIdentification = resolveEnvironmentIdentificationPillLabel(environmentStageLabel) !== null; const glassOpacityRatio = (settings.glassOpacity - MIN_GLASS_OPACITY) / (MAX_GLASS_OPACITY - MIN_GLASS_OPACITY); const glassOpacitySliderStyle = { "--settings-slider-progress": `${glassOpacityRatio * 100}%`, "--settings-slider-fill-offset": `${0.5 - glassOpacityRatio}rem`, } as CSSProperties; return (
updateSettings({ glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity }) } /> ) : null } control={
{settings.glassOpacity}% { const glassOpacity = Number(event.currentTarget.value); if ( Number.isInteger(glassOpacity) && glassOpacity >= MIN_GLASS_OPACITY && glassOpacity <= MAX_GLASS_OPACITY ) { updateSettings({ glassOpacity }); } }} step={5} style={glassOpacitySliderStyle} type="range" value={settings.glassOpacity} />
} /> {showEnvironmentIdentification ? ( updateSettings({ environmentIdentificationMode: DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE, }) } /> ) : null } control={ } /> ) : null}
); } function useFontDefaultFamilies() { const settings = usePrimarySettings(); // An unset preference shows the font it resolves to on this machine; the // default stacks are the platform's own faces, so the name is probed, not // hardcoded. const defaults = useMemo( () => ({ sans: resolveDefaultFamilyLabel(DEFAULT_SANS_FONT_STACK) ?? "System default", code: resolveDefaultFamilyLabel(DEFAULT_CODE_FONT_STACK) ?? "System monospace", }), [], ); return { sans: defaults.sans, code: defaults.code, // The composer inherits whatever the interface preference resolves to. interfaceFamily: settings.fontFamilySans.trim() || defaults.sans, }; } function InterfaceFontRow({ preview }: { preview?: ReactNode }) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const defaults = useFontDefaultFamilies(); return ( updateSettings({ fontFamilySans })} onReset={() => updateSettings({ fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, }) } size={{ label: "Interface font size", min: MIN_INTERFACE_FONT_SIZE, max: MAX_INTERFACE_FONT_SIZE, value: settings.fontSizeInterface, defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), }} {...(preview !== undefined ? { preview } : {})} /> ); } function PromptFontRow() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const defaults = useFontDefaultFamilies(); return ( updateSettings({ fontFamilyComposer })} onReset={() => updateSettings({ fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, }) } size={{ label: "Prompt font size", min: MIN_PROMPT_FONT_SIZE, max: MAX_PROMPT_FONT_SIZE, value: settings.fontSizePrompt, defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), }} preview={} /> ); } function CodeFontRow({ title, description = "Code blocks, diffs, and file previews.", preview, }: { title?: string; description?: string; preview?: ReactNode; }) { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const defaults = useFontDefaultFamilies(); return ( updateSettings({ fontFamilyCode })} onReset={() => updateSettings({ fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, }) } requireMonospace size={{ label: "Code font size", min: MIN_CODE_FONT_SIZE, max: MAX_CODE_FONT_SIZE, value: settings.fontSizeCode, defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), }} preview={preview ?? } /> ); } function TerminalFontRow() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const defaults = useFontDefaultFamilies(); return ( updateSettings({ fontFamilyTerminal })} onReset={() => updateSettings({ fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, }) } requireMonospace size={{ label: "Terminal font size", min: MIN_TERMINAL_FONT_SIZE, max: MAX_TERMINAL_FONT_SIZE, value: settings.fontSizeTerminal, defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), }} preview={ } /> ); } function FontSmoothingRow() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); if (!isMacPlatform(navigator.platform)) return null; return ( updateSettings({ fontSmoothing: DEFAULT_UNIFIED_SETTINGS.fontSmoothing }) } /> ) : null } control={ updateSettings({ fontSmoothing: Boolean(checked) })} aria-label="Font smoothing" /> } /> ); } function WordWrapRow() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); return ( updateSettings({ wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap })} /> ) : null } control={ updateSettings({ wordWrap: Boolean(checked) })} aria-label="Wrap code, tables, diffs, and file previews by default" /> } /> ); } function FontSettingsGroup() { return ( <> ); } /** * The two-font view: one sans, one monospace. The prompt follows the * interface font and the terminal follows the monospace font, so the demos * under each row show every surface the choice reaches. */ function SimpleFontRows() { const settings = usePrimarySettings(); return ( <> } /> } /> ); } // Font smoothing only renders on macOS, so a search jump to it elsewhere // must not flip the section - the target would never mount to be scrolled to. const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ "prompt-font", "terminal-font", ...(typeof navigator !== "undefined" && isMacPlatform(navigator.platform) ? ["font-smoothing"] : []), ]); /** * The two-font view by default - one sans, one monospace, each cascading to * every surface it reaches - with an Advanced switch in the section header * that reveals the per-surface override rows. The choice persists locally, * and a settings-search jump to an override row flips Advanced on so the * target exists to scroll to. */ function TypographySection() { const [advanced, setAdvanced] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, Schema.Boolean, ); const searchTargetId = useSettingsSearchTargetId(); // Flip Advanced on once per search jump so the hidden target can mount and // scroll; tracking the handled id lets the user turn it back off without // the still-set target immediately re-expanding the section. const lastExpandedTargetRef = useRef(null); useEffect(() => { if (searchTargetId === null || !ADVANCED_TYPOGRAPHY_TARGET_IDS.has(searchTargetId)) return; if (lastExpandedTargetRef.current === searchTargetId) return; lastExpandedTargetRef.current = searchTargetId; setAdvanced(true); }, [searchTargetId, setAdvanced]); return ( Advanced setAdvanced(Boolean(checked))} aria-label="Show advanced typography settings" /> } > {advanced ? : } ); } function FontFamilySettingsRow({ id, title, description, defaultFamily, defaultValue, preview, value, onValueChange, onReset, requireMonospace = false, size, }: { id?: string; title: string; description: string; /** What an unset preference renders as, e.g. "Menlo". */ defaultFamily: string; /** The persisted family value supplied by the unified settings defaults. */ defaultValue: string; preview?: ReactNode; value: string; onValueChange: (value: string) => void; onReset: () => void; requireMonospace?: boolean; size: { label: string; min: number; max: number; value: number; defaultValue: number; onChange: (v: number) => void; }; }) { const trimmed = value.trim(); // The fallback input edits a draft; the preference only commits once typing // pauses and the text probes as an available font (or is an explicit // clear), so the current font holds and nothing reflows mid-word. const [draft, setDraft] = useState(value); const [draftSettled, setDraftSettled] = useState(true); const commitTimerRef = useRef(null); const lastValueRef = useRef(value); if (lastValueRef.current !== value) { // The committed value changed externally (hydration, reset, picker // selection); adopt it and drop any pending commit of a stale draft. lastValueRef.current = value; if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } setDraft(value); setDraftSettled(true); } useEffect( () => () => { if (commitTimerRef.current !== null) window.clearTimeout(commitTimerRef.current); }, [], ); const acceptsFamily = (candidate: string) => isFontFamilyAvailable(candidate) && (!requireMonospace || isMonospaceFamily(candidate)); const commitDraft = (next: string) => { setDraftSettled(true); // A rejected name stays in the field, flagged: the terminal would silently // fall back to its default, so the row must not claim it took the value. if (next.trim().length === 0 || acceptsFamily(next)) { onValueChange(next); } }; const flushDraft = () => { if (commitTimerRef.current === null) return; window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; commitDraft(draft); }; const draftTrimmed = draft.trim(); // Flag an unknown name only once typing pauses, and never for an empty // field - that is the starting state, not a rejected entry. const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; const resetToDefault = () => { if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } setDraft(defaultValue); setDraftSettled(true); onReset(); }; const resetAction = value !== defaultValue || size.value !== size.defaultValue ? ( ) : null; const fontEnumeration = useFontEnumeration(); // Everyone starts on the plain input; focusing it is the user gesture that // runs font discovery. Where the engine can enumerate, the control then // upgrades to the picker - popped open when the swap happens under focus, // so the interaction continues without a second click. const inputFocusedRef = useRef(false); const familyControl = fontEnumeration.status === "granted" ? ( ) : ( { inputFocusedRef.current = true; discoverInstalledFonts(); }} onBlur={() => { inputFocusedRef.current = false; flushDraft(); }} onChange={(event) => { const next = event.currentTarget.value; setDraft(next); setDraftSettled(false); if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); } commitTimerRef.current = window.setTimeout(() => { commitTimerRef.current = null; commitDraft(next); }, 400); }} onKeyDown={(event) => { if (event.key === "Enter") flushDraft(); if (event.key === "Escape") { // Discard uncommitted typing without closing the settings page, // which is what an unhandled Escape does. event.preventDefault(); event.stopPropagation(); if (commitTimerRef.current !== null) { window.clearTimeout(commitTimerRef.current); commitTimerRef.current = null; } setDraft(value); setDraftSettled(true); } }} placeholder={defaultFamily} spellCheck={false} value={draft} /> ); const control = (
{familyControl}
); return ( {preview} ); } const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 3; function AutoSettleDaysInput({ value, onCommit, }: { value: number; onCommit: (days: number) => void; }) { // Local draft so the field can be emptied mid-edit; the setting only moves // on valid input and snaps back to the persisted value on blur. const [draft, setDraft] = useState(String(value)); useEffect(() => { setDraft(String(value)); }, [value]); return ( { setDraft(event.target.value); // Number(), not parseInt: "3.5" must be rejected (not truncated to a // committed 3 while the field shows 3.5) — commit only when the // persisted value matches the displayed one. const parsed = Number(event.target.value); if ( Number.isInteger(parsed) && parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS ) { onCommit(parsed); } }} onBlur={() => setDraft(String(value))} aria-label="Days of inactivity before auto-settle" /> ); } // The legacy rows sit behind the fold, so a settings-search jump has to // expand the section before its target can mount and scroll. const LEGACY_FEATURE_TARGET_IDS: ReadonlySet = new Set([ "legacy-plan-mode", "legacy-token-streaming", "legacy-sidebar", ]); /** * Retired features kept only for users who still depend on them. Collapsed by * default so they stay out of the everyday settings path; a settings-search * jump to one of the rows unfolds the section. */ function LegacyFeaturesSection() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const [open, setOpen] = useState(false); const searchTargetId = useSettingsSearchTargetId(); // Unfold once per search jump; tracking the handled id lets the user fold // the section back up without the still-set target immediately reopening it. const lastExpandedTargetRef = useRef(null); useEffect(() => { if (searchTargetId === null) { // A handled jump clears the target; forgetting it here lets a later // jump to the same row expand the section again. lastExpandedTargetRef.current = null; return; } if (!LEGACY_FEATURE_TARGET_IDS.has(searchTargetId)) return; if (lastExpandedTargetRef.current === searchTargetId) return; lastExpandedTargetRef.current = searchTargetId; setOpen(true); }, [searchTargetId]); return (

Legacy features

updateSettings({ planModeEnabled: Boolean(checked) }) } aria-label="Plan mode (legacy)" /> } /> { if (!checked) { updateSettings({ enableLegacyTokenStreaming: false }); return; } void (async () => { const api = readLocalApi(); const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( [ "Turn on token-by-token output?", "It is significantly slower than the default buffered output and hurts the reading experience. This switch exists only for backwards compatibility.", ].join("\n"), ); if (confirmed) updateSettings({ enableLegacyTokenStreaming: true }); })(); }} aria-label="Stream token by token (legacy)" /> } /> updateSettings({ legacySidebarEnabled: Boolean(checked) }) } aria-label="Sidebar (legacy)" /> } />
); } export function GeneralSettingsPanel() { const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); const [backgroundActivityDialogOpen, setBackgroundActivityDialogOpen] = useState(false); const lastEnabledProjectGroupingMode = useRef( readLastEnabledProjectGroupingMode(), ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, otlpTracesUrl: observability?.otlpTracesUrl, otlpMetricsEnabled: observability?.otlpMetricsEnabled ?? false, otlpMetricsUrl: observability?.otlpMetricsUrl, }); const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; const textGenerationModelInstanceEntries = sortProviderInstanceEntries( applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), ); const textGenInstanceEntry = textGenerationModelInstanceEntries.find( (entry) => entry.instanceId === textGenInstanceId, ); const textGenProvider: ProviderDriverKind = textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND; const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance( settings, serverProviders, textGenInstanceId, textGenModel, ); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, ); const resolvedBackgroundActivity = resolveServerBackgroundActivitySettings(settings); const activeBackgroundActivityProfile = resolvedBackgroundActivity.profile; const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); const backgroundActivityDescription = backgroundActivityProfileOption === "advanced" ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${ BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] }.` : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; const canResetBackgroundActivity = !Equal.equals( settings.backgroundActivity, DEFAULT_UNIFIED_SETTINGS.backgroundActivity, ); return ( updateSettings({ sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, }) } /> ) : null } control={ { if (!checked && settings.sidebarProjectGroupingMode !== "separate") { lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode; rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode); } updateSettings({ sidebarProjectGroupingMode: projectGroupingModeFromToggle( checked, lastEnabledProjectGroupingMode.current, ), }); }} aria-label="Project grouping" /> } /> updateSettings({ sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, }) } /> ) : null } control={ updateSettings({ sidebarAutoSettleOnMerge: Boolean(checked) }) } aria-label="Auto-settle merged threads" /> } /> updateSettings({ sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, }) } /> ) : null } control={ updateSettings({ sidebarAutoSettleAfterDays: checked ? AUTO_SETTLE_DEFAULT_DAYS : null, }) } aria-label="Auto-settle inactive threads" /> } /> {settings.sidebarAutoSettleAfterDays !== null ? ( updateSettings({ sidebarAutoSettleAfterDays: days })} /> } /> ) : null} updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, }) } /> ) : null } control={ } /> updateSettings({ diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, }) } /> ) : null } control={ updateSettings({ diffIgnoreWhitespace: Boolean(checked) }) } aria-label="Hide whitespace changes by default" /> } /> updateSettings({ enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, }) } /> ) : null } control={ updateSettings({ enableProviderUpdateChecks: Boolean(checked) }) } aria-label="Check provider versions" /> } /> Background activity This shared policy gates background work such as Git refreshes and provider health probes after their individual intervals elapse. } description={backgroundActivityDescription} resetAction={ canResetBackgroundActivity ? ( updateSettings(resetBackgroundActivitySettings())} /> ) : null } control={ <> {backgroundActivityProfileOption === "advanced" ? ( setBackgroundActivityDialogOpen(true)} > } /> Configure background activity ) : null} } /> updateSettings({ defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> ) : null } control={ } /> {settings.defaultThreadEnvMode === "worktree" ? ( updateSettings({ newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, }) } /> ) : null } control={ updateSettings({ newWorktreesStartFromOrigin: Boolean(checked) }) } aria-label="Start new worktrees from origin by default" /> } /> ) : null} updateSettings({ addProjectBaseDirectory: DEFAULT_UNIFIED_SETTINGS.addProjectBaseDirectory, }) } /> ) : null } control={ updateSettings({ addProjectBaseDirectory: next })} placeholder="~/" spellCheck={false} aria-label="Add project base directory" /> } /> updateSettings({ confirmThreadArchive: DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive, }) } /> ) : null } control={ updateSettings({ confirmThreadArchive: Boolean(checked) }) } aria-label="Confirm thread archiving" /> } /> updateSettings({ confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, }) } /> ) : null } control={ updateSettings({ confirmThreadDelete: Boolean(checked) }) } aria-label="Confirm thread deletion" /> } /> {isElectron ? ( updateSettings({ confirmQuit: DEFAULT_UNIFIED_SETTINGS.confirmQuit }) } /> ) : null } control={ updateSettings({ confirmQuit: Boolean(checked) })} aria-label="Hold to quit" /> } /> ) : null} updateSettings({ textGenerationModelSelection: DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, }) } /> ) : null } control={
{ updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, textGenerationModelSelection: createModelSelection(instanceId, model), }, serverProviders, ), }); }} /> {}} modelOptions={textGenModelOptions} allowPromptInjectedEffort={false} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { updateSettings({ textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, textGenerationModelSelection: createModelSelection( textGenInstanceId, textGenModel, nextOptions, ), }, serverProviders, ), }); }} />
} />
{isElectron || HOSTED_APP_CHANNEL ? ( ) : ( } description="Current version of the application." /> )} } size="xs" variant="outline"> View diagnostics } />
); } export function ArchivedThreadsPanel() { const projects = useProjects(); const { unarchiveThread, confirmAndDeleteThread } = useThreadActions(); const environmentIds = useMemo( () => [...new Set(projects.map((project) => project.environmentId))], [projects], ); const { snapshots: archivedSnapshots, error: archiveError, isLoading: isLoadingArchive, refresh: refreshArchivedThreads, } = useArchivedThreadSnapshots(environmentIds); const archivedGroups = useMemo(() => { const projectsByEnvironmentAndId = new Map( archivedSnapshots.flatMap(({ environmentId, snapshot }) => snapshot.projects.map( (project) => [ `${environmentId}:${project.id}`, { id: project.id, environmentId, name: project.title, cwd: project.workspaceRoot, faviconPath: project.faviconPath, }, ] as const, ), ), ); const threads = archivedSnapshots.flatMap(({ environmentId, snapshot }) => snapshot.threads.map((thread) => ({ ...thread, environmentId, })), ); const archivedProjects = Array.from(projectsByEnvironmentAndId.values()); const groups: Array<{ readonly project: (typeof archivedProjects)[number]; readonly threads: Array<(typeof threads)[number]>; }> = []; for (const project of archivedProjects) { const projectThreads: Array<(typeof threads)[number]> = []; for (const thread of threads) { if (thread.projectId === project.id && thread.environmentId === project.environmentId) { projectThreads.push(thread); } } if (projectThreads.length > 0) { groups.push({ project, threads: projectThreads.toSorted((left, right) => { const leftKey = left.archivedAt ?? left.createdAt; const rightKey = right.archivedAt ?? right.createdAt; return rightKey.localeCompare(leftKey) || right.id.localeCompare(left.id); }), }); } } return groups; }, [archivedSnapshots]); const handleArchivedThreadContextMenu = useCallback( async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { const api = readLocalApi(); if (!api) return; const clicked = await api.contextMenu.show( [ { id: "unarchive", label: "Unarchive" }, { id: "delete", label: "Delete", destructive: true }, ], position, ); if (clicked === "unarchive") { const result = await unarchiveThread(threadRef); if (result._tag === "Success") { refreshArchivedThreads(); } else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to unarchive thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } return; } if (clicked === "delete") { const result = await confirmAndDeleteThread(threadRef); if (result._tag === "Success") { refreshArchivedThreads(); } else if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Failed to delete thread", description: error instanceof Error ? error.message : "An error occurred.", }), ); } } }, [confirmAndDeleteThread, refreshArchivedThreads, unarchiveThread], ); return ( {archivedGroups.length === 0 ? ( {isLoadingArchive ? ( ) : ( )} {isLoadingArchive ? "Loading archived threads" : archiveError ? "Could not load archived threads" : "No archived threads"} } description={ isLoadingArchive ? "Checking connected environments." : (archiveError ?? "Archived threads will appear here.") } /> ) : ( archivedGroups.map(({ project, threads: projectThreads }, index) => ( } > {projectThreads.map((thread) => ( { event.preventDefault(); void (async () => { const result = await settlePromise(() => handleArchivedThreadContextMenu( scopeThreadRef(thread.environmentId, thread.id), { x: event.clientX, y: event.clientY, }, ), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title: "Archived thread action failed", description: error instanceof Error ? error.message : "An error occurred.", }), ); } })(); }} title={thread.title} description={ <> Archived {formatRelativeTimeLabel(thread.archivedAt ?? thread.createdAt)} {" \u00b7 Created "} {formatRelativeTimeLabel(thread.createdAt)} } control={ } /> ))} )) )} ); }