Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 33 additions & 22 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,36 +781,44 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements

await this.updateGlobalState("mode", newMode)

// Load the saved API config for the new mode if it exists
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const listApiConfig = await this.providerSettingsManager.listConfig()
const stickyModesEnabled = this.getGlobalState("stickyModesEnabled") ?? true

// Update listApiConfigMeta first to ensure UI has latest data
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
if (stickyModesEnabled) {
// Load the saved API config for the new mode if it exists
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
const listApiConfig = await this.providerSettingsManager.listConfig()

// If this mode has a saved config, use it
if (savedConfigId) {
const config = listApiConfig?.find((c) => c.id === savedConfigId)
// Update listApiConfigMeta first to ensure UI has latest data
await this.updateGlobalState("listApiConfigMeta", listApiConfig)

if (config?.name) {
const apiConfig = await this.providerSettingsManager.loadConfig(config.name)
// If this mode has a saved config, use it
if (savedConfigId) {
const config = listApiConfig?.find((c) => c.id === savedConfigId)

await Promise.all([
this.updateGlobalState("currentApiConfigName", config.name),
this.updateApiConfiguration(apiConfig),
])
}
} else {
// If no saved config for this mode, save current config as default
const currentApiConfigName = this.getGlobalState("currentApiConfigName")
if (config?.name) {
const apiConfig = await this.providerSettingsManager.loadConfig(config.name)

await Promise.all([
this.updateGlobalState("currentApiConfigName", config.name),
this.updateApiConfiguration(apiConfig),
])
}
} else {
// If no saved config for this mode, save current config as default
const currentApiConfigName = this.getGlobalState("currentApiConfigName")

if (currentApiConfigName) {
const config = listApiConfig?.find((c) => c.name === currentApiConfigName)
if (currentApiConfigName) {
const config = listApiConfig?.find((c) => c.name === currentApiConfigName)

if (config?.id) {
await this.providerSettingsManager.setModeConfig(newMode, config.id)
if (config?.id) {
await this.providerSettingsManager.setModeConfig(newMode, config.id)
}
}
}
} else {
// If sticky modes are disabled, ensure we don't accidentally load a stale config
const listApiConfig = await this.providerSettingsManager.listConfig()
await this.updateGlobalState("listApiConfigMeta", listApiConfig)
}

await this.postStateToWebview()
Expand Down Expand Up @@ -1226,6 +1234,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
showRooIgnoredFiles,
language,
maxReadFileLine,
stickyModesEnabled,
} = await this.getState()

const telemetryKey = process.env.POSTHOG_API_KEY
Expand Down Expand Up @@ -1310,6 +1319,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
maxReadFileLine: maxReadFileLine ?? 500,
settingsImportedAt: this.settingsImportedAt,
hasSystemPromptOverride,
stickyModesEnabled: stickyModesEnabled ?? true,
}
}

Expand Down Expand Up @@ -1397,6 +1407,7 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
telemetrySetting: stateValues.telemetrySetting || "unset",
showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? true,
maxReadFileLine: stateValues.maxReadFileLine ?? 500,
stickyModesEnabled: stateValues.stickyModesEnabled ?? true,
}
}

Expand Down
70 changes: 70 additions & 0 deletions src/core/webview/__tests__/ClineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@ describe("ClineProvider", () => {
showRooIgnoredFiles: true,
renderContext: "sidebar",
maxReadFileLine: 500,
stickyModesEnabled: true,
}

const message: ExtensionMessage = {
Expand Down Expand Up @@ -538,6 +539,36 @@ describe("ClineProvider", () => {
expect(mockPostMessage).toHaveBeenCalled()
})

test("stickyModesEnabled defaults to true when not set", async () => {
// Mock globalState.get to return undefined for stickyModesEnabled
;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
if (key === "stickyModesEnabled") {
return undefined
}
return null
})

const state = await provider.getState()
expect(state.stickyModesEnabled).toBe(true)
})

test("handles stickyModesEnabled message", async () => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]

// Test setting to false
await messageHandler({ type: "stickyModesEnabled", bool: false })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("stickyModesEnabled", false)
expect(mockContext.globalState.update).toHaveBeenCalledWith("stickyModesEnabled", false)
expect(mockPostMessage).toHaveBeenCalled()

// Test setting to true
await messageHandler({ type: "stickyModesEnabled", bool: true })
expect(updateGlobalStateSpy).toHaveBeenCalledWith("stickyModesEnabled", true)
expect(mockContext.globalState.update).toHaveBeenCalledWith("stickyModesEnabled", true)
expect(mockPostMessage).toHaveBeenCalled()
})

test("updates sound utility when sound setting changes", async () => {
await provider.resolveWebviewView(mockWebviewView)

Expand Down Expand Up @@ -1603,6 +1634,45 @@ describe("ClineProvider", () => {
// Verify state was posted to webview
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" }))
})

test("does NOT load/save config when stickyModesEnabled is false", async () => {
// Mock globalState to return stickyModesEnabled: false
mockContext.globalState.get = jest.fn((key: string) => {
if (key === "stickyModesEnabled") return false
if (key === "mode") return "code" // Start in some mode
return undefined
})

// Re-initialize provider with updated mock context
provider = new ClineProvider(mockContext, mockOutputChannel)
await provider.resolveWebviewView(mockWebviewView)

const mockProviderSettingsManager = {
getModeConfigId: jest.fn(),
listConfig: jest.fn().mockResolvedValue([]), // Still need to list configs
loadConfig: jest.fn(),
setModeConfig: jest.fn(),
}
;(provider as any).providerSettingsManager = mockProviderSettingsManager

// Switch to architect mode
await provider.handleModeSwitch("architect")

// Verify mode was updated
expect(mockContext.globalState.update).toHaveBeenCalledWith("mode", "architect")

// Verify config loading/saving methods were NOT called
expect(mockProviderSettingsManager.getModeConfigId).not.toHaveBeenCalled()
expect(mockProviderSettingsManager.loadConfig).not.toHaveBeenCalled()
expect(mockProviderSettingsManager.setModeConfig).not.toHaveBeenCalled()

// Verify listConfig and updateGlobalState("listApiConfigMeta", ...) were still called
expect(mockProviderSettingsManager.listConfig).toHaveBeenCalled()
expect(mockContext.globalState.update).toHaveBeenCalledWith("listApiConfigMeta", [])

// Verify state was posted to webview
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" }))
})
})

describe("updateCustomMode", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,10 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We
await updateGlobalState("showRooIgnoredFiles", message.bool ?? true)
await provider.postStateToWebview()
break
case "stickyModesEnabled":
await updateGlobalState("stickyModesEnabled", message.bool ?? true)
await provider.postStateToWebview()
break
case "maxReadFileLine":
await updateGlobalState("maxReadFileLine", message.value)
await provider.postStateToWebview()
Expand Down
1 change: 1 addition & 0 deletions src/exports/roo-code.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
stickyModesEnabled?: boolean | undefined
}

type ClineMessage = {
Expand Down
1 change: 1 addition & 0 deletions src/exports/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ type GlobalSettings = {
}
| undefined
enhancementApiConfigId?: string | undefined
stickyModesEnabled?: boolean | undefined
}

export type { GlobalSettings }
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export const globalSettingsSchema = z.object({
customModePrompts: customModePromptsSchema.optional(),
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
stickyModesEnabled: z.boolean().optional(),
})

export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Expand Down Expand Up @@ -641,6 +642,7 @@ const globalSettingsRecord: GlobalSettingsRecord = {
customSupportPrompts: undefined,
enhancementApiConfigId: undefined,
cachedChromeHostUrl: undefined,
stickyModesEnabled: undefined,
}

export const GLOBAL_SETTINGS_KEYS = Object.keys(globalSettingsRecord) as Keys<GlobalSettings>[]
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ export type ExtensionState = Pick<
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "stickyModesEnabled"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export interface WebviewMessage {
| "maxReadFileLine"
| "searchFiles"
| "toggleApiConfigPin"
| "stickyModesEnabled"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
Expand Down
47 changes: 47 additions & 0 deletions webview-ui/src/components/settings/MiscellaneousSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Settings } from "lucide-react"

import { SetCachedStateField } from "./types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"

type MiscellaneousSettingsProps = HTMLAttributes<HTMLDivElement> & {
stickyModesEnabled?: boolean
setCachedStateField: SetCachedStateField<"stickyModesEnabled">
}

export const MiscellaneousSettings = ({
stickyModesEnabled,
setCachedStateField,
className,
...props
}: MiscellaneousSettingsProps) => {
const { t } = useAppTranslation()

return (
<div {...props}>
<SectionHeader description={t("settings:miscellaneous.description")}>
<div className="flex items-center gap-2">
<Settings className="w-4" />
<div>{t("settings:sections.miscellaneous")}</div>
</div>
</SectionHeader>

<Section>
<div>
<VSCodeCheckbox
checked={stickyModesEnabled}
onChange={(e: any) => setCachedStateField("stickyModesEnabled", e.target.checked)}
data-testid="sticky-modes-enabled-checkbox">
<span className="font-medium">{t("settings:miscellaneous.stickyModes.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:miscellaneous.stickyModes.description")}
</div>
</div>
</Section>
</div>
)
}
15 changes: 15 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Bell,
Database,
SquareTerminal,
Settings as SettingsIcon, // renamed to avoid conflict with component name
FlaskConical,
AlertTriangle,
Globe,
Expand Down Expand Up @@ -49,6 +50,7 @@ import { CheckpointSettings } from "./CheckpointSettings"
import { NotificationSettings } from "./NotificationSettings"
import { ContextManagementSettings } from "./ContextManagementSettings"
import { TerminalSettings } from "./TerminalSettings"
import { MiscellaneousSettings } from "./MiscellaneousSettings"
import { ExperimentalSettings } from "./ExperimentalSettings"
import { LanguageSettings } from "./LanguageSettings"
import { About } from "./About"
Expand All @@ -66,6 +68,7 @@ const sectionNames = [
"notifications",
"contextManagement",
"terminal",
"miscellaneous",
"experimental",
"language",
"about",
Expand Down Expand Up @@ -131,6 +134,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
terminalZshOhMy,
terminalZshP10k,
terminalZdotdir,
stickyModesEnabled,
writeDelayMs,
showRooIgnoredFiles,
remoteBrowserEnabled,
Expand Down Expand Up @@ -244,6 +248,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy })
vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k })
vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir })
vscode.postMessage({ type: "stickyModesEnabled", bool: stickyModesEnabled })
vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
Expand Down Expand Up @@ -288,6 +293,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
const notificationsRef = useRef<HTMLDivElement>(null)
const contextManagementRef = useRef<HTMLDivElement>(null)
const terminalRef = useRef<HTMLDivElement>(null)
const miscellaneousRef = useRef<HTMLDivElement>(null)
const experimentalRef = useRef<HTMLDivElement>(null)
const languageRef = useRef<HTMLDivElement>(null)
const aboutRef = useRef<HTMLDivElement>(null)
Expand All @@ -301,6 +307,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{ id: "notifications", icon: Bell, ref: notificationsRef },
{ id: "contextManagement", icon: Database, ref: contextManagementRef },
{ id: "terminal", icon: SquareTerminal, ref: terminalRef },
{ id: "miscellaneous", icon: SettingsIcon, ref: miscellaneousRef },
{ id: "experimental", icon: FlaskConical, ref: experimentalRef },
{ id: "language", icon: Globe, ref: languageRef },
{ id: "about", icon: Info, ref: aboutRef },
Expand All @@ -313,6 +320,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
notificationsRef,
contextManagementRef,
terminalRef,
miscellaneousRef,
experimentalRef,
],
)
Expand Down Expand Up @@ -494,6 +502,13 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
/>
</div>

<div ref={miscellaneousRef}>
<MiscellaneousSettings
stickyModesEnabled={stickyModesEnabled}
setCachedStateField={setCachedStateField}
/>
</div>

<div ref={experimentalRef}>
<ExperimentalSettings
setCachedStateField={setCachedStateField}
Expand Down
5 changes: 5 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export interface ExtensionStateContextType extends ExtensionState {
pinnedApiConfigs?: Record<string, boolean>
setPinnedApiConfigs: (value: Record<string, boolean>) => void
togglePinnedApiConfig: (configName: string) => void
stickyModesEnabled: boolean
setStickyModesEnabled: (value: boolean) => void
}

export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
Expand Down Expand Up @@ -163,6 +165,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalZshOhMy: false, // Default Oh My Zsh integration setting
terminalZshP10k: false, // Default Powerlevel10k integration setting
terminalZdotdir: false, // Default ZDOTDIR handling setting
stickyModesEnabled: true, // Default sticky modes to enabled
})

const [didHydrateState, setDidHydrateState] = useState(false)
Expand Down Expand Up @@ -331,6 +334,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode

return { ...prevState, pinnedApiConfigs: newPinned }
}),
stickyModesEnabled: state.stickyModesEnabled ?? true,
setStickyModesEnabled: (value) => setState((prevState) => ({ ...prevState, stickyModesEnabled: value })),
}

return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
Expand Down
Loading