Skip to content

Commit cdfd4f9

Browse files
committed
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
1 parent de13d8a commit cdfd4f9

File tree

7 files changed

+281
-2
lines changed

7 files changed

+281
-2
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ export const globalSettingsSchema = z.object({
131131
hasOpenedModeSelector: z.boolean().optional(),
132132
lastModeExportPath: z.string().optional(),
133133
lastModeImportPath: z.string().optional(),
134+
modeUsageFrequency: z.record(z.string(), z.number()).optional(),
134135
})
135136

136137
export type GlobalSettings = z.infer<typeof globalSettingsSchema>

src/core/webview/ClineProvider.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,11 @@ export class ClineProvider
811811

812812
await this.updateGlobalState("mode", newMode)
813813

814+
// Track mode usage frequency
815+
const modeUsageFrequency = this.getGlobalState("modeUsageFrequency") || {}
816+
modeUsageFrequency[newMode] = (modeUsageFrequency[newMode] || 0) + 1
817+
await this.updateGlobalState("modeUsageFrequency", modeUsageFrequency)
818+
814819
// Load the saved API config for the new mode if it exists
815820
const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode)
816821
const listApiConfig = await this.providerSettingsManager.listConfig()
@@ -1440,6 +1445,7 @@ export class ClineProvider
14401445
alwaysAllowFollowupQuestions,
14411446
followupAutoApproveTimeoutMs,
14421447
diagnosticsEnabled,
1448+
modeUsageFrequency,
14431449
} = await this.getState()
14441450

14451451
const telemetryKey = process.env.POSTHOG_API_KEY
@@ -1561,6 +1567,7 @@ export class ClineProvider
15611567
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
15621568
followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000,
15631569
diagnosticsEnabled: diagnosticsEnabled ?? true,
1570+
modeUsageFrequency: modeUsageFrequency ?? {},
15641571
}
15651572
}
15661573

@@ -1726,6 +1733,7 @@ export class ClineProvider
17261733
codebaseIndexSearchMinScore: stateValues.codebaseIndexConfig?.codebaseIndexSearchMinScore,
17271734
},
17281735
profileThresholds: stateValues.profileThresholds ?? {},
1736+
modeUsageFrequency: stateValues.modeUsageFrequency ?? {},
17291737
}
17301738
}
17311739

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,6 +1651,107 @@ describe("ClineProvider", () => {
16511651
// Verify state was posted to webview
16521652
expect(mockPostMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "state" }))
16531653
})
1654+
1655+
test("tracks mode usage frequency when switching modes", async () => {
1656+
// Mock the contextProxy's getValue to return modeUsageFrequency
1657+
let modeUsageFrequency: Record<string, number> | undefined = undefined
1658+
1659+
vi.spyOn(provider.contextProxy, "getValue").mockImplementation((key: string) => {
1660+
if (key === "modeUsageFrequency") return modeUsageFrequency
1661+
return undefined
1662+
})
1663+
1664+
vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key: string, value: any) => {
1665+
if (key === "modeUsageFrequency") {
1666+
modeUsageFrequency = value
1667+
}
1668+
})
1669+
;(provider as any).providerSettingsManager = {
1670+
getModeConfigId: vi.fn().mockResolvedValue(undefined),
1671+
listConfig: vi.fn().mockResolvedValue([]),
1672+
setModeConfig: vi.fn(),
1673+
} as any
1674+
1675+
// Switch to architect mode
1676+
await provider.handleModeSwitch("architect")
1677+
1678+
// Verify mode usage frequency was tracked
1679+
expect(modeUsageFrequency).toEqual({
1680+
architect: 1,
1681+
})
1682+
1683+
// Set existing usage frequency
1684+
modeUsageFrequency = { architect: 1, code: 3 }
1685+
1686+
// Switch to architect mode again
1687+
await provider.handleModeSwitch("architect")
1688+
1689+
// Verify usage count was incremented
1690+
expect(modeUsageFrequency).toEqual({
1691+
architect: 2,
1692+
code: 3,
1693+
})
1694+
1695+
// Switch to a new mode
1696+
await provider.handleModeSwitch("debug")
1697+
1698+
// Verify new mode was added to usage frequency
1699+
expect(modeUsageFrequency).toEqual({
1700+
architect: 2,
1701+
code: 3,
1702+
debug: 1,
1703+
})
1704+
})
1705+
1706+
test("includes modeUsageFrequency in state when posting to webview", async () => {
1707+
// Mock the contextProxy to return modeUsageFrequency
1708+
let modeUsageFrequency = { code: 5, architect: 3, debug: 1 }
1709+
1710+
vi.spyOn(provider.contextProxy, "getValue").mockImplementation((key: string) => {
1711+
if (key === "modeUsageFrequency") return modeUsageFrequency
1712+
return undefined
1713+
})
1714+
1715+
vi.spyOn(provider.contextProxy, "setValue").mockImplementation(async (key: string, value: any) => {
1716+
if (key === "modeUsageFrequency") {
1717+
modeUsageFrequency = value
1718+
}
1719+
})
1720+
1721+
// Mock getValues to return all necessary state including modeUsageFrequency
1722+
vi.spyOn(provider.contextProxy, "getValues").mockReturnValue({
1723+
modeUsageFrequency,
1724+
mode: "code",
1725+
currentApiConfigName: "default",
1726+
listApiConfigMeta: [],
1727+
} as any)
1728+
;(provider as any).providerSettingsManager = {
1729+
getModeConfigId: vi.fn().mockResolvedValue(undefined),
1730+
listConfig: vi.fn().mockResolvedValue([]),
1731+
setModeConfig: vi.fn(),
1732+
} as any
1733+
1734+
// Clear previous mock calls
1735+
mockPostMessage.mockClear()
1736+
1737+
// Switch mode to trigger state post
1738+
await provider.handleModeSwitch("code")
1739+
1740+
// Find the call that contains the state update
1741+
const stateCalls = mockPostMessage.mock.calls.filter(
1742+
(call: any[]) => call[0]?.type === "state" && call[0]?.state?.modeUsageFrequency,
1743+
)
1744+
1745+
expect(stateCalls.length).toBeGreaterThan(0)
1746+
const lastStateCall = stateCalls[stateCalls.length - 1]
1747+
1748+
// Verify the modeUsageFrequency was incremented correctly
1749+
expect(lastStateCall[0].state.modeUsageFrequency).toEqual({
1750+
code: 6,
1751+
architect: 3,
1752+
debug: 1,
1753+
})
1754+
})
16541755
})
16551756

16561757
describe("updateCustomMode", () => {

src/shared/ExtensionMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export type ExtensionState = Pick<
234234
| "codebaseIndexConfig"
235235
| "codebaseIndexModels"
236236
| "profileThresholds"
237+
| "modeUsageFrequency"
237238
> & {
238239
version: string
239240
clineMessages: ClineMessage[]
@@ -283,6 +284,7 @@ export type ExtensionState = Pick<
283284
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
284285
profileThresholds: Record<string, number>
285286
hasOpenedModeSelector: boolean
287+
modeUsageFrequency?: Record<string, number>
286288
}
287289

288290
export interface ClineSayTool {

webview-ui/src/components/modes/ModesView.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ const ModesView = ({ onDone }: ModesViewProps) => {
7474
customInstructions,
7575
setCustomInstructions,
7676
customModes,
77+
modeUsageFrequency,
7778
} = useExtensionState()
7879

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

86-
// Memoize modes to preserve array order
87-
const modes = useMemo(() => getAllModes(customModes), [customModes])
87+
// Memoize modes and sort by usage frequency
88+
const modes = useMemo(() => {
89+
const allModes = getAllModes(customModes)
90+
91+
// Sort modes by usage frequency (descending)
92+
return [...allModes].sort((a, b) => {
93+
const freqA = modeUsageFrequency?.[a.slug] || 0
94+
const freqB = modeUsageFrequency?.[b.slug] || 0
95+
96+
// Sort by frequency first (higher frequency first)
97+
if (freqB !== freqA) {
98+
return freqB - freqA
99+
}
100+
101+
// If frequencies are equal, maintain original order
102+
return allModes.indexOf(a) - allModes.indexOf(b)
103+
})
104+
}, [customModes, modeUsageFrequency])
88105

89106
const [isDialogOpen, setIsDialogOpen] = useState(false)
90107
const [selectedPromptContent, setSelectedPromptContent] = useState("")

webview-ui/src/components/modes/__tests__/ModesView.spec.tsx

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const mockExtensionState = {
2626
currentApiConfigName: "",
2727
customInstructions: "Initial instructions",
2828
setCustomInstructions: vitest.fn(),
29+
modeUsageFrequency: {},
2930
}
3031

3132
const renderPromptsView = (props = {}) => {
@@ -231,4 +232,152 @@ describe("PromptsView", () => {
231232
text: undefined,
232233
})
233234
})
235+
236+
describe("Mode sorting by usage frequency", () => {
237+
it("sorts modes by usage frequency in descending order", async () => {
238+
const modeUsageFrequency = {
239+
ask: 10,
240+
code: 5,
241+
architect: 15,
242+
debug: 3,
243+
}
244+
245+
renderPromptsView({ modeUsageFrequency })
246+
247+
// Open the mode selector
248+
const selectTrigger = screen.getByTestId("mode-select-trigger")
249+
fireEvent.click(selectTrigger)
250+
251+
// Wait for the dropdown to open
252+
await waitFor(() => {
253+
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
254+
})
255+
256+
// Get all mode options
257+
const modeOptions = screen.getAllByTestId(/^mode-option-/)
258+
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))
259+
260+
// Verify the order - architect (15) should be first, ask (10) second, code (5) third, debug (3) fourth
261+
expect(modeIds[0]).toBe("architect")
262+
expect(modeIds[1]).toBe("ask")
263+
expect(modeIds[2]).toBe("code")
264+
expect(modeIds[3]).toBe("debug")
265+
})
266+
267+
it("maintains original order for modes with equal usage frequency", async () => {
268+
const modeUsageFrequency = {
269+
code: 5,
270+
architect: 5,
271+
ask: 5,
272+
}
273+
274+
renderPromptsView({ modeUsageFrequency })
275+
276+
// Open the mode selector
277+
const selectTrigger = screen.getByTestId("mode-select-trigger")
278+
fireEvent.click(selectTrigger)
279+
280+
// Wait for the dropdown to open
281+
await waitFor(() => {
282+
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
283+
})
284+
285+
// Get all mode options
286+
const modeOptions = screen.getAllByTestId(/^mode-option-/)
287+
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))
288+
289+
// When frequencies are equal, modes should maintain their original order
290+
// The original order is: architect, code, ask, debug, orchestrator (from modes array)
291+
expect(modeIds[0]).toBe("architect")
292+
expect(modeIds[1]).toBe("code")
293+
expect(modeIds[2]).toBe("ask")
294+
})
295+
296+
it("places modes with no usage data at the end", async () => {
297+
const modeUsageFrequency = {
298+
code: 10,
299+
architect: 5,
300+
// ask and debug have no usage data
301+
}
302+
303+
renderPromptsView({ modeUsageFrequency })
304+
305+
// Open the mode selector
306+
const selectTrigger = screen.getByTestId("mode-select-trigger")
307+
fireEvent.click(selectTrigger)
308+
309+
// Wait for the dropdown to open
310+
await waitFor(() => {
311+
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
312+
})
313+
314+
// Get all mode options
315+
const modeOptions = screen.getAllByTestId(/^mode-option-/)
316+
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))
317+
318+
// code (10) should be first, architect (5) second, then modes with no usage
319+
expect(modeIds[0]).toBe("code")
320+
expect(modeIds[1]).toBe("architect")
321+
// ask and debug should be after the modes with usage data
322+
expect(modeIds.indexOf("ask")).toBeGreaterThan(1)
323+
expect(modeIds.indexOf("debug")).toBeGreaterThan(1)
324+
})
325+
326+
it("handles empty usage frequency object correctly", async () => {
327+
renderPromptsView({ modeUsageFrequency: {} })
328+
329+
// Open the mode selector
330+
const selectTrigger = screen.getByTestId("mode-select-trigger")
331+
fireEvent.click(selectTrigger)
332+
333+
// Wait for the dropdown to open
334+
await waitFor(() => {
335+
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
336+
})
337+
338+
// Get all mode options
339+
const modeOptions = screen.getAllByTestId(/^mode-option-/)
340+
341+
// Should show all modes in their original order
342+
expect(modeOptions.length).toBeGreaterThan(0)
343+
})
344+
345+
it("includes custom modes in sorting", async () => {
346+
const customMode = {
347+
slug: "custom-mode",
348+
name: "Custom Mode",
349+
roleDefinition: "Custom role",
350+
groups: [],
351+
}
352+
353+
const modeUsageFrequency = {
354+
"custom-mode": 20,
355+
code: 10,
356+
architect: 5,
357+
}
358+
359+
renderPromptsView({
360+
modeUsageFrequency,
361+
customModes: [customMode],
362+
})
363+
364+
// Open the mode selector
365+
const selectTrigger = screen.getByTestId("mode-select-trigger")
366+
fireEvent.click(selectTrigger)
367+
368+
// Wait for the dropdown to open
369+
await waitFor(() => {
370+
expect(selectTrigger).toHaveAttribute("aria-expanded", "true")
371+
})
372+
373+
// Get all mode options
374+
const modeOptions = screen.getAllByTestId(/^mode-option-/)
375+
const modeIds = modeOptions.map((option) => option.getAttribute("data-testid")?.replace("mode-option-", ""))
376+
377+
// custom-mode (20) should be first, code (10) second, architect (5) third
378+
expect(modeIds[0]).toBe("custom-mode")
379+
expect(modeIds[1]).toBe("code")
380+
expect(modeIds[2]).toBe("architect")
381+
})
382+
})
234383
})

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
229229
},
230230
codebaseIndexModels: { ollama: {}, openai: {} },
231231
alwaysAllowUpdateTodoList: true,
232+
modeUsageFrequency: {},
232233
})
233234

234235
const [didHydrateState, setDidHydrateState] = useState(false)

0 commit comments

Comments
 (0)