Skip to content

Commit e4b5d3c

Browse files
committed
feat: add sorting and pinning for modes and API profiles
- Add global state fields for sorting preferences (modeSortingMode, pinnedModes, customModeOrder, apiProfileSortingMode, customApiProfileOrder) - Implement pinning functionality for modes similar to existing API profile pinning - Update ModeSelector component to support pinning and respect sorting preferences - Update ApiConfigSelector to respect manual sorting when enabled - Add message handlers for new sorting operations - Update ExtensionStateContext to include sorting settings and setters - Modify ClineProvider to pass sorting settings to webview - Add TypeScript type definitions for all new sorting operations Implements #7496
1 parent 6ef9dbd commit e4b5d3c

File tree

8 files changed

+293
-31
lines changed

8 files changed

+293
-31
lines changed

packages/types/src/global-settings.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ export const globalSettingsSchema = z.object({
3838
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
3939
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
4040

41+
// Mode and API profile sorting preferences
42+
modeSortingMode: z.enum(["alphabetical", "manual"]).optional(),
43+
pinnedModes: z.record(z.string(), z.boolean()).optional(),
44+
customModeOrder: z.array(z.string()).optional(),
45+
apiProfileSortingMode: z.enum(["alphabetical", "manual"]).optional(),
46+
customApiProfileOrder: z.array(z.string()).optional(),
47+
4148
lastShownAnnouncementId: z.string().optional(),
4249
customInstructions: z.string().optional(),
4350
taskHistory: z.array(historyItemSchema).optional(),
@@ -232,6 +239,13 @@ export const EVALS_SETTINGS: RooCodeSettings = {
232239

233240
pinnedApiConfigs: {},
234241

242+
// Default sorting settings
243+
modeSortingMode: "alphabetical",
244+
pinnedModes: {},
245+
customModeOrder: [],
246+
apiProfileSortingMode: "alphabetical",
247+
customApiProfileOrder: [],
248+
235249
autoApprovalEnabled: true,
236250
alwaysAllowReadOnly: true,
237251
alwaysAllowReadOnlyOutsideWorkspace: false,

src/core/webview/ClineProvider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,6 +1726,11 @@ export class ClineProvider
17261726
currentApiConfigName,
17271727
listApiConfigMeta,
17281728
pinnedApiConfigs,
1729+
modeSortingMode,
1730+
pinnedModes,
1731+
customModeOrder,
1732+
apiProfileSortingMode,
1733+
customApiProfileOrder,
17291734
mode,
17301735
customModePrompts,
17311736
customSupportPrompts,
@@ -1835,6 +1840,11 @@ export class ClineProvider
18351840
currentApiConfigName: currentApiConfigName ?? "default",
18361841
listApiConfigMeta: listApiConfigMeta ?? [],
18371842
pinnedApiConfigs: pinnedApiConfigs ?? {},
1843+
modeSortingMode: modeSortingMode ?? "alphabetical",
1844+
pinnedModes: pinnedModes ?? {},
1845+
customModeOrder: customModeOrder ?? [],
1846+
apiProfileSortingMode: apiProfileSortingMode ?? "alphabetical",
1847+
customApiProfileOrder: customApiProfileOrder ?? [],
18381848
mode: mode ?? defaultModeSlug,
18391849
customModePrompts: customModePrompts ?? {},
18401850
customSupportPrompts: customSupportPrompts ?? {},
@@ -2031,6 +2041,11 @@ export class ClineProvider
20312041
currentApiConfigName: stateValues.currentApiConfigName ?? "default",
20322042
listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
20332043
pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {},
2044+
modeSortingMode: stateValues.modeSortingMode ?? "alphabetical",
2045+
pinnedModes: stateValues.pinnedModes ?? {},
2046+
customModeOrder: stateValues.customModeOrder ?? [],
2047+
apiProfileSortingMode: stateValues.apiProfileSortingMode ?? "alphabetical",
2048+
customApiProfileOrder: stateValues.customApiProfileOrder ?? [],
20342049
modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
20352050
customModePrompts: stateValues.customModePrompts ?? {},
20362051
customSupportPrompts: stateValues.customSupportPrompts ?? {},

src/core/webview/webviewMessageHandler.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,45 @@ export const webviewMessageHandler = async (
13581358
await provider.postStateToWebview()
13591359
}
13601360
break
1361+
case "toggleModePin":
1362+
if (message.text) {
1363+
const currentPinned = getGlobalState("pinnedModes") ?? {}
1364+
const updatedPinned: Record<string, boolean> = { ...currentPinned }
1365+
1366+
if (currentPinned[message.text]) {
1367+
delete updatedPinned[message.text]
1368+
} else {
1369+
updatedPinned[message.text] = true
1370+
}
1371+
1372+
await updateGlobalState("pinnedModes", updatedPinned)
1373+
await provider.postStateToWebview()
1374+
}
1375+
break
1376+
case "modeSortingMode":
1377+
if (message.text === "alphabetical" || message.text === "manual") {
1378+
await updateGlobalState("modeSortingMode", message.text)
1379+
await provider.postStateToWebview()
1380+
}
1381+
break
1382+
case "apiProfileSortingMode":
1383+
if (message.text === "alphabetical" || message.text === "manual") {
1384+
await updateGlobalState("apiProfileSortingMode", message.text)
1385+
await provider.postStateToWebview()
1386+
}
1387+
break
1388+
case "customModeOrder":
1389+
if (message.values && Array.isArray(message.values)) {
1390+
await updateGlobalState("customModeOrder", message.values)
1391+
await provider.postStateToWebview()
1392+
}
1393+
break
1394+
case "customApiProfileOrder":
1395+
if (message.values && Array.isArray(message.values)) {
1396+
await updateGlobalState("customApiProfileOrder", message.values)
1397+
await provider.postStateToWebview()
1398+
}
1399+
break
13611400
case "enhancementApiConfigId":
13621401
await updateGlobalState("enhancementApiConfigId", message.text)
13631402
await provider.postStateToWebview()

src/shared/ExtensionMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,11 @@ export type ExtensionState = Pick<
201201
| "currentApiConfigName"
202202
| "listApiConfigMeta"
203203
| "pinnedApiConfigs"
204+
| "modeSortingMode"
205+
| "pinnedModes"
206+
| "customModeOrder"
207+
| "apiProfileSortingMode"
208+
| "customApiProfileOrder"
204209
// | "lastShownAnnouncementId"
205210
| "customInstructions"
206211
// | "taskHistory" // Optional in GlobalSettings, required here.

src/shared/WebviewMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,13 @@ export interface WebviewMessage {
172172
| "maxDiagnosticMessages"
173173
| "searchFiles"
174174
| "toggleApiConfigPin"
175+
| "toggleModePin"
175176
| "setHistoryPreviewCollapsed"
176177
| "hasOpenedModeSelector"
178+
| "modeSortingMode"
179+
| "apiProfileSortingMode"
180+
| "customModeOrder"
181+
| "customApiProfileOrder"
177182
| "accountButtonClicked"
178183
| "rooCloudSignIn"
179184
| "rooCloudSignOut"

webview-ui/src/components/chat/ApiConfigSelector.tsx

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"
66
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
77
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
88
import { useAppTranslation } from "@/i18n/TranslationContext"
9+
import { useExtensionState } from "@/context/ExtensionStateContext"
910
import { vscode } from "@/utils/vscode"
1011
import { Button } from "@/components/ui"
1112

@@ -39,6 +40,9 @@ export const ApiConfigSelector = ({
3940
const [searchValue, setSearchValue] = useState("")
4041
const portalContainer = useRooPortal("roo-portal")
4142

43+
// Get sorting preferences from extension state
44+
const { apiProfileSortingMode, customApiProfileOrder } = useExtensionState()
45+
4246
// Create searchable items for fuzzy search.
4347
const searchableItems = useMemo(
4448
() =>
@@ -65,12 +69,40 @@ export const ApiConfigSelector = ({
6569
return matchingItems
6670
}, [listApiConfigMeta, searchValue, fzfInstance])
6771

72+
// Sort configs based on sorting preferences
73+
const sortedConfigs = useMemo(() => {
74+
const sorted = [...filteredConfigs]
75+
76+
if (apiProfileSortingMode === "manual" && customApiProfileOrder && customApiProfileOrder.length > 0) {
77+
// Sort based on custom order
78+
sorted.sort((a, b) => {
79+
const aIndex = customApiProfileOrder.indexOf(a.id)
80+
const bIndex = customApiProfileOrder.indexOf(b.id)
81+
82+
// If both are in custom order, sort by their position
83+
if (aIndex !== -1 && bIndex !== -1) {
84+
return aIndex - bIndex
85+
}
86+
// If only one is in custom order, it comes first
87+
if (aIndex !== -1) return -1
88+
if (bIndex !== -1) return 1
89+
// Otherwise maintain original order
90+
return 0
91+
})
92+
} else {
93+
// Alphabetical sorting (default)
94+
sorted.sort((a, b) => a.name.localeCompare(b.name))
95+
}
96+
97+
return sorted
98+
}, [filteredConfigs, apiProfileSortingMode, customApiProfileOrder])
99+
68100
// Separate pinned and unpinned configs.
69101
const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
70-
const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
71-
const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
102+
const pinned = sortedConfigs.filter((config) => pinnedApiConfigs?.[config.id])
103+
const unpinned = sortedConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
72104
return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
73-
}, [filteredConfigs, pinnedApiConfigs])
105+
}, [sortedConfigs, pinnedApiConfigs])
74106

75107
const handleSelect = useCallback(
76108
(configId: string) => {

webview-ui/src/components/chat/ModeSelector.tsx

Lines changed: 118 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"
1212
import { useExtensionState } from "@/context/ExtensionStateContext"
1313
import { useAppTranslation } from "@/i18n/TranslationContext"
1414
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
15-
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
15+
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui"
1616

1717
import { IconButton } from "./IconButton"
1818

@@ -45,7 +45,14 @@ export const ModeSelector = ({
4545
const [searchValue, setSearchValue] = React.useState("")
4646
const searchInputRef = React.useRef<HTMLInputElement>(null)
4747
const portalContainer = useRooPortal("roo-portal")
48-
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
48+
const {
49+
hasOpenedModeSelector,
50+
setHasOpenedModeSelector,
51+
modeSortingMode,
52+
pinnedModes,
53+
togglePinnedMode,
54+
customModeOrder,
55+
} = useExtensionState()
4956
const { t } = useAppTranslation()
5057

5158
const trackModeSelectorOpened = React.useCallback(() => {
@@ -99,9 +106,44 @@ export const ModeSelector = ({
99106
[descriptionSearchItems],
100107
)
101108

109+
// Sort modes based on sorting preferences
110+
const sortedModes = React.useMemo(() => {
111+
let sorted = [...modes]
112+
113+
if (modeSortingMode === "manual" && customModeOrder && customModeOrder.length > 0) {
114+
// Sort based on custom order
115+
sorted.sort((a, b) => {
116+
const aIndex = customModeOrder.indexOf(a.slug)
117+
const bIndex = customModeOrder.indexOf(b.slug)
118+
119+
// If both are in custom order, sort by their position
120+
if (aIndex !== -1 && bIndex !== -1) {
121+
return aIndex - bIndex
122+
}
123+
// If only one is in custom order, it comes first
124+
if (aIndex !== -1) return -1
125+
if (bIndex !== -1) return 1
126+
// Otherwise maintain original order
127+
return 0
128+
})
129+
} else {
130+
// Alphabetical sorting (default)
131+
sorted.sort((a, b) => a.name.localeCompare(b.name))
132+
}
133+
134+
// Apply pinning - pinned modes come first
135+
if (pinnedModes) {
136+
const pinned = sorted.filter((mode) => pinnedModes[mode.slug])
137+
const unpinned = sorted.filter((mode) => !pinnedModes[mode.slug])
138+
sorted = [...pinned, ...unpinned]
139+
}
140+
141+
return sorted
142+
}, [modes, modeSortingMode, pinnedModes, customModeOrder])
143+
102144
// Filter modes based on search value using fuzzy search with priority.
103145
const filteredModes = React.useMemo(() => {
104-
if (!searchValue) return modes
146+
if (!searchValue) return sortedModes
105147

106148
// First search in names/slugs.
107149
const nameMatches = nameFzfInstance.find(searchValue)
@@ -118,8 +160,13 @@ export const ModeSelector = ({
118160
.map((result) => result.item.original),
119161
]
120162

121-
return combinedResults
122-
}, [modes, searchValue, nameFzfInstance, descriptionFzfInstance])
163+
// Preserve the sorting order after filtering
164+
const sortedFilteredResults = sortedModes.filter((mode) =>
165+
combinedResults.some((result) => result.slug === mode.slug),
166+
)
167+
168+
return sortedFilteredResults
169+
}, [sortedModes, searchValue, nameFzfInstance, descriptionFzfInstance])
123170

124171
const onClearSearch = React.useCallback(() => {
125172
setSearchValue("")
@@ -230,29 +277,24 @@ export const ModeSelector = ({
230277
</div>
231278
) : (
232279
<div className="py-1">
233-
{filteredModes.map((mode) => (
234-
<div
235-
key={mode.slug}
236-
onClick={() => handleSelect(mode.slug)}
237-
className={cn(
238-
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
239-
"hover:bg-vscode-list-hoverBackground",
240-
mode.slug === value
241-
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
242-
: "",
243-
)}
244-
data-testid="mode-selector-item">
245-
<div className="flex-1 min-w-0">
246-
<div className="font-bold truncate">{mode.name}</div>
247-
{mode.description && (
248-
<div className="text-xs text-vscode-descriptionForeground truncate">
249-
{mode.description}
250-
</div>
280+
{/* Show pinned modes first if any */}
281+
{pinnedModes &&
282+
Object.keys(pinnedModes).length > 0 &&
283+
filteredModes.filter((mode) => pinnedModes[mode.slug]).length > 0 && (
284+
<>
285+
{filteredModes
286+
.filter((mode) => pinnedModes[mode.slug])
287+
.map((mode) => renderModeItem(mode, true))}
288+
{/* Separator between pinned and unpinned */}
289+
{filteredModes.filter((mode) => !pinnedModes[mode.slug]).length > 0 && (
290+
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
251291
)}
252-
</div>
253-
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
254-
</div>
255-
))}
292+
</>
293+
)}
294+
{/* Show unpinned modes */}
295+
{filteredModes
296+
.filter((mode) => !pinnedModes || !pinnedModes[mode.slug])
297+
.map((mode) => renderModeItem(mode, false))}
256298
</div>
257299
)}
258300
</div>
@@ -301,4 +343,53 @@ export const ModeSelector = ({
301343
</PopoverContent>
302344
</Popover>
303345
)
346+
347+
function renderModeItem(mode: (typeof modes)[0], isPinned: boolean) {
348+
const isSelected = mode.slug === value
349+
350+
return (
351+
<div
352+
key={mode.slug}
353+
onClick={() => handleSelect(mode.slug)}
354+
className={cn(
355+
"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
356+
"hover:bg-vscode-list-hoverBackground",
357+
isSelected
358+
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
359+
: "",
360+
)}
361+
data-testid="mode-selector-item">
362+
<div className="flex-1 min-w-0">
363+
<div className="font-bold truncate">{mode.name}</div>
364+
{mode.description && (
365+
<div className="text-xs text-vscode-descriptionForeground truncate">{mode.description}</div>
366+
)}
367+
</div>
368+
<div className="flex items-center gap-1">
369+
{isSelected && (
370+
<div className="size-5 p-1 flex items-center justify-center">
371+
<Check className="size-3" />
372+
</div>
373+
)}
374+
<StandardTooltip content={isPinned ? t("chat:unpin") : t("chat:pin")}>
375+
<Button
376+
variant="ghost"
377+
size="icon"
378+
tabIndex={-1}
379+
onClick={(e) => {
380+
e.stopPropagation()
381+
togglePinnedMode?.(mode.slug)
382+
vscode.postMessage({ type: "toggleModePin", text: mode.slug })
383+
}}
384+
className={cn("size-5 flex items-center justify-center", {
385+
"opacity-0 group-hover:opacity-100": !isPinned && !isSelected,
386+
"bg-accent opacity-100": isPinned,
387+
})}>
388+
<span className="codicon codicon-pin text-xs opacity-50" />
389+
</Button>
390+
</StandardTooltip>
391+
</div>
392+
</div>
393+
)
394+
}
304395
}

0 commit comments

Comments
 (0)