From cdfd4f9d141f86d6f798f44d3a6df53df943e3fe Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 20 Jul 2025 04:16:57 +0000 Subject: [PATCH] feat: sort modes by usage frequency - Add modeUsageFrequency tracking to global state schema - Track mode usage when switching modes in ClineProvider - Update ModesView to sort modes by usage frequency (descending) - Add modeUsageFrequency to ExtensionState and context - Add comprehensive tests for mode usage tracking and sorting Fixes #5975 --- packages/types/src/global-settings.ts | 1 + src/core/webview/ClineProvider.ts | 8 + .../webview/__tests__/ClineProvider.spec.ts | 101 ++++++++++++ src/shared/ExtensionMessage.ts | 2 + webview-ui/src/components/modes/ModesView.tsx | 21 ++- .../modes/__tests__/ModesView.spec.tsx | 149 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 1 + 7 files changed, 281 insertions(+), 2 deletions(-) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a30550dce1..98eb835a15 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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 diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6231f08167..79fecb5afb 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -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() @@ -1440,6 +1445,7 @@ export class ClineProvider alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, diagnosticsEnabled, + modeUsageFrequency, } = await this.getState() const telemetryKey = process.env.POSTHOG_API_KEY @@ -1561,6 +1567,7 @@ export class ClineProvider alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: diagnosticsEnabled ?? true, + modeUsageFrequency: modeUsageFrequency ?? {}, } } @@ -1726,6 +1733,7 @@ export class ClineProvider codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore, }, profileThresholds: stateValues.profileThresholds ?? {}, + modeUsageFrequency: stateValues.modeUsageFrequency ?? {}, } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5272c33451..9a53ba6e24 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -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 | 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", () => { diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 4f2aa2da15..c30dea5649 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -234,6 +234,7 @@ export type ExtensionState = Pick< | "codebaseIndexConfig" | "codebaseIndexModels" | "profileThresholds" + | "modeUsageFrequency" > & { version: string clineMessages: ClineMessage[] @@ -283,6 +284,7 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean + modeUsageFrequency?: Record } export interface ClineSayTool { diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 170d03b0e4..1819c955c9 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -74,6 +74,7 @@ const ModesView = ({ onDone }: ModesViewProps) => { customInstructions, setCustomInstructions, customModes, + modeUsageFrequency, } = useExtensionState() // Use a local state to track the visually active mode @@ -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("") diff --git a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx index e202114bbb..cef68ebdaf 100644 --- a/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx +++ b/webview-ui/src/components/modes/__tests__/ModesView.spec.tsx @@ -26,6 +26,7 @@ const mockExtensionState = { currentApiConfigName: "", customInstructions: "Initial instructions", setCustomInstructions: vitest.fn(), + modeUsageFrequency: {}, } const renderPromptsView = (props = {}) => { @@ -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") + }) + }) }) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index c970733fba..e7308fa12c 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -229,6 +229,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, codebaseIndexModels: { ollama: {}, openai: {} }, alwaysAllowUpdateTodoList: true, + modeUsageFrequency: {}, }) const [didHydrateState, setDidHydrateState] = useState(false)