Compare commits

...
Author SHA1 Message Date
Cline Evaluation 87c43732a4 Fix settings on switch 2025-06-25 17:54:50 -06:00
Cline Evaluation 61bf6dd98f Fix settings on switch 2025-06-25 17:54:49 -06:00
Cline Evaluation 7b706308f1 markdown fix 2025-06-25 17:54:00 -06:00
Cline Evaluation b1681ebeb4 markdown fix 2025-06-25 17:54:00 -06:00
Cline Evaluation f86ec8a2e8 type fix 2025-06-25 17:54:00 -06:00
5 changed files with 259 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix race condition in model switching
@@ -35,7 +35,7 @@ export function AlertDialogContent({ className, children, ...props }: React.HTML
className={`fixed top-[50%] left-[50%] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] ${className}`}
onClick={(e) => e.stopPropagation()}
{...props}>
<div className="bg-[var(--vscode-editor-background)] rounded-sm gap-3 border border-[var(--vscode-panel-border)] p-4 shadow-lg sm:max-w-md">
<div className="bg-[var(--vscode-editor-background)] rounded-sm gap-3 border border-[var(--vscode-panel-border)] p-6 shadow-lg sm:max-w-lg">
{children}
</div>
</div>
@@ -47,7 +47,7 @@ export function AlertDialogHeader({ className, ...props }: React.HTMLAttributes<
}
export function AlertDialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={`flex flex-row justify-end gap-2 mt-4 ${className}`} {...props} />
return <div className={`flex flex-row justify-end gap-3 mt-6 ${className}`} {...props} />
}
export function AlertDialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
@@ -76,11 +76,23 @@ export function UnsavedChangesDialog({
onOpenChange,
onConfirm,
onCancel,
onSave,
title = "Unsaved Changes",
description = "You have unsaved changes. Are you sure you want to discard them?",
confirmText = "Discard Changes",
saveText = "Save & Continue",
showSaveOption = false,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
onCancel: () => void
onSave?: () => void
title?: string
description?: string
confirmText?: string
saveText?: string
showSaveOption?: boolean
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
@@ -88,15 +100,16 @@ export function UnsavedChangesDialog({
<AlertDialogHeader>
<AlertDialogTitle>
<AlertTriangle className="w-5 h-5 text-[var(--vscode-errorForeground)]" />
Unsaved Changes
{title}
</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to discard them?
</AlertDialogDescription>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Discard Changes</AlertDialogAction>
{showSaveOption && onSave && <AlertDialogAction onClick={onSave}>{saveText}</AlertDialogAction>}
<AlertDialogAction onClick={onConfirm} appearance={showSaveOption ? "secondary" : "primary"}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
@@ -15,25 +15,36 @@ import { StateServiceClient } from "@/services/grpc-client"
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/state"
// Styled component for Act Mode text with more specific styling
const ActModeHighlight: React.FC = () => (
<span
onClick={() => {
StateServiceClient.togglePlanActMode(
TogglePlanActModeRequest.create({
chatSettings: {
mode: PlanActMode.ACT,
},
}),
)
}}
title="Click to toggle to Act Mode"
className="text-[var(--vscode-textLink-foreground)] hover:opacity-90 cursor-pointer inline-flex items-center gap-1">
<div className="p-1 rounded-[12px] bg-[var(--vscode-editor-background)] flex items-center justify-end w-4 border-[1px] border-[var(--vscode-input-border)]">
<div className="rounded-full bg-[var(--vscode-textLink-foreground)] w-2 h-2" />
</div>
Act Mode (A)
</span>
)
const ActModeHighlight: React.FC = () => {
const { chatSettings } = useExtensionState()
return (
<span
onClick={() => {
// Only toggle to Act mode if we're currently in Plan mode
if (chatSettings.mode === "plan") {
StateServiceClient.togglePlanActMode(
TogglePlanActModeRequest.create({
chatSettings: {
mode: PlanActMode.ACT,
preferredLanguage: chatSettings.preferredLanguage,
openAiReasoningEffort: chatSettings.openAIReasoningEffort,
},
}),
)
}
}}
title={chatSettings.mode === "plan" ? "Click to toggle to Act Mode" : "Already in Act Mode"}
className={`text-[var(--vscode-textLink-foreground)] inline-flex items-center gap-1 ${
chatSettings.mode === "plan" ? "hover:opacity-90 cursor-pointer" : "cursor-default opacity-60"
}`}>
<div className="p-1 rounded-[12px] bg-[var(--vscode-editor-background)] flex items-center justify-end w-4 border-[1px] border-[var(--vscode-input-border)]">
<div className="rounded-full bg-[var(--vscode-textLink-foreground)] w-2 h-2" />
</div>
Act Mode (A)
</span>
)
}
interface MarkdownBlockProps {
markdown?: string
@@ -113,19 +113,21 @@ const McpConfigurationView = ({ onDone, initialTab }: McpViewProps) => {
)
}
const StyledTabButton = styled.button<{ isActive: boolean }>`
const StyledTabButton = styled.button<{ isActive: boolean; disabled?: boolean }>`
background: none;
border: none;
border-bottom: 2px solid ${(props) => (props.isActive ? "var(--vscode-foreground)" : "transparent")};
color: ${(props) => (props.isActive ? "var(--vscode-foreground)" : "var(--vscode-descriptionForeground)")};
padding: 8px 16px;
cursor: pointer;
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
font-size: 13px;
margin-bottom: -1px;
font-family: inherit;
opacity: ${(props) => (props.disabled ? 0.6 : 1)};
pointer-events: ${(props) => (props.disabled ? "none" : "auto")};
&:hover {
color: var(--vscode-foreground);
color: ${(props) => (props.disabled ? "var(--vscode-descriptionForeground)" : "var(--vscode-foreground)")};
}
`
@@ -133,12 +135,16 @@ export const TabButton = ({
children,
isActive,
onClick,
disabled,
style,
}: {
children: React.ReactNode
isActive: boolean
onClick: () => void
disabled?: boolean
style?: React.CSSProperties
}) => (
<StyledTabButton isActive={isActive} onClick={onClick}>
<StyledTabButton isActive={isActive} onClick={onClick} disabled={disabled} style={style}>
{children}
</StyledTabButton>
)
@@ -112,6 +112,10 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
const [isUnsavedChangesDialogOpen, setIsUnsavedChangesDialogOpen] = useState(false)
// Store the action to perform after confirmation
const pendingAction = useRef<() => void>()
// Track if we're currently switching modes
const [isSwitchingMode, setIsSwitchingMode] = useState(false)
// Track pending mode switch when there are unsaved changes
const [pendingModeSwitch, setPendingModeSwitch] = useState<"plan" | "act" | null>(null)
const {
apiConfiguration,
version,
@@ -260,8 +264,32 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
setModelIdErrorMessage(undefined)
}, [apiConfiguration])
// Track the previous mode to detect mode switches
const previousMode = useRef(chatSettings.mode)
// Update original state when mode changes
useEffect(() => {
// Detect if the mode has changed
if (previousMode.current !== chatSettings.mode) {
// Mode has changed, update the original state immediately to reflect the new apiConfiguration and chatSettings
originalState.current = {
...originalState.current,
apiConfiguration: apiConfiguration,
chatSettings: chatSettings,
}
// Update the previous mode reference
previousMode.current = chatSettings.mode
}
}, [chatSettings.mode, apiConfiguration, chatSettings])
// Check for unsaved changes by comparing current state with original state
useEffect(() => {
// Don't check for changes while switching modes
if (isSwitchingMode) {
return
}
const hasChanges =
JSON.stringify(apiConfiguration) !== JSON.stringify(originalState.current.apiConfiguration) ||
telemetrySetting !== originalState.current.telemetrySetting ||
@@ -271,7 +299,6 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
mcpRichDisplayEnabled !== originalState.current.mcpRichDisplayEnabled ||
JSON.stringify(chatSettings) !== JSON.stringify(originalState.current.chatSettings) ||
mcpResponsesCollapsed !== originalState.current.mcpResponsesCollapsed ||
JSON.stringify(chatSettings) !== JSON.stringify(originalState.current.chatSettings) ||
shellIntegrationTimeout !== originalState.current.shellIntegrationTimeout ||
terminalOutputLineLimit !== originalState.current.terminalOutputLineLimit ||
terminalReuseEnabled !== originalState.current.terminalReuseEnabled ||
@@ -292,7 +319,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
terminalReuseEnabled,
terminalOutputLineLimit,
defaultTerminalProfile,
localBrowserSettings,
isSwitchingMode,
])
// Handle cancel button click
@@ -368,17 +395,134 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
])
// Handle confirmation dialog actions
const handleConfirmDiscard = useCallback(() => {
const handleConfirmDiscard = useCallback(async () => {
setIsUnsavedChangesDialogOpen(false)
if (pendingAction.current) {
// Check if this is for a mode switch
if (pendingModeSwitch) {
// Reset all state to original values (discard changes)
setTelemetrySetting(originalState.current.telemetrySetting)
setPlanActSeparateModelsSetting(originalState.current.planActSeparateModelsSetting)
setChatSettings(originalState.current.chatSettings)
if (typeof setApiConfiguration === "function") {
setApiConfiguration(originalState.current.apiConfiguration ?? {})
}
if (typeof setEnableCheckpointsSetting === "function") {
setEnableCheckpointsSetting(
typeof originalState.current.enableCheckpointsSetting === "boolean"
? originalState.current.enableCheckpointsSetting
: false,
)
}
if (typeof setMcpMarketplaceEnabled === "function") {
setMcpMarketplaceEnabled(
typeof originalState.current.mcpMarketplaceEnabled === "boolean"
? originalState.current.mcpMarketplaceEnabled
: false,
)
}
if (typeof setMcpRichDisplayEnabled === "function") {
setMcpRichDisplayEnabled(
typeof originalState.current.mcpRichDisplayEnabled === "boolean"
? originalState.current.mcpRichDisplayEnabled
: true,
)
}
// Reset terminal settings
if (typeof setShellIntegrationTimeout === "function") {
setShellIntegrationTimeout(originalState.current.shellIntegrationTimeout)
}
if (typeof setTerminalOutputLineLimit === "function") {
setTerminalOutputLineLimit(originalState.current.terminalOutputLineLimit)
}
if (typeof setTerminalReuseEnabled === "function") {
setTerminalReuseEnabled(originalState.current.terminalReuseEnabled ?? true)
}
if (typeof setDefaultTerminalProfile === "function") {
setDefaultTerminalProfile(originalState.current.defaultTerminalProfile ?? "default")
}
if (typeof setMcpResponsesCollapsed === "function") {
setMcpResponsesCollapsed(originalState.current.mcpResponsesCollapsed ?? false)
}
// Now perform the mode switch
const targetMode = pendingModeSwitch
setPendingModeSwitch(null)
setIsSwitchingMode(true)
try {
await StateServiceClient.togglePlanActMode(
TogglePlanActModeRequest.create({
chatSettings: {
mode: targetMode === "plan" ? PlanActMode.PLAN : PlanActMode.ACT,
preferredLanguage: chatSettings.preferredLanguage,
openAiReasoningEffort: chatSettings.openAIReasoningEffort,
},
}),
)
} catch (error) {
console.error("Failed to toggle Plan/Act mode:", error)
} finally {
setIsSwitchingMode(false)
}
} else if (pendingAction.current) {
// Regular cancel button flow
pendingAction.current()
pendingAction.current = undefined
}
}, [])
}, [
pendingModeSwitch,
setTelemetrySetting,
setPlanActSeparateModelsSetting,
setChatSettings,
setApiConfiguration,
setEnableCheckpointsSetting,
setMcpMarketplaceEnabled,
setMcpRichDisplayEnabled,
setShellIntegrationTimeout,
setTerminalOutputLineLimit,
setTerminalReuseEnabled,
setDefaultTerminalProfile,
setMcpResponsesCollapsed,
chatSettings.preferredLanguage,
chatSettings.openAIReasoningEffort,
])
// Handle save and switch for mode changes
const handleSaveAndSwitch = useCallback(async () => {
setIsUnsavedChangesDialogOpen(false)
if (pendingModeSwitch) {
// Save the current settings first
await handleSubmit(true)
// Now perform the mode switch
const targetMode = pendingModeSwitch
setPendingModeSwitch(null)
setIsSwitchingMode(true)
try {
await StateServiceClient.togglePlanActMode(
TogglePlanActModeRequest.create({
chatSettings: {
mode: targetMode === "plan" ? PlanActMode.PLAN : PlanActMode.ACT,
preferredLanguage: chatSettings.preferredLanguage,
openAiReasoningEffort: chatSettings.openAIReasoningEffort,
},
}),
)
} catch (error) {
console.error("Failed to toggle Plan/Act mode:", error)
} finally {
setIsSwitchingMode(false)
}
}
}, [pendingModeSwitch, handleSubmit, chatSettings.preferredLanguage, chatSettings.openAIReasoningEffort])
const handleCancelDiscard = useCallback(() => {
setIsUnsavedChangesDialogOpen(false)
pendingAction.current = undefined
setPendingModeSwitch(null)
}, [])
// validate as soon as the component is mounted
@@ -446,14 +590,25 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
}
const handlePlanActModeChange = async (tab: "plan" | "act") => {
if (tab === chatSettings.mode) {
// Prevent switching if already in that mode or if currently switching
if (tab === chatSettings.mode || isSwitchingMode) {
return
}
// Update settings first to ensure any changes to the current tab are saved
await handleSubmit(true)
// Check if there are unsaved changes
if (hasUnsavedChanges) {
// Store the pending mode switch
setPendingModeSwitch(tab)
// Show the unsaved changes dialog
setIsUnsavedChangesDialogOpen(true)
return
}
// No unsaved changes, proceed with the switch
setIsSwitchingMode(true)
try {
// Perform the mode switch
await StateServiceClient.togglePlanActMode(
TogglePlanActModeRequest.create({
chatSettings: {
@@ -465,6 +620,9 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
)
} catch (error) {
console.error("Failed to toggle Plan/Act mode:", error)
} finally {
// Always re-enable mode switching, even on error
setIsSwitchingMode(false)
}
}
@@ -612,13 +770,27 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
<div className="flex gap-[1px] mb-[10px] -mt-2 border-0 border-b border-solid border-[var(--vscode-panel-border)]">
<TabButton
isActive={chatSettings.mode === "plan"}
onClick={() => handlePlanActModeChange("plan")}>
Plan Mode
onClick={() => handlePlanActModeChange("plan")}
disabled={isSwitchingMode}
style={{
opacity: isSwitchingMode ? 0.6 : 1,
cursor: isSwitchingMode ? "not-allowed" : "pointer",
}}>
{isSwitchingMode && chatSettings.mode === "act"
? "Switching..."
: "Plan Mode"}
</TabButton>
<TabButton
isActive={chatSettings.mode === "act"}
onClick={() => handlePlanActModeChange("act")}>
Act Mode
onClick={() => handlePlanActModeChange("act")}
disabled={isSwitchingMode}
style={{
opacity: isSwitchingMode ? 0.6 : 1,
cursor: isSwitchingMode ? "not-allowed" : "pointer",
}}>
{isSwitchingMode && chatSettings.mode === "plan"
? "Switching..."
: "Act Mode"}
</TabButton>
</div>
@@ -787,6 +959,16 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
onOpenChange={setIsUnsavedChangesDialogOpen}
onConfirm={handleConfirmDiscard}
onCancel={handleCancelDiscard}
onSave={pendingModeSwitch ? handleSaveAndSwitch : undefined}
title={pendingModeSwitch ? "Save Changes?" : "Unsaved Changes"}
description={
pendingModeSwitch
? `Do you want to save your changes to ${chatSettings.mode === "plan" ? "Plan" : "Act"} mode before switching to ${pendingModeSwitch === "plan" ? "Plan" : "Act"} mode?`
: "You have unsaved changes. Are you sure you want to discard them?"
}
confirmText={pendingModeSwitch ? "Switch Without Saving" : "Discard Changes"}
saveText="Save & Switch"
showSaveOption={!!pendingModeSwitch}
/>
</Tab>
)