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
This commit is contained in:
Roo Code 2025-08-28 15:03:06 +00:00
parent 6ef9dbd873
commit e4b5d3cb36
8 changed files with 293 additions and 31 deletions

View file

@ -38,6 +38,13 @@ export const globalSettingsSchema = z.object({
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
// Mode and API profile sorting preferences
modeSortingMode: z.enum(["alphabetical", "manual"]).optional(),
pinnedModes: z.record(z.string(), z.boolean()).optional(),
customModeOrder: z.array(z.string()).optional(),
apiProfileSortingMode: z.enum(["alphabetical", "manual"]).optional(),
customApiProfileOrder: z.array(z.string()).optional(),
lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
taskHistory: z.array(historyItemSchema).optional(),
@ -232,6 +239,13 @@ export const EVALS_SETTINGS: RooCodeSettings = {
pinnedApiConfigs: {},
// Default sorting settings
modeSortingMode: "alphabetical",
pinnedModes: {},
customModeOrder: [],
apiProfileSortingMode: "alphabetical",
customApiProfileOrder: [],
autoApprovalEnabled: true,
alwaysAllowReadOnly: true,
alwaysAllowReadOnlyOutsideWorkspace: false,

View file

@ -1726,6 +1726,11 @@ export class ClineProvider
currentApiConfigName,
listApiConfigMeta,
pinnedApiConfigs,
modeSortingMode,
pinnedModes,
customModeOrder,
apiProfileSortingMode,
customApiProfileOrder,
mode,
customModePrompts,
customSupportPrompts,
@ -1835,6 +1840,11 @@ export class ClineProvider
currentApiConfigName: currentApiConfigName ?? "default",
listApiConfigMeta: listApiConfigMeta ?? [],
pinnedApiConfigs: pinnedApiConfigs ?? {},
modeSortingMode: modeSortingMode ?? "alphabetical",
pinnedModes: pinnedModes ?? {},
customModeOrder: customModeOrder ?? [],
apiProfileSortingMode: apiProfileSortingMode ?? "alphabetical",
customApiProfileOrder: customApiProfileOrder ?? [],
mode: mode ?? defaultModeSlug,
customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {},
@ -2031,6 +2041,11 @@ export class ClineProvider
currentApiConfigName: stateValues.currentApiConfigName ?? "default",
listApiConfigMeta: stateValues.listApiConfigMeta ?? [],
pinnedApiConfigs: stateValues.pinnedApiConfigs ?? {},
modeSortingMode: stateValues.modeSortingMode ?? "alphabetical",
pinnedModes: stateValues.pinnedModes ?? {},
customModeOrder: stateValues.customModeOrder ?? [],
apiProfileSortingMode: stateValues.apiProfileSortingMode ?? "alphabetical",
customApiProfileOrder: stateValues.customApiProfileOrder ?? [],
modeApiConfigs: stateValues.modeApiConfigs ?? ({} as Record<Mode, string>),
customModePrompts: stateValues.customModePrompts ?? {},
customSupportPrompts: stateValues.customSupportPrompts ?? {},

View file

@ -1358,6 +1358,45 @@ export const webviewMessageHandler = async (
await provider.postStateToWebview()
}
break
case "toggleModePin":
if (message.text) {
const currentPinned = getGlobalState("pinnedModes") ?? {}
const updatedPinned: Record<string, boolean> = { ...currentPinned }
if (currentPinned[message.text]) {
delete updatedPinned[message.text]
} else {
updatedPinned[message.text] = true
}
await updateGlobalState("pinnedModes", updatedPinned)
await provider.postStateToWebview()
}
break
case "modeSortingMode":
if (message.text === "alphabetical" || message.text === "manual") {
await updateGlobalState("modeSortingMode", message.text)
await provider.postStateToWebview()
}
break
case "apiProfileSortingMode":
if (message.text === "alphabetical" || message.text === "manual") {
await updateGlobalState("apiProfileSortingMode", message.text)
await provider.postStateToWebview()
}
break
case "customModeOrder":
if (message.values && Array.isArray(message.values)) {
await updateGlobalState("customModeOrder", message.values)
await provider.postStateToWebview()
}
break
case "customApiProfileOrder":
if (message.values && Array.isArray(message.values)) {
await updateGlobalState("customApiProfileOrder", message.values)
await provider.postStateToWebview()
}
break
case "enhancementApiConfigId":
await updateGlobalState("enhancementApiConfigId", message.text)
await provider.postStateToWebview()

View file

@ -201,6 +201,11 @@ export type ExtensionState = Pick<
| "currentApiConfigName"
| "listApiConfigMeta"
| "pinnedApiConfigs"
| "modeSortingMode"
| "pinnedModes"
| "customModeOrder"
| "apiProfileSortingMode"
| "customApiProfileOrder"
// | "lastShownAnnouncementId"
| "customInstructions"
// | "taskHistory" // Optional in GlobalSettings, required here.

View file

@ -172,8 +172,13 @@ export interface WebviewMessage {
| "maxDiagnosticMessages"
| "searchFiles"
| "toggleApiConfigPin"
| "toggleModePin"
| "setHistoryPreviewCollapsed"
| "hasOpenedModeSelector"
| "modeSortingMode"
| "apiProfileSortingMode"
| "customModeOrder"
| "customApiProfileOrder"
| "accountButtonClicked"
| "rooCloudSignIn"
| "rooCloudSignOut"

View file

@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"
@ -39,6 +40,9 @@ export const ApiConfigSelector = ({
const [searchValue, setSearchValue] = useState("")
const portalContainer = useRooPortal("roo-portal")
// Get sorting preferences from extension state
const { apiProfileSortingMode, customApiProfileOrder } = useExtensionState()
// Create searchable items for fuzzy search.
const searchableItems = useMemo(
() =>
@ -65,12 +69,40 @@ export const ApiConfigSelector = ({
return matchingItems
}, [listApiConfigMeta, searchValue, fzfInstance])
// Sort configs based on sorting preferences
const sortedConfigs = useMemo(() => {
const sorted = [...filteredConfigs]
if (apiProfileSortingMode === "manual" && customApiProfileOrder && customApiProfileOrder.length > 0) {
// Sort based on custom order
sorted.sort((a, b) => {
const aIndex = customApiProfileOrder.indexOf(a.id)
const bIndex = customApiProfileOrder.indexOf(b.id)
// If both are in custom order, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex
}
// If only one is in custom order, it comes first
if (aIndex !== -1) return -1
if (bIndex !== -1) return 1
// Otherwise maintain original order
return 0
})
} else {
// Alphabetical sorting (default)
sorted.sort((a, b) => a.name.localeCompare(b.name))
}
return sorted
}, [filteredConfigs, apiProfileSortingMode, customApiProfileOrder])
// Separate pinned and unpinned configs.
const { pinnedConfigs, unpinnedConfigs } = useMemo(() => {
const pinned = filteredConfigs.filter((config) => pinnedApiConfigs?.[config.id])
const unpinned = filteredConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
const pinned = sortedConfigs.filter((config) => pinnedApiConfigs?.[config.id])
const unpinned = sortedConfigs.filter((config) => !pinnedApiConfigs?.[config.id])
return { pinnedConfigs: pinned, unpinnedConfigs: unpinned }
}, [filteredConfigs, pinnedApiConfigs])
}, [sortedConfigs, pinnedApiConfigs])
const handleSelect = useCallback(
(configId: string) => {

View file

@ -12,7 +12,7 @@ import { cn } from "@/lib/utils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { useRooPortal } from "@/components/ui/hooks/useRooPortal"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip } from "@/components/ui"
import { Popover, PopoverContent, PopoverTrigger, StandardTooltip, Button } from "@/components/ui"
import { IconButton } from "./IconButton"
@ -45,7 +45,14 @@ export const ModeSelector = ({
const [searchValue, setSearchValue] = React.useState("")
const searchInputRef = React.useRef<HTMLInputElement>(null)
const portalContainer = useRooPortal("roo-portal")
const { hasOpenedModeSelector, setHasOpenedModeSelector } = useExtensionState()
const {
hasOpenedModeSelector,
setHasOpenedModeSelector,
modeSortingMode,
pinnedModes,
togglePinnedMode,
customModeOrder,
} = useExtensionState()
const { t } = useAppTranslation()
const trackModeSelectorOpened = React.useCallback(() => {
@ -99,9 +106,44 @@ export const ModeSelector = ({
[descriptionSearchItems],
)
// Sort modes based on sorting preferences
const sortedModes = React.useMemo(() => {
let sorted = [...modes]
if (modeSortingMode === "manual" && customModeOrder && customModeOrder.length > 0) {
// Sort based on custom order
sorted.sort((a, b) => {
const aIndex = customModeOrder.indexOf(a.slug)
const bIndex = customModeOrder.indexOf(b.slug)
// If both are in custom order, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex
}
// If only one is in custom order, it comes first
if (aIndex !== -1) return -1
if (bIndex !== -1) return 1
// Otherwise maintain original order
return 0
})
} else {
// Alphabetical sorting (default)
sorted.sort((a, b) => a.name.localeCompare(b.name))
}
// Apply pinning - pinned modes come first
if (pinnedModes) {
const pinned = sorted.filter((mode) => pinnedModes[mode.slug])
const unpinned = sorted.filter((mode) => !pinnedModes[mode.slug])
sorted = [...pinned, ...unpinned]
}
return sorted
}, [modes, modeSortingMode, pinnedModes, customModeOrder])
// Filter modes based on search value using fuzzy search with priority.
const filteredModes = React.useMemo(() => {
if (!searchValue) return modes
if (!searchValue) return sortedModes
// First search in names/slugs.
const nameMatches = nameFzfInstance.find(searchValue)
@ -118,8 +160,13 @@ export const ModeSelector = ({
.map((result) => result.item.original),
]
return combinedResults
}, [modes, searchValue, nameFzfInstance, descriptionFzfInstance])
// Preserve the sorting order after filtering
const sortedFilteredResults = sortedModes.filter((mode) =>
combinedResults.some((result) => result.slug === mode.slug),
)
return sortedFilteredResults
}, [sortedModes, searchValue, nameFzfInstance, descriptionFzfInstance])
const onClearSearch = React.useCallback(() => {
setSearchValue("")
@ -230,29 +277,24 @@ export const ModeSelector = ({
</div>
) : (
<div className="py-1">
{filteredModes.map((mode) => (
<div
key={mode.slug}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center",
"hover:bg-vscode-list-hoverBackground",
mode.slug === value
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
data-testid="mode-selector-item">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">
{mode.description}
</div>
{/* Show pinned modes first if any */}
{pinnedModes &&
Object.keys(pinnedModes).length > 0 &&
filteredModes.filter((mode) => pinnedModes[mode.slug]).length > 0 && (
<>
{filteredModes
.filter((mode) => pinnedModes[mode.slug])
.map((mode) => renderModeItem(mode, true))}
{/* Separator between pinned and unpinned */}
{filteredModes.filter((mode) => !pinnedModes[mode.slug]).length > 0 && (
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
)}
</div>
{mode.slug === value && <Check className="ml-auto size-4 p-0.5" />}
</div>
))}
</>
)}
{/* Show unpinned modes */}
{filteredModes
.filter((mode) => !pinnedModes || !pinnedModes[mode.slug])
.map((mode) => renderModeItem(mode, false))}
</div>
)}
</div>
@ -301,4 +343,53 @@ export const ModeSelector = ({
</PopoverContent>
</Popover>
)
function renderModeItem(mode: (typeof modes)[0], isPinned: boolean) {
const isSelected = mode.slug === value
return (
<div
key={mode.slug}
onClick={() => handleSelect(mode.slug)}
className={cn(
"px-3 py-1.5 text-sm cursor-pointer flex items-center group",
"hover:bg-vscode-list-hoverBackground",
isSelected
? "bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground"
: "",
)}
data-testid="mode-selector-item">
<div className="flex-1 min-w-0">
<div className="font-bold truncate">{mode.name}</div>
{mode.description && (
<div className="text-xs text-vscode-descriptionForeground truncate">{mode.description}</div>
)}
</div>
<div className="flex items-center gap-1">
{isSelected && (
<div className="size-5 p-1 flex items-center justify-center">
<Check className="size-3" />
</div>
)}
<StandardTooltip content={isPinned ? t("chat:unpin") : t("chat:pin")}>
<Button
variant="ghost"
size="icon"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation()
togglePinnedMode?.(mode.slug)
vscode.postMessage({ type: "toggleModePin", text: mode.slug })
}}
className={cn("size-5 flex items-center justify-center", {
"opacity-0 group-hover:opacity-100": !isPinned && !isSelected,
"bg-accent opacity-100": isPinned,
})}>
<span className="codicon codicon-pin text-xs opacity-50" />
</Button>
</StandardTooltip>
</div>
</div>
)
}
}

View file

@ -43,6 +43,18 @@ export interface ExtensionStateContextType extends ExtensionState {
mdmCompliant?: boolean
hasOpenedModeSelector: boolean // New property to track if user has opened mode selector
setHasOpenedModeSelector: (value: boolean) => void // Setter for the new property
// Mode and API profile sorting settings
modeSortingMode?: "alphabetical" | "manual"
setModeSortingMode: (value: "alphabetical" | "manual") => void
pinnedModes?: Record<string, boolean>
setPinnedModes: (value: Record<string, boolean>) => void
togglePinnedMode: (modeSlug: string) => void
customModeOrder?: string[]
setCustomModeOrder: (value: string[]) => void
apiProfileSortingMode?: "alphabetical" | "manual"
setApiProfileSortingMode: (value: "alphabetical" | "manual") => void
customApiProfileOrder?: string[]
setCustomApiProfileOrder: (value: string[]) => void
alwaysAllowFollowupQuestions: boolean // New property for follow-up questions auto-approve
setAlwaysAllowFollowupQuestions: (value: boolean) => void // Setter for the new property
followupAutoApproveTimeoutMs: number | undefined // Timeout in ms for auto-approving follow-up questions
@ -224,6 +236,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
maxImageFileSize: 5, // Default max image file size in MB
maxTotalImageSize: 20, // Default max total image size in MB
pinnedApiConfigs: {}, // Empty object for pinned API configs
modeSortingMode: "alphabetical" as const, // Default mode sorting
pinnedModes: {}, // Empty object for pinned modes
customModeOrder: [], // Empty array for custom mode order
apiProfileSortingMode: "alphabetical" as const, // Default API profile sorting
customApiProfileOrder: [], // Empty array for custom API profile order
terminalZshOhMy: false, // Default Oh My Zsh integration setting
maxConcurrentFileReads: 5, // Default concurrent file reads
terminalZshP10k: false, // Default Powerlevel10k integration setting
@ -292,7 +309,26 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
switch (message.type) {
case "state": {
const newState = message.state!
setState((prevState) => mergeExtensionState(prevState, newState))
setState((prevState) => {
const merged = mergeExtensionState(prevState, newState)
// Handle new sorting settings if present
if ((newState as any).modeSortingMode !== undefined) {
;(merged as any).modeSortingMode = (newState as any).modeSortingMode
}
if ((newState as any).pinnedModes !== undefined) {
;(merged as any).pinnedModes = (newState as any).pinnedModes
}
if ((newState as any).customModeOrder !== undefined) {
;(merged as any).customModeOrder = (newState as any).customModeOrder
}
if ((newState as any).apiProfileSortingMode !== undefined) {
;(merged as any).apiProfileSortingMode = (newState as any).apiProfileSortingMode
}
if ((newState as any).customApiProfileOrder !== undefined) {
;(merged as any).customApiProfileOrder = (newState as any).customApiProfileOrder
}
return merged
})
setShowWelcome(!checkExistKey(newState.apiConfiguration))
setDidHydrateState(true)
// Update alwaysAllowFollowupQuestions if present in state message
@ -410,6 +446,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
marketplaceItems,
marketplaceInstalledMetadata,
profileThresholds: state.profileThresholds ?? {},
modeSortingMode: (state as any).modeSortingMode,
pinnedModes: (state as any).pinnedModes,
customModeOrder: (state as any).customModeOrder,
apiProfileSortingMode: (state as any).apiProfileSortingMode,
customApiProfileOrder: (state as any).customApiProfileOrder,
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
remoteControlEnabled: state.remoteControlEnabled ?? false,
@ -502,6 +543,26 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setHistoryPreviewCollapsed: (value) =>
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),
setModeSortingMode: (value) => setState((prevState) => ({ ...prevState, modeSortingMode: value }) as any),
setPinnedModes: (value) => setState((prevState) => ({ ...prevState, pinnedModes: value }) as any),
togglePinnedMode: (modeSlug) =>
setState((prevState) => {
const currentPinned = (prevState as any).pinnedModes || {}
const newPinned = {
...currentPinned,
[modeSlug]: !currentPinned[modeSlug],
}
// If the mode is now unpinned, remove it from the object
if (!newPinned[modeSlug]) {
delete newPinned[modeSlug]
}
return { ...prevState, pinnedModes: newPinned } as any
}),
setCustomModeOrder: (value) => setState((prevState) => ({ ...prevState, customModeOrder: value }) as any),
setApiProfileSortingMode: (value) =>
setState((prevState) => ({ ...prevState, apiProfileSortingMode: value }) as any),
setCustomApiProfileOrder: (value) =>
setState((prevState) => ({ ...prevState, customApiProfileOrder: value }) as any),
setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })),
setAutoCondenseContextPercent: (value) =>
setState((prevState) => ({ ...prevState, autoCondenseContextPercent: value })),