mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
fix: prevent unnecessary context condensing when toggling settings
- Fixed dirty state tracking in SettingsView to properly detect when settings are reverted to original values - Modified all setter functions to check entire state against original extension state - Only send settings updates to backend when values actually differ from saved state - Added comprehensive tests for dirty state management - This prevents unnecessary context condensing triggers during active tasks Fixes issue where Save button remained enabled after toggling settings back to original values, which could trigger unwanted context condensing even when no actual changes were made.
This commit is contained in:
parent
c4c478023d
commit
b18721b183
3 changed files with 660 additions and 136 deletions
|
|
@ -60,6 +60,9 @@ const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"])
|
|||
import { MarketplaceManager, MarketplaceItemType } from "../../services/marketplace"
|
||||
import { setPendingTodoList } from "../tools/updateTodoListTool"
|
||||
|
||||
// Cache for condense-related settings to prevent unnecessary re-evaluation
|
||||
const condenseSettingsCache = new Map<string, any>()
|
||||
|
||||
export const webviewMessageHandler = async (
|
||||
provider: ClineProvider,
|
||||
message: WebviewMessage,
|
||||
|
|
@ -70,6 +73,21 @@ export const webviewMessageHandler = async (
|
|||
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
|
||||
await provider.contextProxy.setValue(key, value)
|
||||
|
||||
// Helper function to check if a condense-related setting has actually changed
|
||||
const hasCondenseSettingChanged = (key: string, value: any): boolean => {
|
||||
const cachedValue = condenseSettingsCache.get(key)
|
||||
if (cachedValue === undefined) {
|
||||
// First time setting this value
|
||||
condenseSettingsCache.set(key, value)
|
||||
return true
|
||||
}
|
||||
const changed = JSON.stringify(cachedValue) !== JSON.stringify(value)
|
||||
if (changed) {
|
||||
condenseSettingsCache.set(key, value)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
const getCurrentCwd = () => {
|
||||
return provider.getCurrentTask()?.cwd || provider.cwd
|
||||
}
|
||||
|
|
@ -581,12 +599,18 @@ export const webviewMessageHandler = async (
|
|||
provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
|
||||
break
|
||||
case "autoCondenseContext":
|
||||
await updateGlobalState("autoCondenseContext", message.bool)
|
||||
await provider.postStateToWebview()
|
||||
// Only update if the value has actually changed to prevent unnecessary context re-evaluation
|
||||
if (hasCondenseSettingChanged("autoCondenseContext", message.bool)) {
|
||||
await updateGlobalState("autoCondenseContext", message.bool)
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "autoCondenseContextPercent":
|
||||
await updateGlobalState("autoCondenseContextPercent", message.value)
|
||||
await provider.postStateToWebview()
|
||||
// Only update if the value has actually changed to prevent unnecessary context re-evaluation
|
||||
if (hasCondenseSettingChanged("autoCondenseContextPercent", message.value)) {
|
||||
await updateGlobalState("autoCondenseContextPercent", message.value)
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "terminalOperation":
|
||||
if (message.terminalOperation) {
|
||||
|
|
@ -1650,8 +1674,11 @@ export const webviewMessageHandler = async (
|
|||
await provider.postStateToWebview()
|
||||
break
|
||||
case "profileThresholds":
|
||||
await updateGlobalState("profileThresholds", message.values)
|
||||
await provider.postStateToWebview()
|
||||
// Only update if the value has actually changed to prevent unnecessary context re-evaluation
|
||||
if (hasCondenseSettingChanged("profileThresholds", message.values)) {
|
||||
await updateGlobalState("profileThresholds", message.values)
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
break
|
||||
case "autoApprovalEnabled":
|
||||
await updateGlobalState("autoApprovalEnabled", message.bool ?? false)
|
||||
|
|
|
|||
|
|
@ -212,16 +212,39 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
}
|
||||
}, [settingsImportedAt, extensionState])
|
||||
|
||||
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState[field] === value) {
|
||||
return prevState
|
||||
}
|
||||
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback(
|
||||
(field, value) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState[field] === value) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, [field]: value }
|
||||
})
|
||||
}, [])
|
||||
const newState = { ...prevState, [field]: value }
|
||||
|
||||
// Check if any field in the new state differs from the original extension state
|
||||
// This properly handles toggling back to original values
|
||||
let hasAnyChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "apiConfiguration") continue // Handle separately
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasAnyChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Also check apiConfiguration if it exists
|
||||
if (!hasAnyChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasAnyChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasAnyChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setApiConfigurationField = useCallback(
|
||||
<K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K], isUserAction: boolean = true) => {
|
||||
|
|
@ -236,139 +259,444 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
// This prevents the dirty state when the component initializes and auto-syncs values
|
||||
const isInitialSync = !isUserAction && previousValue === undefined && value !== undefined
|
||||
|
||||
if (!isInitialSync) {
|
||||
setChangeDetected(true)
|
||||
const newState = {
|
||||
...prevState,
|
||||
apiConfiguration: { ...prevState.apiConfiguration, [field]: value },
|
||||
}
|
||||
return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
|
||||
|
||||
if (!isInitialSync) {
|
||||
// Check if any field in the new state differs from the original extension state
|
||||
let hasAnyChanges = false
|
||||
|
||||
// Check non-apiConfiguration fields
|
||||
for (const key in newState) {
|
||||
if (key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasAnyChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check apiConfiguration
|
||||
if (!hasAnyChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasAnyChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasAnyChanges)
|
||||
}
|
||||
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[],
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setExperimentEnabled: SetExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.experiments?.[id] === enabled) {
|
||||
return prevState
|
||||
}
|
||||
const setExperimentEnabled: SetExperimentEnabled = useCallback(
|
||||
(id: ExperimentId, enabled: boolean) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.experiments?.[id] === enabled) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
|
||||
})
|
||||
}, [])
|
||||
const newState = { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
|
||||
|
||||
const setTelemetrySetting = useCallback((setting: TelemetrySetting) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.telemetrySetting === setting) {
|
||||
return prevState
|
||||
}
|
||||
// Check if experiments differ from original
|
||||
const hasExperimentChanges = hasChanged(newState.experiments, extensionState.experiments)
|
||||
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, telemetrySetting: setting }
|
||||
})
|
||||
}, [])
|
||||
// Check other fields for changes
|
||||
let hasOtherChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "experiments" || key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasOtherChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
|
||||
setCachedState((prevState) => {
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, openRouterImageApiKey: apiKey }
|
||||
})
|
||||
}, [])
|
||||
// Check apiConfiguration
|
||||
if (!hasOtherChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasOtherChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
const setImageGenerationSelectedModel = useCallback((model: string) => {
|
||||
setCachedState((prevState) => {
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, openRouterImageGenerationSelectedModel: model }
|
||||
})
|
||||
}, [])
|
||||
setChangeDetected(hasExperimentChanges || hasOtherChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setCustomSupportPromptsField = useCallback((prompts: Record<string, string | undefined>) => {
|
||||
setCachedState((prevState) => {
|
||||
if (JSON.stringify(prevState.customSupportPrompts) === JSON.stringify(prompts)) {
|
||||
return prevState
|
||||
}
|
||||
const setTelemetrySetting = useCallback(
|
||||
(setting: TelemetrySetting) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.telemetrySetting === setting) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
setChangeDetected(true)
|
||||
return { ...prevState, customSupportPrompts: prompts }
|
||||
})
|
||||
}, [])
|
||||
const newState = { ...prevState, telemetrySetting: setting }
|
||||
|
||||
// Check if telemetry setting differs from original
|
||||
const hasTelemetryChange = newState.telemetrySetting !== extensionState.telemetrySetting
|
||||
|
||||
// Check other fields for changes
|
||||
let hasOtherChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "telemetrySetting" || key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasOtherChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check apiConfiguration
|
||||
if (!hasOtherChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasOtherChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasTelemetryChange || hasOtherChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setOpenRouterImageApiKey = useCallback(
|
||||
(apiKey: string) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.openRouterImageApiKey === apiKey) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
const newState = { ...prevState, openRouterImageApiKey: apiKey }
|
||||
|
||||
// Check if any field differs from original
|
||||
let hasAnyChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasAnyChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check apiConfiguration
|
||||
if (!hasAnyChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasAnyChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasAnyChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setImageGenerationSelectedModel = useCallback(
|
||||
(model: string) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState.openRouterImageGenerationSelectedModel === model) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
const newState = { ...prevState, openRouterImageGenerationSelectedModel: model }
|
||||
|
||||
// Check if any field differs from original
|
||||
let hasAnyChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasAnyChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check apiConfiguration
|
||||
if (!hasAnyChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasAnyChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasAnyChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const setCustomSupportPromptsField = useCallback(
|
||||
(prompts: Record<string, string | undefined>) => {
|
||||
setCachedState((prevState) => {
|
||||
if (JSON.stringify(prevState.customSupportPrompts) === JSON.stringify(prompts)) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
const newState = { ...prevState, customSupportPrompts: prompts }
|
||||
|
||||
// Check if any field differs from original
|
||||
let hasAnyChanges = false
|
||||
for (const key in newState) {
|
||||
if (key === "apiConfiguration") continue
|
||||
const cachedValue = newState[key as keyof ExtensionStateContextType]
|
||||
const originalValue = extensionState[key as keyof ExtensionStateContextType]
|
||||
if (hasChanged(cachedValue, originalValue)) {
|
||||
hasAnyChanges = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check apiConfiguration
|
||||
if (!hasAnyChanges && newState.apiConfiguration && extensionState.apiConfiguration) {
|
||||
hasAnyChanges = hasChanged(newState.apiConfiguration, extensionState.apiConfiguration)
|
||||
}
|
||||
|
||||
setChangeDetected(hasAnyChanges)
|
||||
return newState
|
||||
})
|
||||
},
|
||||
[extensionState],
|
||||
)
|
||||
|
||||
const isSettingValid = !errorMessage
|
||||
|
||||
// Helper function to check if a value has changed
|
||||
const hasChanged = (cachedValue: any, originalValue: any): boolean => {
|
||||
// Handle objects and arrays with deep comparison
|
||||
if (typeof cachedValue === "object" && cachedValue !== null) {
|
||||
return JSON.stringify(cachedValue) !== JSON.stringify(originalValue)
|
||||
}
|
||||
return cachedValue !== originalValue
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isSettingValid) {
|
||||
vscode.postMessage({ type: "language", text: language })
|
||||
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
|
||||
vscode.postMessage({
|
||||
type: "alwaysAllowReadOnlyOutsideWorkspace",
|
||||
bool: alwaysAllowReadOnlyOutsideWorkspace,
|
||||
})
|
||||
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
|
||||
vscode.postMessage({ type: "alwaysAllowWriteOutsideWorkspace", bool: alwaysAllowWriteOutsideWorkspace })
|
||||
vscode.postMessage({ type: "alwaysAllowWriteProtected", bool: alwaysAllowWriteProtected })
|
||||
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
|
||||
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
|
||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
|
||||
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
|
||||
vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] })
|
||||
vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
|
||||
vscode.postMessage({ type: "allowedMaxCost", value: allowedMaxCost ?? undefined })
|
||||
vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
|
||||
vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
|
||||
vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
|
||||
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
|
||||
vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled })
|
||||
vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed })
|
||||
vscode.postMessage({ type: "soundVolume", value: soundVolume })
|
||||
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
|
||||
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
|
||||
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
|
||||
vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
|
||||
vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
|
||||
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
|
||||
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
|
||||
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
|
||||
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
|
||||
vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 })
|
||||
vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout })
|
||||
vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled })
|
||||
vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay })
|
||||
vscode.postMessage({ type: "terminalPowershellCounter", bool: terminalPowershellCounter })
|
||||
vscode.postMessage({ type: "terminalZshClearEolMark", bool: terminalZshClearEolMark })
|
||||
vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy })
|
||||
vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k })
|
||||
vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir })
|
||||
vscode.postMessage({ type: "terminalCompressProgressBar", bool: terminalCompressProgressBar })
|
||||
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
|
||||
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
|
||||
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
|
||||
vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
|
||||
vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 })
|
||||
vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles })
|
||||
vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 })
|
||||
vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 })
|
||||
vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 })
|
||||
vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 })
|
||||
vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages })
|
||||
vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 })
|
||||
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
|
||||
vscode.postMessage({ type: "updateExperimental", values: experiments })
|
||||
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
|
||||
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
|
||||
vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions })
|
||||
vscode.postMessage({ type: "alwaysAllowUpdateTodoList", bool: alwaysAllowUpdateTodoList })
|
||||
vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs })
|
||||
vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" })
|
||||
vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" })
|
||||
vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
|
||||
vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true })
|
||||
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
|
||||
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
|
||||
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
|
||||
vscode.postMessage({ type: "openRouterImageApiKey", text: openRouterImageApiKey })
|
||||
vscode.postMessage({
|
||||
type: "openRouterImageGenerationSelectedModel",
|
||||
text: openRouterImageGenerationSelectedModel,
|
||||
})
|
||||
// Only send messages for settings that have actually changed
|
||||
if (hasChanged(language, extensionState.language)) {
|
||||
vscode.postMessage({ type: "language", text: language })
|
||||
}
|
||||
if (hasChanged(alwaysAllowReadOnly, extensionState.alwaysAllowReadOnly)) {
|
||||
vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
|
||||
}
|
||||
if (hasChanged(alwaysAllowReadOnlyOutsideWorkspace, extensionState.alwaysAllowReadOnlyOutsideWorkspace)) {
|
||||
vscode.postMessage({
|
||||
type: "alwaysAllowReadOnlyOutsideWorkspace",
|
||||
bool: alwaysAllowReadOnlyOutsideWorkspace,
|
||||
})
|
||||
}
|
||||
if (hasChanged(alwaysAllowWrite, extensionState.alwaysAllowWrite)) {
|
||||
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
|
||||
}
|
||||
if (hasChanged(alwaysAllowWriteOutsideWorkspace, extensionState.alwaysAllowWriteOutsideWorkspace)) {
|
||||
vscode.postMessage({ type: "alwaysAllowWriteOutsideWorkspace", bool: alwaysAllowWriteOutsideWorkspace })
|
||||
}
|
||||
if (hasChanged(alwaysAllowWriteProtected, extensionState.alwaysAllowWriteProtected)) {
|
||||
vscode.postMessage({ type: "alwaysAllowWriteProtected", bool: alwaysAllowWriteProtected })
|
||||
}
|
||||
if (hasChanged(alwaysAllowExecute, extensionState.alwaysAllowExecute)) {
|
||||
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
|
||||
}
|
||||
if (hasChanged(alwaysAllowBrowser, extensionState.alwaysAllowBrowser)) {
|
||||
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
|
||||
}
|
||||
if (hasChanged(alwaysAllowMcp, extensionState.alwaysAllowMcp)) {
|
||||
vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
|
||||
}
|
||||
if (hasChanged(allowedCommands, extensionState.allowedCommands)) {
|
||||
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
|
||||
}
|
||||
if (hasChanged(deniedCommands, extensionState.deniedCommands)) {
|
||||
vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] })
|
||||
}
|
||||
if (hasChanged(allowedMaxRequests, extensionState.allowedMaxRequests)) {
|
||||
vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
|
||||
}
|
||||
if (hasChanged(allowedMaxCost, extensionState.allowedMaxCost)) {
|
||||
vscode.postMessage({ type: "allowedMaxCost", value: allowedMaxCost ?? undefined })
|
||||
}
|
||||
|
||||
// CRITICAL: Only send condense-related settings if they actually changed
|
||||
// This prevents unnecessary context condensing triggers during active tasks
|
||||
if (hasChanged(autoCondenseContext, extensionState.autoCondenseContext)) {
|
||||
vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
|
||||
}
|
||||
if (hasChanged(autoCondenseContextPercent, extensionState.autoCondenseContextPercent)) {
|
||||
vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
|
||||
}
|
||||
if (hasChanged(profileThresholds, extensionState.profileThresholds)) {
|
||||
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
|
||||
}
|
||||
|
||||
if (hasChanged(browserToolEnabled, extensionState.browserToolEnabled)) {
|
||||
vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
|
||||
}
|
||||
if (hasChanged(soundEnabled, extensionState.soundEnabled)) {
|
||||
vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
|
||||
}
|
||||
if (hasChanged(ttsEnabled, extensionState.ttsEnabled)) {
|
||||
vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled })
|
||||
}
|
||||
if (hasChanged(ttsSpeed, extensionState.ttsSpeed)) {
|
||||
vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed })
|
||||
}
|
||||
if (hasChanged(soundVolume, extensionState.soundVolume)) {
|
||||
vscode.postMessage({ type: "soundVolume", value: soundVolume })
|
||||
}
|
||||
if (hasChanged(diffEnabled, extensionState.diffEnabled)) {
|
||||
vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
|
||||
}
|
||||
if (hasChanged(enableCheckpoints, extensionState.enableCheckpoints)) {
|
||||
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
|
||||
}
|
||||
if (hasChanged(browserViewportSize, extensionState.browserViewportSize)) {
|
||||
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
|
||||
}
|
||||
if (hasChanged(remoteBrowserHost, extensionState.remoteBrowserHost)) {
|
||||
vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
|
||||
}
|
||||
if (hasChanged(remoteBrowserEnabled, extensionState.remoteBrowserEnabled)) {
|
||||
vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
|
||||
}
|
||||
if (hasChanged(fuzzyMatchThreshold, extensionState.fuzzyMatchThreshold)) {
|
||||
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
|
||||
}
|
||||
if (hasChanged(writeDelayMs, extensionState.writeDelayMs)) {
|
||||
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
|
||||
}
|
||||
if (hasChanged(screenshotQuality, extensionState.screenshotQuality)) {
|
||||
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
|
||||
}
|
||||
if (hasChanged(terminalOutputLineLimit, extensionState.terminalOutputLineLimit)) {
|
||||
vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
|
||||
}
|
||||
if (hasChanged(terminalOutputCharacterLimit, extensionState.terminalOutputCharacterLimit)) {
|
||||
vscode.postMessage({
|
||||
type: "terminalOutputCharacterLimit",
|
||||
value: terminalOutputCharacterLimit ?? 50000,
|
||||
})
|
||||
}
|
||||
if (hasChanged(terminalShellIntegrationTimeout, extensionState.terminalShellIntegrationTimeout)) {
|
||||
vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout })
|
||||
}
|
||||
if (hasChanged(terminalShellIntegrationDisabled, extensionState.terminalShellIntegrationDisabled)) {
|
||||
vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled })
|
||||
}
|
||||
if (hasChanged(terminalCommandDelay, extensionState.terminalCommandDelay)) {
|
||||
vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay })
|
||||
}
|
||||
if (hasChanged(terminalPowershellCounter, extensionState.terminalPowershellCounter)) {
|
||||
vscode.postMessage({ type: "terminalPowershellCounter", bool: terminalPowershellCounter })
|
||||
}
|
||||
if (hasChanged(terminalZshClearEolMark, extensionState.terminalZshClearEolMark)) {
|
||||
vscode.postMessage({ type: "terminalZshClearEolMark", bool: terminalZshClearEolMark })
|
||||
}
|
||||
if (hasChanged(terminalZshOhMy, extensionState.terminalZshOhMy)) {
|
||||
vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy })
|
||||
}
|
||||
if (hasChanged(terminalZshP10k, extensionState.terminalZshP10k)) {
|
||||
vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k })
|
||||
}
|
||||
if (hasChanged(terminalZdotdir, extensionState.terminalZdotdir)) {
|
||||
vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir })
|
||||
}
|
||||
if (hasChanged(terminalCompressProgressBar, extensionState.terminalCompressProgressBar)) {
|
||||
vscode.postMessage({ type: "terminalCompressProgressBar", bool: terminalCompressProgressBar })
|
||||
}
|
||||
if (hasChanged(mcpEnabled, extensionState.mcpEnabled)) {
|
||||
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
|
||||
}
|
||||
if (hasChanged(alwaysApproveResubmit, extensionState.alwaysApproveResubmit)) {
|
||||
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
|
||||
}
|
||||
if (hasChanged(requestDelaySeconds, extensionState.requestDelaySeconds)) {
|
||||
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
|
||||
}
|
||||
if (hasChanged(maxOpenTabsContext, extensionState.maxOpenTabsContext)) {
|
||||
vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
|
||||
}
|
||||
if (hasChanged(maxWorkspaceFiles, extensionState.maxWorkspaceFiles)) {
|
||||
vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 })
|
||||
}
|
||||
if (hasChanged(showRooIgnoredFiles, extensionState.showRooIgnoredFiles)) {
|
||||
vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles })
|
||||
}
|
||||
if (hasChanged(maxReadFileLine, extensionState.maxReadFileLine)) {
|
||||
vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 })
|
||||
}
|
||||
if (hasChanged(maxImageFileSize, extensionState.maxImageFileSize)) {
|
||||
vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 })
|
||||
}
|
||||
if (hasChanged(maxTotalImageSize, extensionState.maxTotalImageSize)) {
|
||||
vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 })
|
||||
}
|
||||
if (hasChanged(maxConcurrentFileReads, extensionState.maxConcurrentFileReads)) {
|
||||
vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 })
|
||||
}
|
||||
if (hasChanged(includeDiagnosticMessages, extensionState.includeDiagnosticMessages)) {
|
||||
vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages })
|
||||
}
|
||||
if (hasChanged(maxDiagnosticMessages, extensionState.maxDiagnosticMessages)) {
|
||||
vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 })
|
||||
}
|
||||
if (hasChanged(currentApiConfigName, extensionState.currentApiConfigName)) {
|
||||
vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
|
||||
}
|
||||
if (hasChanged(experiments, extensionState.experiments)) {
|
||||
vscode.postMessage({ type: "updateExperimental", values: experiments })
|
||||
}
|
||||
if (hasChanged(alwaysAllowModeSwitch, extensionState.alwaysAllowModeSwitch)) {
|
||||
vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
|
||||
}
|
||||
if (hasChanged(alwaysAllowSubtasks, extensionState.alwaysAllowSubtasks)) {
|
||||
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
|
||||
}
|
||||
if (hasChanged(alwaysAllowFollowupQuestions, extensionState.alwaysAllowFollowupQuestions)) {
|
||||
vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions })
|
||||
}
|
||||
if (hasChanged(alwaysAllowUpdateTodoList, extensionState.alwaysAllowUpdateTodoList)) {
|
||||
vscode.postMessage({ type: "alwaysAllowUpdateTodoList", bool: alwaysAllowUpdateTodoList })
|
||||
}
|
||||
if (hasChanged(followupAutoApproveTimeoutMs, extensionState.followupAutoApproveTimeoutMs)) {
|
||||
vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs })
|
||||
}
|
||||
if (hasChanged(condensingApiConfigId, extensionState.condensingApiConfigId)) {
|
||||
vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" })
|
||||
}
|
||||
if (hasChanged(customCondensingPrompt, extensionState.customCondensingPrompt)) {
|
||||
vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" })
|
||||
}
|
||||
if (hasChanged(customSupportPrompts, extensionState.customSupportPrompts)) {
|
||||
vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
|
||||
}
|
||||
if (hasChanged(includeTaskHistoryInEnhance, extensionState.includeTaskHistoryInEnhance)) {
|
||||
vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true })
|
||||
}
|
||||
if (hasChanged(apiConfiguration, extensionState.apiConfiguration)) {
|
||||
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
|
||||
}
|
||||
if (hasChanged(telemetrySetting, extensionState.telemetrySetting)) {
|
||||
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
|
||||
}
|
||||
if (hasChanged(openRouterImageApiKey, extensionState.openRouterImageApiKey)) {
|
||||
vscode.postMessage({ type: "openRouterImageApiKey", text: openRouterImageApiKey })
|
||||
}
|
||||
if (
|
||||
hasChanged(
|
||||
openRouterImageGenerationSelectedModel,
|
||||
extensionState.openRouterImageGenerationSelectedModel,
|
||||
)
|
||||
) {
|
||||
vscode.postMessage({
|
||||
type: "openRouterImageGenerationSelectedModel",
|
||||
text: openRouterImageGenerationSelectedModel,
|
||||
})
|
||||
}
|
||||
setChangeDetected(false)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,17 +18,18 @@ vi.mock("../ApiConfigManager", () => ({
|
|||
}))
|
||||
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) =>
|
||||
VSCodeButton: ({ children, onClick, appearance, disabled, "data-testid": dataTestId }: any) =>
|
||||
appearance === "icon" ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="codicon codicon-close"
|
||||
aria-label="Remove command"
|
||||
data-testid={dataTestId}>
|
||||
data-testid={dataTestId}
|
||||
disabled={disabled}>
|
||||
<span className="codicon codicon-close" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId}>
|
||||
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -135,8 +136,13 @@ vi.mock("@/components/ui", () => ({
|
|||
data-testid={dataTestId}
|
||||
/>
|
||||
),
|
||||
Button: ({ children, onClick, variant, className, "data-testid": dataTestId }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} className={className} data-testid={dataTestId}>
|
||||
Button: ({ children, onClick, variant, className, disabled, "data-testid": dataTestId }: any) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-variant={variant}
|
||||
className={className}
|
||||
data-testid={dataTestId}
|
||||
disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
|
|
@ -637,3 +643,166 @@ describe("SettingsView - Duplicate Commands", () => {
|
|||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SettingsView - Dirty State Management", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("disables Save button when toggling settings back to original values", () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the notifications tab
|
||||
activateTab("notifications")
|
||||
|
||||
// Initially, Save button should be disabled (no changes)
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
|
||||
// Enable sound (make a change)
|
||||
const soundCheckbox = screen.getByTestId("sound-enabled-checkbox")
|
||||
fireEvent.click(soundCheckbox)
|
||||
|
||||
// Save button should now be enabled
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Toggle sound back off (revert to original)
|
||||
fireEvent.click(soundCheckbox)
|
||||
|
||||
// Save button should be disabled again (no net changes)
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("keeps Save button enabled when there are actual changes from original state", () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the notifications tab
|
||||
activateTab("notifications")
|
||||
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
const soundCheckbox = screen.getByTestId("sound-enabled-checkbox")
|
||||
const ttsCheckbox = screen.getByTestId("tts-enabled-checkbox")
|
||||
|
||||
// Make multiple changes
|
||||
fireEvent.click(soundCheckbox) // Enable sound
|
||||
fireEvent.click(ttsCheckbox) // Enable TTS
|
||||
|
||||
// Save button should be enabled
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Toggle one back but keep the other changed
|
||||
fireEvent.click(soundCheckbox) // Disable sound (back to original)
|
||||
|
||||
// Save button should still be enabled (TTS is still changed)
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Toggle the other back too
|
||||
fireEvent.click(ttsCheckbox) // Disable TTS (back to original)
|
||||
|
||||
// Now Save button should be disabled (everything back to original)
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("correctly tracks dirty state for context management settings", () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the context tab
|
||||
activateTab("context")
|
||||
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
|
||||
// Initially disabled
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
|
||||
// Enable auto-condense
|
||||
const autoCondenseCheckbox = screen.getByTestId("auto-condense-checkbox")
|
||||
fireEvent.click(autoCondenseCheckbox)
|
||||
|
||||
// Save button should be enabled
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Toggle back
|
||||
fireEvent.click(autoCondenseCheckbox)
|
||||
|
||||
// Save button should be disabled again
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
})
|
||||
|
||||
it("does not send settings to backend when Save is clicked with no changes", () => {
|
||||
// Clear any previous mock calls
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the notifications tab
|
||||
activateTab("notifications")
|
||||
|
||||
// Toggle a setting on and off
|
||||
const soundCheckbox = screen.getByTestId("sound-enabled-checkbox")
|
||||
fireEvent.click(soundCheckbox) // Enable
|
||||
fireEvent.click(soundCheckbox) // Disable (back to original)
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
// Try to click Save (should be disabled and not trigger any action)
|
||||
fireEvent.click(saveButton)
|
||||
|
||||
// Check that no settings-related messages were sent
|
||||
// (There may be initial setup messages like requestRouterModels)
|
||||
const settingsMessages = (vscode.postMessage as any).mock.calls.filter((call: any) => {
|
||||
const type = call[0]?.type
|
||||
return (
|
||||
type === "soundEnabled" ||
|
||||
type === "ttsEnabled" ||
|
||||
type === "autoCondenseContext" ||
|
||||
type === "autoCondenseContextPercent"
|
||||
)
|
||||
})
|
||||
expect(settingsMessages).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("correctly handles complex state changes across multiple settings", () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Start with autoApprove tab
|
||||
activateTab("autoApprove")
|
||||
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Save button should be enabled
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Add a command
|
||||
const input = screen.getByTestId("command-input")
|
||||
fireEvent.change(input, { target: { value: "npm test" } })
|
||||
const addButton = screen.getByTestId("add-command-button")
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Still enabled (more changes)
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Remove the command
|
||||
const removeButton = screen.getByTestId("remove-command-0")
|
||||
fireEvent.click(removeButton)
|
||||
|
||||
// Still enabled (execute checkbox is still changed)
|
||||
expect(saveButton).not.toHaveAttribute("disabled")
|
||||
|
||||
// Toggle execute checkbox back
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Now should be disabled (everything back to original)
|
||||
expect(saveButton).toHaveAttribute("disabled")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue