Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const globalSettingsSchema = z.object({
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
lastModeImportPath: z.string().optional(),
modeUsageFrequency: z.record(z.string(), z.number()).optional(),
})

export type GlobalSettings = z.infer<typeof globalSettingsSchema>
Expand Down
8 changes: 8 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,11 @@ export class ClineProvider

await this.updateGlobalState("mode", newMode)

// Track mode usage frequency
const modeUsageFrequency = this.getGlobalState("modeUsageFrequency") || {}
modeUsageFrequency[newMode] = (modeUsageFrequency[newMode] || 0) + 1
await this.updateGlobalState("modeUsageFrequency", modeUsageFrequency)

// 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()
Expand Down Expand Up @@ -1440,6 +1445,7 @@ export class ClineProvider
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
diagnosticsEnabled,
modeUsageFrequency,
} = await this.getState()

const telemetryKey = process.env.POSTHOG_API_KEY
Expand Down Expand Up @@ -1561,6 +1567,7 @@ export class ClineProvider
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000,
diagnosticsEnabled: diagnosticsEnabled ?? true,
modeUsageFrequency: modeUsageFrequency ?? {},
}
}

Expand Down Expand Up @@ -1726,6 +1733,7 @@ export class ClineProvider
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
},
profileThresholds: stateValues.profileThresholds ?? {},
modeUsageFrequency: stateValues.modeUsageFrequency ?? {},
}
}

Expand Down
101 changes: 101 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,107 @@ describe("ClineProvider", () => {
// Verify state was posted to webview
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" }))
})

test("tracks mode usage frequency when switching modes", async () => {
// Mock the contextProxy's getValue to return modeUsageFrequency
let modeUsageFrequency: Record<string, number> | undefined = undefined

vi.spyOn(provider.contextProxy, "getValue").mockImplementation((key: string) => {
if (key === "modeUsageFrequency") return modeUsageFrequency
return undefined
})

vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key: string, value: any) => {
if (key === "modeUsageFrequency") {
modeUsageFrequency = value
}
})
;(provider as any).providerSettingsManager = {
getModeConfigId: vi.fn().mockResolvedValue(undefined),
listConfig: vi.fn().mockResolvedValue([]),
setModeConfig: vi.fn(),
} as any

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

// Verify mode usage frequency was tracked
expect(modeUsageFrequency).toEqual({
architect: 1,
})

// Set existing usage frequency
modeUsageFrequency = { architect: 1, code: 3 }

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

// Verify usage count was incremented
expect(modeUsageFrequency).toEqual({
architect: 2,
code: 3,
})

// Switch to a new mode
await provider.handleModeSwitch("debug")

// Verify new mode was added to usage frequency
expect(modeUsageFrequency).toEqual({
architect: 2,
code: 3,
debug: 1,
})
})

test("includes modeUsageFrequency in state when posting to webview", async () => {
// Mock the contextProxy to return modeUsageFrequency
let modeUsageFrequency = { code: 5, architect: 3, debug: 1 }

vi.spyOn(provider.contextProxy, "getValue").mockImplementation((key: string) => {
if (key === "modeUsageFrequency") return modeUsageFrequency
return undefined
})

vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key: string, value: any) => {
if (key === "modeUsageFrequency") {
modeUsageFrequency = value
}
})

// Mock getValues to return all necessary state including modeUsageFrequency
vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({
modeUsageFrequency,
mode: "code",
currentApiConfigName: "default",
listApiConfigMeta: [],
} as any)
;(provider as any).providerSettingsManager = {
getModeConfigId: vi.fn().mockResolvedValue(undefined),
listConfig: vi.fn().mockResolvedValue([]),
setModeConfig: vi.fn(),
} as any

// Clear previous mock calls
mockPostMessage.mockClear()

// Switch mode to trigger state post
await provider.handleModeSwitch("code")

// Find the call that contains the state update
const stateCalls = mockPostMessage.mock.calls.filter(
(call: any[]) => call[0]?.type === "state" && call[0]?.state?.modeUsageFrequency,
)

expect(stateCalls.length).toBeGreaterThan(0)
const lastStateCall = stateCalls[stateCalls.length - 1]

// Verify the modeUsageFrequency was incremented correctly
expect(lastStateCall[0].state.modeUsageFrequency).toEqual({
code: 6,
architect: 3,
debug: 1,
})
})
})

describe("updateCustomMode", () => {
Expand Down
2 changes: 2 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export type ExtensionState = Pick<
| "codebaseIndexConfig"
| "codebaseIndexModels"
| "profileThresholds"
| "modeUsageFrequency"
> & {
version: string
clineMessages: ClineMessage[]
Expand Down Expand Up @@ -283,6 +284,7 @@ export type ExtensionState = Pick<
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
modeUsageFrequency?: Record<string, number>
}

export interface ClineSayTool {
Expand Down
21 changes: 19 additions & 2 deletions webview-ui/src/components/modes/ModesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
customInstructions,
setCustomInstructions,
customModes,
modeUsageFrequency,
} = useExtensionState()

// Use a local state to track the visually active mode
Expand All @@ -83,8 +84,24 @@ const ModesView = ({ onDone }: ModesViewProps) => {
// 3. Still sending the mode change to the backend for persistence
const [visualMode, setVisualMode] = useState(mode)

// Memoize modes to preserve array order
const modes = useMemo(() => getAllModes(customModes), [customModes])
// Memoize modes and sort by usage frequency
const modes = useMemo(() => {
const allModes = getAllModes(customModes)

// Sort modes by usage frequency (descending)
return [...allModes].sort((a, b) => {
const freqA = modeUsageFrequency?.[a.slug] || 0
const freqB = modeUsageFrequency?.[b.slug] || 0

// Sort by frequency first (higher frequency first)
if (freqB !== freqA) {
return freqB - freqA
}

// If frequencies are equal, maintain original order
return allModes.indexOf(a) - allModes.indexOf(b)
})
}, [customModes, modeUsageFrequency])

const [isDialogOpen, setIsDialogOpen] = useState(false)
const [selectedPromptContent, setSelectedPromptContent] = useState("")
Expand Down
149 changes: 149 additions & 0 deletions webview-ui/src/components/modes/__tests__/ModesView.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const mockExtensionState = {
currentApiConfigName: "",
customInstructions: "Initial instructions",
setCustomInstructions: vitest.fn(),
modeUsageFrequency: {},
}

const renderPromptsView = (props = {}) => {
Expand Down Expand Up @@ -231,4 +232,152 @@ describe("PromptsView", () => {
text: undefined,
})
})

describe("Mode sorting by usage frequency", () => {
it("sorts modes by usage frequency in descending order", async () => {
const modeUsageFrequency = {
ask: 10,
code: 5,
architect: 15,
debug: 3,
}

renderPromptsView({ modeUsageFrequency })

// Open the mode selector
const selectTrigger = screen.getByTestId("mode-select-trigger")
fireEvent.click(selectTrigger)

// Wait for the dropdown to open
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})

// Get all mode options
const modeOptions = screen.getAllByTestId(/^mode-option-/)
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))

// Verify the order - architect (15) should be first, ask (10) second, code (5) third, debug (3) fourth
expect(modeIds[0]).toBe("architect")
expect(modeIds[1]).toBe("ask")
expect(modeIds[2]).toBe("code")
expect(modeIds[3]).toBe("debug")
})

it("maintains original order for modes with equal usage frequency", async () => {
const modeUsageFrequency = {
code: 5,
architect: 5,
ask: 5,
}

renderPromptsView({ modeUsageFrequency })

// Open the mode selector
const selectTrigger = screen.getByTestId("mode-select-trigger")
fireEvent.click(selectTrigger)

// Wait for the dropdown to open
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})

// Get all mode options
const modeOptions = screen.getAllByTestId(/^mode-option-/)
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))

// When frequencies are equal, modes should maintain their original order
// The original order is: architect, code, ask, debug, orchestrator (from modes array)
expect(modeIds[0]).toBe("architect")
expect(modeIds[1]).toBe("code")
expect(modeIds[2]).toBe("ask")
})

it("places modes with no usage data at the end", async () => {
const modeUsageFrequency = {
code: 10,
architect: 5,
// ask and debug have no usage data
}

renderPromptsView({ modeUsageFrequency })

// Open the mode selector
const selectTrigger = screen.getByTestId("mode-select-trigger")
fireEvent.click(selectTrigger)

// Wait for the dropdown to open
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})

// Get all mode options
const modeOptions = screen.getAllByTestId(/^mode-option-/)
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))

// code (10) should be first, architect (5) second, then modes with no usage
expect(modeIds[0]).toBe("code")
expect(modeIds[1]).toBe("architect")
// ask and debug should be after the modes with usage data
expect(modeIds.indexOf("ask")).toBeGreaterThan(1)
expect(modeIds.indexOf("debug")).toBeGreaterThan(1)
})

it("handles empty usage frequency object correctly", async () => {
renderPromptsView({ modeUsageFrequency: {} })

// Open the mode selector
const selectTrigger = screen.getByTestId("mode-select-trigger")
fireEvent.click(selectTrigger)

// Wait for the dropdown to open
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})

// Get all mode options
const modeOptions = screen.getAllByTestId(/^mode-option-/)

// Should show all modes in their original order
expect(modeOptions.length).toBeGreaterThan(0)
})

it("includes custom modes in sorting", async () => {
const customMode = {
slug: "custom-mode",
name: "Custom Mode",
roleDefinition: "Custom role",
groups: [],
}

const modeUsageFrequency = {
"custom-mode": 20,
code: 10,
architect: 5,
}

renderPromptsView({
modeUsageFrequency,
customModes: [customMode],
})

// Open the mode selector
const selectTrigger = screen.getByTestId("mode-select-trigger")
fireEvent.click(selectTrigger)

// Wait for the dropdown to open
await waitFor(() => {
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
})

// Get all mode options
const modeOptions = screen.getAllByTestId(/^mode-option-/)
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))

// custom-mode (20) should be first, code (10) second, architect (5) third
expect(modeIds[0]).toBe("custom-mode")
expect(modeIds[1]).toBe("code")
expect(modeIds[2]).toBe("architect")
})
})
})
1 change: 1 addition & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
codebaseIndexModels: { ollama: {}, openai: {} },
alwaysAllowUpdateTodoList: true,
modeUsageFrequency: {},
})

const [didHydrateState, setDidHydrateState] = useState(false)
Expand Down
Loading