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
This commit is contained in:
Roo Code 2025-07-20 04:16:57 +00:00
parent de13d8a5cf
commit cdfd4f9d14
7 changed files with 281 additions and 2 deletions

View file

@ -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>

View file

@ -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 ?? {},
}
}

View file

@ -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", () => {

View file

@ -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<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
modeUsageFrequency?: Record<string, number>
}
export interface ClineSayTool {

View file

@ -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("")

View file

@ -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")
})
})
})

View file

@ -229,6 +229,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
codebaseIndexModels: { ollama: {}, openai: {} },
alwaysAllowUpdateTodoList: true,
modeUsageFrequency: {},
})
const [didHydrateState, setDidHydrateState] = useState(false)