From ec14092c383c303e16de6798c6548959adb016f8 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Tue, 2 Sep 2025 20:17:36 +0000 Subject: [PATCH] feat: replace unsaved changes dialog with auto-save system - Remove disruptive "Unsaved Changes" confirmation dialog - Implement automatic saving with 500ms debouncing - Add visual status indicators (orange for saving, green for saved, red for errors) - Prevent profile switching during active save operations - Update translation strings to reflect new behavior - Simplify checkUnsaveChanges to work with auto-save - Background saves continue even when closing settings panel Fixes #7599 --- .../src/components/settings/SettingsView.tsx | 264 +++++++++++------- webview-ui/src/i18n/locales/en/settings.json | 7 +- 2 files changed, 169 insertions(+), 102 deletions(-) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f680f3e5fe..d377e02fee 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -18,12 +18,13 @@ import { Database, SquareTerminal, FlaskConical, - AlertTriangle, Globe, Info, MessageSquare, LucideIcon, + Circle, } from "lucide-react" +import { useDebounce } from "react-use" import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types" @@ -31,22 +32,7 @@ import { vscode } from "@src/utils/vscode" import { cn } from "@src/lib/utils" import { useAppTranslation } from "@src/i18n/TranslationContext" import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext" -import { - AlertDialog, - AlertDialogContent, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, - AlertDialogHeader, - AlertDialogFooter, - Button, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, - StandardTooltip, -} from "@src/components/ui" +import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, StandardTooltip } from "@src/components/ui" import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab" import { SetCachedStateField, SetExperimentEnabled } from "./types" @@ -103,17 +89,16 @@ const SettingsView = forwardRef(({ onDone, t const extensionState = useExtensionState() const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState - const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) - const [isChangeDetected, setChangeDetected] = useState(false) const [errorMessage, setErrorMessage] = useState(undefined) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) ? (targetSection as SectionName) : "providers", ) + const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle") + const [saveError, setSaveError] = useState(undefined) const prevApiConfigName = useRef(currentApiConfigName) - const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(extensionState) @@ -196,14 +181,14 @@ const SettingsView = forwardRef(({ onDone, t setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName - setChangeDetected(false) - }, [currentApiConfigName, extensionState, isChangeDetected]) + setSaveStatus("idle") + }, [currentApiConfigName, extensionState]) // Bust the cache when settings are imported. useEffect(() => { if (settingsImportedAt) { setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) - setChangeDetected(false) + setSaveStatus("idle") } }, [settingsImportedAt, extensionState]) @@ -213,7 +198,7 @@ const SettingsView = forwardRef(({ onDone, t return prevState } - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, [field]: value } }) }, []) @@ -232,7 +217,7 @@ const SettingsView = forwardRef(({ onDone, t const isInitialSync = !isUserAction && previousValue === undefined && value !== undefined if (!isInitialSync) { - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made } return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } } }) @@ -246,7 +231,7 @@ const SettingsView = forwardRef(({ onDone, t return prevState } - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } } }) }, []) @@ -257,21 +242,21 @@ const SettingsView = forwardRef(({ onDone, t return prevState } - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, telemetrySetting: setting } }) }, []) const setOpenRouterImageApiKey = useCallback((apiKey: string) => { setCachedState((prevState) => { - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, openRouterImageApiKey: apiKey } }) }, []) const setImageGenerationSelectedModel = useCallback((model: string) => { setCachedState((prevState) => { - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, openRouterImageGenerationSelectedModel: model } }) }, []) @@ -282,15 +267,24 @@ const SettingsView = forwardRef(({ onDone, t return prevState } - setChangeDetected(true) + setSaveStatus("idle") // Reset to idle when changes are made return { ...prevState, customSupportPrompts: prompts } }) }, []) const isSettingValid = !errorMessage - const handleSubmit = () => { - if (isSettingValid) { + const performSave = useCallback(() => { + if (!isSettingValid) { + setSaveStatus("error") + setSaveError(errorMessage) + return + } + + setSaveStatus("saving") + setSaveError(undefined) + + try { vscode.postMessage({ type: "language", text: language }) vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly }) vscode.postMessage({ @@ -364,44 +358,126 @@ const SettingsView = forwardRef(({ onDone, t type: "openRouterImageGenerationSelectedModel", text: openRouterImageGenerationSelectedModel, }) - setChangeDetected(false) - } - } + setSaveStatus("saved") + // Reset to idle after showing saved status for a bit + setTimeout(() => { + setSaveStatus("idle") + }, 2000) + } catch (error) { + setSaveStatus("error") + setSaveError(error instanceof Error ? error.message : "Failed to save settings") + } + }, [ + isSettingValid, + errorMessage, + language, + alwaysAllowReadOnly, + alwaysAllowReadOnlyOutsideWorkspace, + alwaysAllowWrite, + alwaysAllowWriteOutsideWorkspace, + alwaysAllowWriteProtected, + alwaysAllowExecute, + alwaysAllowBrowser, + alwaysAllowMcp, + allowedCommands, + deniedCommands, + allowedMaxRequests, + allowedMaxCost, + autoCondenseContext, + autoCondenseContextPercent, + browserToolEnabled, + soundEnabled, + ttsEnabled, + ttsSpeed, + soundVolume, + diffEnabled, + enableCheckpoints, + browserViewportSize, + remoteBrowserHost, + remoteBrowserEnabled, + fuzzyMatchThreshold, + writeDelayMs, + screenshotQuality, + terminalOutputLineLimit, + terminalOutputCharacterLimit, + terminalShellIntegrationTimeout, + terminalShellIntegrationDisabled, + terminalCommandDelay, + terminalPowershellCounter, + terminalZshClearEolMark, + terminalZshOhMy, + terminalZshP10k, + terminalZdotdir, + terminalCompressProgressBar, + mcpEnabled, + alwaysApproveResubmit, + requestDelaySeconds, + maxOpenTabsContext, + maxWorkspaceFiles, + showRooIgnoredFiles, + maxReadFileLine, + maxImageFileSize, + maxTotalImageSize, + cachedState.maxConcurrentFileReads, + includeDiagnosticMessages, + maxDiagnosticMessages, + currentApiConfigName, + experiments, + alwaysAllowModeSwitch, + alwaysAllowSubtasks, + alwaysAllowFollowupQuestions, + alwaysAllowUpdateTodoList, + followupAutoApproveTimeoutMs, + condensingApiConfigId, + customCondensingPrompt, + customSupportPrompts, + includeTaskHistoryInEnhance, + apiConfiguration, + telemetrySetting, + profileThresholds, + openRouterImageApiKey, + openRouterImageGenerationSelectedModel, + ]) + + // Auto-save with debouncing + useDebounce( + () => { + // Only auto-save if we have changes (status is idle) and settings are valid + if (saveStatus === "idle" && isSettingValid) { + performSave() + } + }, + 500, // 500ms debounce + [saveStatus, isSettingValid, performSave], + ) + + // Simplified checkUnsaveChanges - just execute the action immediately since we auto-save const checkUnsaveChanges = useCallback( (then: () => void) => { - if (isChangeDetected) { - confirmDialogHandler.current = then - setDiscardDialogShow(true) + // If we're currently saving, wait for it to complete + if (saveStatus === "saving") { + // Wait a bit for save to complete, then execute + setTimeout(then, 100) } else { then() } }, - [isChangeDetected], + [saveStatus], ) useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges]) - const onConfirmDialogResult = useCallback( - (confirm: boolean) => { - if (confirm) { - // Discard changes: Reset state and flag - setCachedState(extensionState) // Revert to original state - setChangeDetected(false) // Reset change flag - confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) - } - // If confirm is false (Cancel), do nothing, dialog closes automatically - }, - [extensionState], // Depend on extensionState to get the latest original state - ) - - // Handle tab changes with unsaved changes check + // Handle tab changes - no need to check for unsaved changes anymore const handleTabChange = useCallback( (newTab: SectionName) => { - // Directly switch tab without checking for unsaved changes + // Prevent tab switching while saving + if (saveStatus === "saving") { + return + } setActiveTab(newTab) }, - [], // No dependency on isChangeDetected needed anymore + [saveStatus], ) // Store direct DOM element refs for each tab @@ -494,26 +570,32 @@ const SettingsView = forwardRef(({ onDone, t

{t("settings:header.title")}

-
- - - +
+ {/* Status indicator */} +
+ {saveStatus === "saving" && ( + + + + )} + {saveStatus === "saved" && ( + + + + )} + {saveStatus === "error" && ( + + + + )} + {saveStatus === "idle" && errorMessage && ( + + + + )} +
- @@ -594,11 +676,13 @@ const SettingsView = forwardRef(({ onDone, t - checkUnsaveChanges(() => - vscode.postMessage({ type: "loadApiConfiguration", text: configName }), - ) - } + onSelectConfig={(configName: string) => { + // Prevent profile switching while saving + if (saveStatus === "saving") { + return + } + vscode.postMessage({ type: "loadApiConfiguration", text: configName }) + }} onDeleteConfig={(configName: string) => vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) } @@ -764,28 +848,6 @@ const SettingsView = forwardRef(({ onDone, t )}
- - - - - - - {t("settings:unsavedChangesDialog.title")} - - - {t("settings:unsavedChangesDialog.description")} - - - - onConfirmDialogResult(false)}> - {t("settings:unsavedChangesDialog.cancelButton")} - - onConfirmDialogResult(true)}> - {t("settings:unsavedChangesDialog.discardButton")} - - - - ) }) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 7df37a0270..b2e981199f 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -12,7 +12,12 @@ "title": "Settings", "saveButtonTooltip": "Save changes", "nothingChangedTooltip": "Nothing changed", - "doneButtonTooltip": "Discard unsaved changes and close settings panel" + "doneButtonTooltip": "Close settings panel" + }, + "status": { + "saving": "Saving changes...", + "saved": "All changes saved", + "error": "Failed to save changes" }, "unsavedChangesDialog": { "title": "Unsaved Changes",